Compare commits

...

11 Commits

Author SHA1 Message Date
Chris An e2a14a430e fix(scanner): bound wide bucket-root fanout memory (#8961)
Root prefixes were excluded from ordinary compaction and force_compact only
flattened the root above a hardcoded 250k child ceiling, so buckets with
tens of thousands of top-level prefixes kept one cache entry each after
#8971. Compact wide roots outside checkpointed forward sweeps, and honor
force_compact's limit for root fanout.
2026-11-05 18:06:48 +00:00
Chris An 6b70584dde chore(ci): bump overtrue/cla-bot to v0.1.0 (#9188)
Pin CLA Check to f054da99 (v0.1.0) so built-in bot allowlist skips
cursoragent, Dependabot, GitHub Actions, Renovate, and Copilot without
requiring a CLA signature.

Co-authored-by: cursoragent <cursoragent@users.noreply.github.com>
2026-11-05 23:54:13 +08:00
Chris An d6210ac17b fix(s3,ecstore,table-catalog): bound work driven by client input (#9172)
* fix(ecstore,heal,select): bound storage-layer work driven by client input

Scan findings in the erasure store, heal, file metadata and S3 Select
layers, grouped because they share the disk walk, the multipart staging
area, delete recovery and the select snapshot lease:

- Multipart: upload ids are canonicalized before they name a staging path
  or a lock key (backlog#3027); trailing-slash keys are encoded like every
  other handler (backlog#3055); unlisted parts are removed at completion
  (backlog#3106); per-bucket uploads are found through a signed per-disk
  index instead of a scan of the shared staging area (backlog#3121);
  encrypted part ETags carry a per-upload secret so a write-only user
  cannot confirm plaintext guesses (backlog#3044).
- Delete and recovery: recovery walks are bounded per request
  (backlog#3038); a delete marker on a key whose xl.meta path is a
  directory, and sidecar names used as child keys, no longer wedge the
  journal (backlog#3035, backlog#3105); over-quota PUTs no longer list the
  bucket under the ledger lock (backlog#3128).
- Listing: scan_dir sends metadata without inline data (backlog#3052),
  counts delete markers against max-keys (backlog#3108), and rejects a
  marker that carries the cursor tag (backlog#3059).
- Heal: bucket metadata and heal records live in separate namespaces so
  DeleteBucket cannot erase a drive rebuild (backlog#3039), a deleted
  bucket is skipped instead of failing the rebuild (backlog#3040), and
  directory objects are encoded on readback (backlog#3061).
- Restore and tiering: RestoreObject preserves tags and the version's
  modification time (backlog#3073, backlog#3074); Snowball transport
  headers are not forwarded to the tier (backlog#3090); an epoch
  modification time round-trips through xl.meta (backlog#3063).
- Locks: PutObject takes its bucket locks after the body is staged
  (backlog#3131); SelectObjectContent releases its namespace locks once
  the snapshot is taken (backlog#3132).
- RPC and peers: the v3 signature check consumes the v2 nonce
  (backlog#3129); a bucket-metadata reload timeout no longer marks the
  peer offline (backlog#3124); replication target requests carry an
  operation deadline (backlog#3102); the scanner resumes its interrupted
  wait after a lifecycle write (backlog#3118).
- S3 Select: bounded schema inference, column cap and budgeted CSV
  batches, Parquet dictionary bounds and pre-planner projection checks
  (backlog#3075, backlog#3076, backlog#3077, backlog#3078).

* test(e2e): seed legacy duplicate bucket tags offline for the OPA contract

* fix(s3): authorize what handlers store and bound request-driven work

Scan findings in the S3 request layer (access hooks, object use cases,
HTTP server layers), grouped because they share the authorization
condition builder, the PutObject admission path and the response
layers. Builds on the storage-layer batch for the bounded recovery and
multipart APIs it calls.

Authorization and policy conditions:
- copy source is percent-decoded and version-canonical before the
  s3:x-amz-copy-source condition (backlog#3135); CopyObject authorizes
  the tag set it copies (backlog#3034); HeadObject with the replication
  check header still authorizes s3:GetObject (backlog#3047); the
  x-rustfs-source-version-id header feeds s3:versionid (backlog#3072);
  recursive force-delete with a REPLICA header authorizes every
  descendant as a delete (backlog#3070); include-deleted listings require
  s3:ListBucketVersions (backlog#3099); the metadata listing route keeps
  table isolation and uses the same prefix it authorizes (backlog#3053,
  backlog#3054); If-Match probing needs read permission (backlog#3097);
  Object Lambda checks s3:GetObjectVersion (backlog#3058); replication
  proxy fallback keeps ExistingObjectTag denies (backlog#3089); HeadObject
  returns lock and tag headers only with permission (backlog#3113);
  IPv4 clients on the dual-stack listener match IPv4 SourceIp conditions
  (backlog#3049); bucket policies are parsed once and cached
  (backlog#3032); keystone: access keys no longer verify with an empty
  secret (backlog#3043).
Admission and resource bounds:
- GetObject releases disk-read permits when the client stops reading
  (backlog#3023); PutObject takes buffer and foreground admission only
  after body bytes arrive and bounds the wait (backlog#3031,
  backlog#3084); xl.meta size is bounded on the write path
  (backlog#3100); XML compat layers stream instead of buffering whole
  bodies (backlog#3094); tier GETs stream the remote body (backlog#3046); on-demand migration pulls release permits on
  unread GETs (backlog#3110); SSE-KMS key policy covers UploadPart and
  completion (backlog#3130); MinIO admin decryption runs Argon2id off the
  async worker (backlog#3024).
Input validation and leaks:
- replication headers are validated (source ETag shape, original size)
  (backlog#3092, backlog#3093); control characters in keys and lifecycle
  rule IDs are rejected (backlog#3101, backlog#3117); Snowball member
  mtime needs s3:ReplicateObject (backlog#3083); DeleteObjects checks
  Content-MD5 and admin/STS reject SigV2 replays with altered query or
  body (backlog#3037, backlog#3026); request logs redact presigned
  queries and handler spans skip credentials (backlog#3068,
  backlog#3107); SSE-C listings and completions withhold plaintext
  metadata and checksums (backlog#3120, backlog#3050); CORS resolves the
  bucket like s3s does (backlog#3104); PutBucketTagging rejects duplicate
  keys (backlog#3126); notifications fire after a client disconnect
  (backlog#3127); ListenNotification streams re-check access
  (backlog#3119).

* fix(api): drop imports left unused by the base merge

* test(s3): move the KMS multipart denial test to the app layer

The test drives app-layer multipart use cases, so it belongs with them;
living in the SSE module it imported app code from the infra layer and
failed the layer dependency guard. The SSE test helpers it borrows are
exposed through the storage and app facades.

* fix(s3): keep the SSE-C key fingerprint on keyless multipart completion

The backlog#3050 fix withheld both the plaintext checksums and the
customer-key MD5 header from a CompleteMultipartUpload that did not
present the key. The checksums are a digest of the plaintext and stay
hidden; the key MD5 identifies the key only, and the S3 conformance suite
(test_copy_part_enc) expects it on a keyless completion, so it is echoed
again.

* fix(tier): stream GET bodies and document replication budgets

* fix(ecstore,s3): keep the multipart index and cold fill off disk lifetimes

Two lifecycle regressions surfaced once the storage and request batches
ran together against main:

- The multipart index owner cloned the disk's pinned publication root and
  bootstrapped on a task of its own. That kept the mount alive past the
  last LocalDisk owner (the disk lifecycle allows exactly three holders of
  the root FD) and left a stray task behind, which the local-disk and
  read-repair tests detect. The owner now keeps only the root path and
  reopens it for identity checks, and the bootstrap scan rides the disk's
  existing cleanup task, where stopping the disk abandons it and a
  reconnect resumes it.
- The disk-read admission wrapper defers releasing its permit until an
  in-flight read completes, so a cancelled client cannot widen disk
  concurrency. The cold-fill producer, however, drops a read it has
  already classified as stalled when its deadline fires, and that permit
  stayed pinned for the lifetime of the stuck read. The producer now
  abandons the admission explicitly on timeout.

* test(ecstore): pass the index owner to the Linux cleanup-loop test

* fix(s3): release cold-fill admission after watchdog handoff

* fix(s3,ecstore): close the round-1 adversarial review findings

Adversarial review of the combined request- and storage-layer batch found
fixes that fell short of their issues, two behaviour regressions for
legitimate clients and several unrequested costs:

- backlog#3038: a bulk DeleteObjects reserved the whole per-disk
  inspection allowance up front for every key in flight and exhausted the
  16 Mi request budget on any set wider than eight drives, failing healthy
  keys with SlowDown. Calls are now admitted while budget remains and
  charged for the entries they actually visited. Object-scoped discovery
  charges plain child keys at one entry per 64 names instead of one each,
  so deleting a missing key that names a folder prefix completes instead
  of answering 503 forever while a hostile prefix stays bounded. A peer
  still on the previous binary is reported
  as an unsupported participant, the repository's rolling-upgrade
  contract, instead of as a budget verdict.
- backlog#3037/#3026: a SigV2 configuration PUT without Content-MD5 was
  still replayable with another body; it is refused with MissingContentMD5
  while SigV4 keeps the optional-MD5 contract.
- backlog#3072: DeleteObject now resolves x-rustfs-source-version-id before
  authorization like PUT and COPY, so a replica delete cannot remove a
  version the policy never saw.
- backlog#3121: the multipart index no longer fsyncs a marker and four
  directories per multipart metadata write (it is rebuilt on every start),
  and ListMultipartUploads discovers roots concurrently.
- backlog#3077: Parquet SelectObjectContent decodes budget-sized batches
  instead of one row per batch.
- backlog#3031: a full upload buffer pool hands out an unpooled buffer
  instead of refusing the upload; the progress floor already bounds stalls.
- backlog#3023: the GET disk-read idle cut-off is configurable
  (RUSTFS_GET_DISK_READ_IDLE_TIMEOUT_SECS, default 120 s) so a paused
  client is not truncated after 30 s.
- backlog#3075: JSON Lines schema inference keeps the sample collected at
  the 4 MiB bound instead of failing the query.
- backlog#3024: compat admin decryption queues briefly for a slot instead
  of refusing the third concurrent request.
- backlog#3113: HEAD skips its permission probes for the bucket owner.
- backlog#3117: control characters in a lifecycle rule ID are rejected on
  PutBucketLifecycle as well.

The lock-crate publication FIFO serves backlog#3111 and moves to the
table-catalog PR. Also fixed: a lossy admin error message, an unused
s3-client function, two unused test-util features, a duplicated key
validation, the s3s footprint baseline, and a stale log event name in the
reverse-proxy runbook. The ODM multipart write-back test runs on the large
test stack like its sibling.

* fix(table-catalog): bound maintenance work and isolate table namespaces (#9174)

* fix(table-catalog): bound maintenance work and isolate table namespaces

Scan findings in the Iceberg table catalog:

- Metadata maintenance deleted every unreferenced *.metadata.json in a
  table's directory without checking whether a renamed table in another
  namespace still owned those files (backlog#3060); it now resolves the
  owning table through the directory index before deleting.
- Manifest lists and manifests were decoded with the embedded Avro
  writer schema and no recursion depth limit; a self-referencing record
  schema overflowed the stack (backlog#3080). Schemas are validated for
  depth before decoding.
- Compaction decoded every input Parquet file into Arrow batches held in
  memory; a decompression bomb OOM-killed the server (backlog#3085). Row
  groups are streamed through a byte budget and oversized inputs are
  refused.
- An S3 request for a key outside an indexed table prefix listed the
  bucket's whole namespaces/ catalog prefix and failed once it held more
  than 4096 objects (backlog#3086); a per-bucket directory index answers
  index misses without the listing.
- The maintenance planner read whole orphan files to obtain their
  modification time (backlog#3087); it reads metadata only.
- A client disconnect between persisting active_rename_id and clearing it
  left the bucket's catalog entry locked at 503 (backlog#3088); the rename
  runs detached from the request future and clears the flag on every exit.
- Table commits held the bucket publication fence until publication,
  starving every object write (backlog#3111); commits now hold it under a
  deadline and object writes are admitted between commits.

The directory index is managed through three new admin routes under
{warehouse}/catalog/directory, registered in the admin route policy.

The durable-strong store is left as is (its snapshot partitioning for
backlog#3109 and the strong-path deadline are not ported): that backing
is being retired separately, so the new tests cover the object backing.

* fix(table-catalog): close the review gaps in the scan-fix batch

Adversarial review of the table-catalog batch found the fixes narrower
than the issues they close and two cost regressions:

- backlog#3085: the compaction footer check bounded nothing a page header
  could not override; parquet sizes its page buffers from the header.
  Walk every column chunk's page headers (Thrift compact, no decompression)
  and hold each page and the chunk total to the budget before decoding.
  The stored-input budget is per rewrite group, so a multi-group job no
  longer fails after writing earlier outputs.
- backlog#3086: the default index-miss scan still counted maintenance
  reports toward its 4096-object limit; only table entries count now, and
  the listing itself is bounded to 64 pages. The directory index remains
  an optional accelerator.
- backlog#3111: a create wrote its metadata file under the fence as the
  first durable mutation, which disarmed the deadline before the unbounded
  registration inventory. The write precedes publication now. The deadline
  gets its own knob (RUSTFS_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS,
  default 30 s, never below the lock-acquire timeout) instead of reusing
  the 5 s lock timeout, and the ownership inventory reads entries
  concurrently.
- Directory candidates are written only while a directory exists; the
  backfill covers earlier entries. A directory on the migration-fence
  path is reported as a conflict instead of a parse error.
- Dead test hooks, a stale comment and the support-matrix text that did
  not match the code are removed or corrected.

* fix(select): preserve complete samples and Parquet batch limits

* fix(s3,ecstore): close the round-2 adversarial review findings

DeleteObject resolved x-rustfs-source-version-id for authorization on
every request while storage consumes it only for a REPLICA request, so a
non-replica caller with s3:DeleteObjectVersion could trigger a plain
delete of the current version. The hook now resolves the header under
the same predicate as storage, and the regression test covers the
non-replica case.

HEAD no longer skips its permission probes for the bucket owner; an
explicit bucket-policy Deny on the metadata actions binds the owner too.

Multipart index pages verify each root against the staging area and
prune markers whose staging root is gone, under the marker's own lease
with a re-check, so the per-drive index stays proportional to live
uploads instead of every key ever multipart-initiated on the drive.

SigV2 configuration PUTs that declare an empty body need no Content-MD5:
there is no body to replay. The minimum-progress floor stays independent
of RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT; the knob's documentation now
says so instead of claiming 0 disables it. The quota bootstrap window
(503 until the background scan publishes the ledger) is kept, because
ledger admission must not come from scanner reporting; the compatibility
matrix now documents it.

The S3 compatibility matrix gains a section for the client-visible
validation bounds, and the drive-timeout tuning guide tabulates
RUSTFS_GET_DISK_READ_IDLE_TIMEOUT_SECS.

* fix(lock): queue table-bucket publication writers fairly

Table commits take the bucket publication fence as a write lock; under a
stream of commits the lock's try-and-retry acquisition let a new commit
jump ahead of object writers that had been waiting longer, which is part
of what starved them (backlog#3111). Requests for the publication lock
path now take a FIFO ticket per shard: a waiter is admitted only when it
is at the head of the queue, cancelled waiters release their turn, and
batch acquisitions join the same queue. Other lock paths are unchanged.

This moved here from the request-layer batch, where no cited issue
covered it; it serves the table-catalog issue this PR closes.

* fix(table-catalog): close the round-2 adversarial review findings

Parquet page-header walker: read the size fields by field id as parquet
does, accepting I16/I32/I64 wire kinds and rejecting any other kind, so
a header encoding uncompressed_page_size as I64 can no longer bypass the
budget while parquet allocates it. Bound every list, set and map count by
the bytes remaining in the chunk, and reject bool collection elements
(parquet skips them with zero width, so either width would desynchronize
the two parsers); this removes the 2^63-iteration spin on LIST<BOOL>.
Start each column-chunk walk at the dictionary page offset whenever the
footer declares one, exactly as parquet's byte_range does, instead of
min(dictionary, data).

Footer pre-walk: before parquet decodes a footer, walk FileMetaData with
the hardened cursor and charge the declared row_groups count times the
RowGroupMetaData size to the decode budget, because parquet 59 reserves
that Vec before reading one element.

Preparation deadline: clamp the
RUSTFS_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS knob to 86,400
seconds so Instant + Duration cannot overflow.

Index-miss scan: after each listing page, jump the cursor past the
maintenance subtree the page ended in, and allow one extra page per table
entry already listed, so job-report history scales with the catalog while
a report flood under one table stays bounded.

Migration fence: read the fence-path object once and decide from its
stored kind whether it is directory state or a fence.

Expired or failed create: remove the initial metadata file when the
registration fails before any catalog write may have begun; nothing else
references it. Correct the comment and support matrix that claimed
maintenance reclaims it.

Tests cover each walker divergence, the footer row-group reservation, the
deadline ceiling, the paging budget for honest catalogs and for a report
flood, and the removal of the unpublished metadata file.

* test(admin): register the catalog directory routes in the route matrix

The table-catalog batch added GET, POST and DELETE
{warehouse}/catalog/directory on both the Iceberg and the compat admin
prefixes without listing them in the admin route matrix, so
test_admin_route_matrix_matches_registered_routes failed on CI.

* fix(s3,ecstore): close the round-3 adversarial review findings

Multipart index pages now treat the cursor as the last examined marker
rather than the last root, so a page that pruned a stale marker still
validates and ListMultipartUploads keeps using the index. A marker is
written before its staging root is published, so pruning spares markers
younger than a five-minute grace period and an existing marker is
touched on every new upload; the prune re-checks both under the marker
lease. Bounded delete-recovery inspection rides the durable-mutation
lane like the unbounded path instead of the one-worker foreground lane.
The on-demand-migration inline body budget scales with the object size
(at least 64 KiB/s) instead of a flat 120 s. The control-body digest
service honors RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT=0 as "no
inactivity timeout" like every other reader of that knob.

Quota reconciliation throttle objects are deleted together with a
retired generation's ledger, so bucket turnover leaves no residue. The PUT guard
test shim is gone; tests call the shared guard. Bounded DiskAPI defaults
answer MethodNotAllowed like their siblings. Stale
budget docs, an unreachable CSV arm, an always-Some limit parameter,
commented-out constants and a misplaced doc comment are cleaned up;
weak test assertions name the expected error; lifecycle rule-ID control
characters gain a test. The compatibility matrix records the CopyObject
tag-copy permission, the exact SigV2 empty-body predicate, replica and
migration writes of control-character keys, and the SigV2 DeleteObjects
digest rule.

* fix(table-catalog,heal): close the round-3 review findings

A. Pack compaction rewrite groups to min(max-rewrite-bytes-per-job,
   TABLE_COMPACTION_MAX_STORED_BYTES), reject an over-ceiling group before
   any output is written, and name the ceiling in the commit-path errors;
   document the effective cap in the support matrix and scheduler runbook.
B. Keep TaskCancelled/TaskTimeout classification on replacement passes:
   the namespace verification runs only for completed or storage-failed
   passes, in the erasure healer and the task bucket prepass.
C. Compare the replacement bucket pass per bucket: a recorded identity
   that is gone or replaced still errors or restarts the pass, buckets
   created mid-pass are appended to the identities, pending_buckets and a
   persisted positional order without rewinding progress or the
   checkpoint; verification ignores additions.
D. Read the migration-fence object once in the object-backed write permit
   and dispatch on its kind.
E. Drop the strong store's copy of maintenance_cleanup_roots.
F. Remove the unused test hooks fail_put_prefix, corrupt_put_prefix and
   pause_after_metadata; make compact_parquet_data_files and
   PublicationTicket.state private.
G. Document the body-revision identity, the legacy revalidation budget
   and its HTTP 503 in the support matrix.
H. Define HEAL_REPLACEMENT_RECOVERY_DIR once in ecstore and consume it
   from heal through the storage_api facade.

* fix(ecstore): cap the index pages one disk may answer

Pruned multipart index pages may carry few roots, so the page cursor no
longer bounds how many pages a peer can make a listing fetch. Cut a disk
off after 4096 pages (about one million examined markers) so it
contributes no coverage and the listing falls back to the staging scan.

* fix(ecstore,table-catalog): close the round-4 adversarial review findings

Multipart index markers dated in the future (a backwards clock step
between recording a marker and publishing its staging root) were judged
old and could be pruned while their upload was in flight; a future-dated
marker is now kept. The marker touch on EEXIST is best effort, so a
filesystem that refuses to set times no longer fails the upload commit.
Pruning runs on the durable-mutation lane instead of the one-worker
foreground lane.

Quota reconciliation keeps one throttle object per bucket incarnation (a
quota revision change replaces it instead of orphaning the previous
revision's object), the retirement cleanup deletes it ahead of the ledger
and also when the ledger is already gone, and a scan the retirement
already refused writes nothing back. Bounded DiskAPI defaults answer
UnsupportedDisk, the verdict the aggregate gives a peer without the
protocol. The compaction ceiling pre-check runs over every rewrite group
before any output is written, and the worker's ceiling error no longer
names a placeholder group.

Docs: the inline on-demand-migration client budget, the protocol-2
downgrade conflict of replacement passes, the exact footprint numbers.
2026-11-05 23:32:54 +08:00
Chris An 158aa276e9 fix(storage): bound heal retries and recover pool metadata (#9164)
* fix(ecstore): retry metadata writes rejected before staging

* fix(ecstore): reuse registered disks during startup

* fix(ecstore): retain remote health monitors during reconnect

* test(heal): release the commit barrier during graceful shutdown

* test(e2e): register graceful shutdown barrier regressions

* fix(ecstore): roll back interrupted pool metadata commits

* test(upgrade): honor the 1.0.4 delete recovery protocol

* fix(heal): bound admission during object rate pressure

* test(e2e): handle rebalance completion before stop

* test(e2e): observe rebalance activity before the start reply

* test(rebalance): hold the first run until stop cancels admission
2026-11-05 21:42:19 +08:00
Hauser Zhi 3362421240 fix(obs): add fine-grained object lock histograms (#9177)
Use explicit buckets that preserve millisecond lock acquisitions while retaining visibility into long-held delete and recovery locks. Add an exporter-backed regression test for both histogram instruments.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-05 08:11:08 +00:00
GatewayJ 57df9181d0 fix(audit): preserve OIDC identity in S3 request audits (#9178) 2026-11-05 06:03:33 +00:00
Hauser Zhi 371f5e4ffc fix(ecstore): streamline lock RPC and listing ownership (#9176)
* fix(ecstore): preserve lock cleanup and listing trace context

Own remote lock admission and dispatched RPC cleanup across caller cancellation. Reuse bounded late-result cleanup and retain the listing request span in metacache tasks.

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

* perf(ecstore): streamline lock RPC and listing fanout

Poll RPCs in callers and transfer pending work before channel eviction. Share walk options across producers and borrow cached peer keys. Keep deadline classification deterministic.

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-05 05:59:05 +00:00
cxymds 8a5a2c9c93 fix(s3): treat duplicate CreateBucket as owned by the requester (#9167)
* fix(s3): treat duplicate CreateBucket as owned by the requester

The duplicate-bucket response was decided from the requester's root-owner
flag, so every IAM user recreating a bucket received 409
BucketAlreadyExists, including a user recreating a bucket it created.
BucketAlreadyExists means the bucket belongs to another account; a
deployment owns a single namespace, so the answer is purely region-based:
the default region returns the legacy 200 OK, any other region returns
BucketAlreadyOwnedByYou.

Both the existing-bucket fast path and the BucketExists path now share one
helper. Authorization still runs first, so a requester without
s3:CreateBucket gets AccessDenied and cannot probe bucket existence.

Refs #8493

* test(ci): refresh e2e selections for duplicate CreateBucket

The two new create_bucket_region cases join the e2e-smoke and e2e-full
selections, which main also extended with multipart_upload_id tests.

smoke is measured from a local nextest listing: removing the two new cases
reproduces main's committed digest exactly. e2e-full is derived the same way
from main's listing artifact (run 41933823399, babfeaed0) plus the two
multipart_upload_id cases, which reproduces main's committed linux digest
before the new cases are added; CI re-verifies both platforms.

test_bucket_create_exists_nonowner asserts BucketAlreadyExists for a second
identity, which a single-owner deployment cannot produce. Move it to
excluded_tests.txt with the deployment-ownership rationale; the IAM-user
duplicate paths stay covered by crates/e2e_test/src/create_bucket_region_test.rs.
2026-11-05 03:06:50 +00:00
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
227 changed files with 31383 additions and 3207 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=2f97cbbeb2dbf52919b704a3d07336c162a41f95079d941d74251f9279c494e6
sha256-darwin=2f97cbbeb2dbf52919b704a3d07336c162a41f95079d941d74251f9279c494e6
sha256-linux=348fa5eed9ae58962c06b33283ff4f15122cff97bce9b4eb1e3ffcb227264b0b
sha256-darwin=348fa5eed9ae58962c06b33283ff4f15122cff97bce9b4eb1e3ffcb227264b0b
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=7c705942d3a9e583ebaa85845daa21c4f81fdbf36def24db931aaef38636c53e
sha256-linux=5c33dc395233671cb77452f8fb12900ecdbeb368632222b96835d310bce1aab8
sha256-darwin=219ac11e720b3f54dfee4d5d5e95171785ba159b2fe01db3037a8f5061c1ad32
sha256-linux=2d34698cc8ec52d5b6c37ac612919a1d21f36cd56a1af539a7522a4eea518e26
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=3814cdde8cacf4187e18188d342ed70931cc32e268e42e5bb438d565bb4d0f81
sha256-linux=663025a80273579f95ae3f49af02c0afcbfce94df09701c360a13e607b7637db
sha256-darwin=e84e04208f19dc0596b10d4d1b799ce43d6cc431415902f9c5458aa8232bc2d8
sha256-linux=1f9c7add72c44d1c12ef4ac050bb7917431be657c0c90b29f70c3df56f8bbae3
+1 -1
View File
@@ -1 +1 @@
sha256=00f8a56b75990b487a594b97c36610b94476a6944ba00473e37187ea654d42bf
sha256=5bdd4c2eadbc41625ae3184fff34062e000d328108436259b2b7c4fe2531ecaf
+1 -1
View File
@@ -701,7 +701,7 @@ test-group = 'ecstore-serial-flaky'
[profile.e2e-smoke]
default-filter = """
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(/^reliant::lifecycle::/)
| test(/^conditional_delete_objects_test::/)
+3 -3
View File
@@ -22,9 +22,9 @@ The following files in the project root are essential for Docker operations:
| 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` |
| **`Dockerfile`** | **Production Image (Alpine)**<br>Lightweight image using musl libc. Downloads pre-built binaries from GitHub Releases. | `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.source`** | **Development Image**<br>Builds RustFS from source code. Includes build tools. Ideal for local development and CI. | `docker build -f Dockerfile.source .` |
| **`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. Includes the bundled bootstrap configuration. | `docker build -f Dockerfile.glibc .` |
| **`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
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
- name: Run CLA Bot
if: github.event_name != 'merge_group'
uses: overtrue/cla-bot@5e5929846a60cfb42b16d0d38e135ef67d94fc88 # v0.0.9
uses: overtrue/cla-bot@f054da99d5777e47e1af9933737b9c0c5584be8d # v0.1.0
with:
github-token: ${{ github.token }}
registry-token: ${{ steps.registry-token.outputs.token }}
Generated
+4
View File
@@ -10090,6 +10090,7 @@ version = "1.0.2"
dependencies = [
"bytes",
"hotpath",
"metrics",
"rustfs-io-metrics",
"thiserror 2.0.21",
"tokio",
@@ -10276,6 +10277,7 @@ dependencies = [
"hotpath",
"parking_lot 0.13.0",
"rand 0.10.3",
"rustfs-common",
"rustfs-io-metrics",
"rustfs-utils",
"serde",
@@ -10788,8 +10790,10 @@ version = "1.0.2"
dependencies = [
"async-trait",
"bytes",
"csv-core",
"datafusion",
"derive_builder",
"flate2",
"futures",
"hotpath",
"parking_lot 0.13.0",
+1
View File
@@ -191,6 +191,7 @@ quinn = { version = "0.11.12", default-features = false, features = [
# Serialization and Data Formats
apache-avro = { version = "0.22.0", features = ["snappy", "zstandard"] }
bytes = { version = "1.12.1" }
csv-core = "0.1.13"
bytesize = "2.7.0"
byteorder = "1.5.0"
flatbuffers = "25.12.19"
+11 -3
View File
@@ -16,6 +16,7 @@ FROM alpine:3.24.2 AS build
ARG TARGETARCH
ARG RELEASE=latest
ARG RC_RELEASE=latest
RUN apk add --no-cache ca-certificates curl jq unzip
WORKDIR /build
@@ -69,6 +70,9 @@ RUN set -eux; \
chmod +x /build/rustfs; \
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
@@ -95,14 +99,18 @@ RUN apk upgrade --no-cache && \
ca-certificates \
coreutils \
curl \
jq \
tzdata \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530"
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
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 && \
adduser -u 10001 -G rustfs -S rustfs -D && \
@@ -124,6 +132,6 @@ VOLUME ["/data"]
USER rustfs
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["rustfs"]
+13 -4
View File
@@ -12,7 +12,10 @@
# See the License for the specific language governing permissions and
# 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; \
export DEBIAN_FRONTEND=noninteractive; \
@@ -21,6 +24,7 @@ RUN set -eux; \
build-essential \
ca-certificates \
curl \
jq \
git \
pkg-config \
libssl-dev \
@@ -36,6 +40,7 @@ COPY . .
RUN ./scripts/static.sh
RUN cargo run --bin gproto
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; \
groupadd -g 10001 rustfs; \
@@ -50,8 +55,12 @@ RUN set -eux; \
chown -R rustfs:rustfs /data /logs /app; \
chmod 0750 /data /logs
COPY entrypoint.sh /entrypoint.sh
RUN install -m 0755 /usr/src/rustfs/target/release/rustfs /usr/bin/rustfs && chmod +x /entrypoint.sh
COPY entrypoint.sh /etc/rustfs/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" \
RUST_LOG="warn" \
@@ -63,5 +72,5 @@ ENV RUSTFS_VOLUMES="/data" \
EXPOSE 9000 9001
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["/usr/bin/rustfs"]
+11 -3
View File
@@ -16,6 +16,7 @@ FROM ubuntu:26.04 AS build
ARG TARGETARCH
ARG RELEASE=latest
ARG RC_RELEASE=latest
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
@@ -76,6 +77,9 @@ RUN set -eux; \
chmod +x /build/rustfs; \
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
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 \
ca-certificates \
curl \
jq \
tzdata \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530" \
&& rm -rf /var/lib/apt/lists/*
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 && \
useradd -u 10001 -g rustfs -m -s /sbin/nologin rustfs && \
@@ -131,6 +139,6 @@ VOLUME ["/data"]
USER rustfs
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["rustfs"]
+19 -7
View File
@@ -28,17 +28,19 @@ ARG TARGETPLATFORM
ARG BUILDPLATFORM
ARG TARGETARCH
ARG RUSTFS_BUILD_FEATURES=""
ARG RC_RELEASE=latest
# -----------------------------
# Build stage
# -----------------------------
FROM rust:1.97.1-trixie AS builder
FROM rust:1.99.1-trixie AS builder
# Re-declare args after FROM
ARG TARGETPLATFORM
ARG BUILDPLATFORM
ARG TARGETARCH
ARG RUSTFS_BUILD_FEATURES
ARG RC_RELEASE
# Debug: print platforms
RUN echo "Build info -> BUILDPLATFORM=${BUILDPLATFORM}, TARGETPLATFORM=${TARGETPLATFORM}"
@@ -52,6 +54,7 @@ RUN set -eux; \
build-essential \
ca-certificates \
curl \
jq \
git \
pkg-config \
libssl-dev \
@@ -166,6 +169,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
;; \
esac
RUN sh /usr/src/rustfs/scripts/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /usr/local/bin/rc
# -----------------------------
# Development stage (keeps toolchain)
# -----------------------------
@@ -199,10 +204,13 @@ ENV RUSTFS_VOLUMES="/data" \
# We rely on cargo run to build and run
EXPOSE 9000 9001
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY entrypoint.sh /etc/rustfs/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", "--"]
# -----------------------------
@@ -227,6 +235,7 @@ RUN set -eux; \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
jq \
tzdata \
coreutils; \
rm -rf /var/lib/apt/lists/*
@@ -248,8 +257,11 @@ RUN set -eux; \
# Copy the freshly built binary and the entrypoint
COPY --from=builder /usr/local/bin/rustfs /usr/bin/rustfs
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /usr/bin/rustfs /entrypoint.sh
COPY --from=builder /usr/local/bin/rc /usr/bin/rc
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)
ENV RUSTFS_VOLUMES="/data" \
@@ -263,5 +275,5 @@ EXPOSE 9000
VOLUME ["/data"]
# Keep root here; entrypoint will drop privileges using chroot --userspec
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
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 @@
+6 -1
View File
@@ -175,12 +175,17 @@ pub const ENV_HTTP_SOCKET_RECV_BUFFER_BYTES: &str = "RUSTFS_HTTP_SOCKET_RECV_BUF
pub const DEFAULT_HTTP_SOCKET_RECV_BUFFER_BYTES: usize = 0;
/// Environment variable for the S3 request-body inter-chunk read timeout
/// (seconds). Default: 300. Set to 0 to disable.
/// (seconds). Default: 300. Set to 0 to disable the inactivity timeout.
///
/// This bounds how long a `PutObject`/upload may wait for the *next* body chunk
/// while more bytes are still expected. It resets on every chunk, so it does not
/// penalize slow-but-progressing uploads — it only fires when a peer sends a
/// partial body and then goes silent *without* closing the connection (no EOF).
/// Independently of this knob, `PutObject` and `UploadPart` require at least
/// 64 KiB of body progress per 60 seconds of server wait (the minimum-progress
/// floor, logged as `upload_body_progress_stalled`); it bounds trickle uploads
/// that would otherwise pin upload admission indefinitely, and it stays in
/// force when this timeout is set to 0.
/// A reverse proxy or CDN that forwards a truncated body this way would
/// otherwise make RustFS wait forever for bytes that never arrive; with this
/// timeout the stalled read is aborted and logged with the received/expected
@@ -502,10 +502,163 @@ mod tests {
assert!(response.contains(if expected == reqwest::StatusCode::FORBIDDEN {
"<Code>AccessDenied</Code>"
} else {
"<Code>BucketAlreadyExists</Code>"
"<Code>BucketAlreadyOwnedByYou</Code>"
}));
}
}
Ok(())
}
/// A deployment owns a single bucket namespace, so an IAM user that recreates
/// an existing bucket is the owner, not another account. In the default region
/// every such request returns the legacy idempotent `200 OK` (AWS CreateBucket
/// semantics), including buckets created by root and by another IAM user.
#[tokio::test]
async fn test_create_bucket_duplicate_in_default_region_is_idempotent_for_iam_user()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
let root = env.create_s3_client();
admin_create_user(&env, "duplicate-iam", "duplicate-iam-secret").await?;
admin_attach_user_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
"readwrite",
"duplicate-iam",
)
.await?;
let iam = Client::from_conf(build_test_s3_config(
&env.url,
"duplicate-iam",
"duplicate-iam-secret",
None,
"duplicate-create",
));
root.create_bucket()
.bucket("duplicate-root-owned")
.send()
.await
.expect("root creates the first bucket");
iam.create_bucket()
.bucket("duplicate-root-owned")
.send()
.await
.expect("an IAM user recreating a root-owned bucket in us-east-1 gets 200 OK");
iam.create_bucket()
.bucket("duplicate-iam-owned")
.send()
.await
.expect("the IAM user creates its own bucket");
iam.create_bucket()
.bucket("duplicate-iam-owned")
.send()
.await
.expect("the same IAM user recreating its bucket gets 200 OK");
root.create_bucket()
.bucket("duplicate-iam-owned")
.send()
.await
.expect("root recreating an IAM-created bucket gets 200 OK");
Ok(())
}
/// Outside the default region the same ownership answer becomes
/// `409 BucketAlreadyOwnedByYou` for every authorized requester. Requests that
/// lack `s3:CreateBucket` still fail authorization before the duplicate check.
#[tokio::test]
async fn test_create_bucket_duplicate_outside_default_region_reports_owned_by_you() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
let region = "eu-west-1";
let root = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new(region))
.build(),
);
admin_create_user(&env, "duplicate-eu-iam", "duplicate-eu-iam-secret").await?;
admin_attach_user_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
"readwrite",
"duplicate-eu-iam",
)
.await?;
let iam = Client::from_conf(
build_test_s3_config(&env.url, "duplicate-eu-iam", "duplicate-eu-iam-secret", None, "duplicate-create")
.to_builder()
.region(Region::new(region))
.build(),
);
admin_create_user(&env, "duplicate-eu-readonly", "duplicate-eu-readonly-secret").await?;
admin_attach_user_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
"readonly",
"duplicate-eu-readonly",
)
.await?;
let readonly = Client::from_conf(
build_test_s3_config(
&env.url,
"duplicate-eu-readonly",
"duplicate-eu-readonly-secret",
None,
"duplicate-create",
)
.to_builder()
.region(Region::new(region))
.build(),
);
for (client, bucket) in [(&root, "duplicate-eu-root"), (&iam, "duplicate-eu-iam")] {
client
.create_bucket()
.bucket(bucket)
.send()
.await
.unwrap_or_else(|error| panic!("{bucket} must be created: {error:?}"));
// Both the creator and the other authenticated identity own the namespace.
for creator in [&root, &iam] {
let error = creator
.create_bucket()
.bucket(bucket)
.send()
.await
.expect_err("a duplicate create outside us-east-1 must report ownership");
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("BucketAlreadyOwnedByYou"),
"{bucket}"
);
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(409), "{bucket}");
}
}
let denied = readonly
.create_bucket()
.bucket("duplicate-eu-root")
.send()
.await
.expect_err("a requester without s3:CreateBucket must be denied before the duplicate check");
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
Ok(())
}
}
@@ -25,7 +25,8 @@
use super::harness::{
DistCluster, DistLayout, TestResult, cluster_admin, cluster_admin_ok, decommission_status_json, get_object_bytes,
payload_for, put_object, s3_probe_client, s3_probe_error_code, sha256_hex, unique_bucket, wait_until,
payload_for, put_object, rebalance_complete, rebalance_status_json, s3_probe_client, s3_probe_error_code, sha256_hex,
unique_bucket, wait_until,
};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment};
@@ -47,6 +48,7 @@ use tokio::time::{Instant, sleep};
use uuid::Uuid;
pub(crate) const MOVEMENT_BARRIER_DIR_ENV: &str = "RUSTFS_E2E_DATA_MOVEMENT_BARRIER_DIR";
pub(crate) const STAGE_AFTER_ENTRY_ADMISSION: &str = "after_entry_admission";
pub(crate) const STAGE_BEFORE_TARGET_COMMIT: &str = "before_target_commit";
pub(crate) const STAGE_BEFORE_SOURCE_CLEANUP: &str = "before_source_cleanup";
pub(crate) const STAGE_AFTER_RESPONSIBILITY_PERSISTED: &str = "after_responsibility_persisted";
@@ -1166,8 +1168,21 @@ pub(crate) async fn clear_decommission(cluster: &RustFSTestClusterEnvironment, p
.await
}
pub(crate) async fn stop_rebalance(cluster: &RustFSTestClusterEnvironment) -> TestResult<String> {
cluster_admin_ok(cluster, Method::POST, "/rustfs/admin/v3/rebalance/stop", None).await
pub(crate) async fn stop_rebalance(cluster: &RustFSTestClusterEnvironment, expected_id: &str) -> TestResult<String> {
let path = "/rustfs/admin/v3/rebalance/stop";
let (status, response) = cluster_admin(cluster, Method::POST, path, None).await?;
if status.is_success() {
return Ok(response);
}
// The run can finish between the active observation and the stop request.
// Only completion of that same run makes a not-started response acceptable.
if status == StatusCode::NOT_FOUND
&& response.contains("<Code>NoSuchResource</Code>")
&& rebalance_complete(&rebalance_status_json(cluster).await?, expected_id)?
{
return Ok(response);
}
Err(format!("POST {path} failed: {status} {response}").into())
}
pub(crate) fn decommission_pool_entry(status: &Value, pool_id: usize) -> TestResult<Value> {
@@ -1585,6 +1600,129 @@ pub(crate) async fn assert_every_node_serves(dist: &DistCluster, bucket: &str, l
mod tests {
use super::*;
const REBALANCE_NOT_STARTED: &str =
"<Error><Code>NoSuchResource</Code><Message>pool rebalance is not started</Message></Error>";
async fn mock_rebalance_stop(
stop_status: StatusCode,
stop_body: &str,
run_status: Value,
) -> TestResult<(TestResult<String>, Vec<String>)> {
use bytes::Bytes;
use http_body_util::Full;
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio::task::JoinSet;
let listener = TcpListener::bind("127.0.0.1:0").await?;
let mut cluster = RustFSTestClusterEnvironment::new(1).await?;
cluster.nodes[0].url = format!("http://{}", listener.local_addr()?);
let stop_body = stop_body.to_string();
let (requests_tx, mut requests_rx) = mpsc::unbounded_channel();
let mut server = JoinSet::new();
server.spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(connection) => connection,
Err(error) => return Err::<(), Box<dyn std::error::Error + Send + Sync>>(error.into()),
};
let stop_body = stop_body.clone();
let run_status = run_status.clone();
let requests_tx = requests_tx.clone();
let handler = service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
let route = format!("{} {}", request.method(), request.uri().path());
requests_tx.send(route.clone()).expect("record mock admin request");
let (status, body) = match route.as_str() {
"POST /rustfs/admin/v3/rebalance/stop" => (stop_status, stop_body.clone()),
"GET /rustfs/admin/v3/rebalance/status" => (StatusCode::OK, run_status.to_string()),
_ => (StatusCode::BAD_REQUEST, "unexpected admin request".to_string()),
};
async move { http::Response::builder().status(status).body(Full::new(Bytes::from(body))) }
});
hyper::server::conn::http1::Builder::new()
.keep_alive(false)
.serve_connection(TokioIo::new(stream), handler)
.await?;
}
});
let outcome = tokio::time::timeout(Duration::from_secs(5), stop_rebalance(&cluster, "run-1")).await?;
server.abort_all();
let requests = std::iter::from_fn(|| requests_rx.try_recv().ok()).collect();
Ok((outcome, requests))
}
fn completed_rebalance() -> Value {
serde_json::json!({
"id": "run-1",
"pools": [
{ "id": 0, "status": "Completed", "progress": { "versions": 113 } },
{ "id": 1, "status": "None" }
]
})
}
#[tokio::test]
async fn rebalance_stop_success_does_not_query_completion() -> TestResult {
let (outcome, requests) = mock_rebalance_stop(StatusCode::OK, "", Value::Null).await?;
assert_eq!(outcome?, "");
assert_eq!(requests, ["POST /rustfs/admin/v3/rebalance/stop"]);
Ok(())
}
#[tokio::test]
async fn rebalance_stop_accepts_completed_run_after_not_started_response() -> TestResult {
let (outcome, requests) =
mock_rebalance_stop(StatusCode::NOT_FOUND, REBALANCE_NOT_STARTED, completed_rebalance()).await?;
outcome?;
assert_eq!(
requests,
[
"POST /rustfs/admin/v3/rebalance/stop",
"GET /rustfs/admin/v3/rebalance/status"
]
);
Ok(())
}
#[tokio::test]
async fn rebalance_stop_rejects_unproven_completion_and_other_errors() -> TestResult {
let complete = completed_rebalance();
let mut invalid = vec![Value::Null];
for (pointer, value) in [
("/id", serde_json::json!("run-2")),
("/pools/0/status", serde_json::json!("Started")),
("/pools/0/status", serde_json::json!("Failed")),
("/pools/0/status", serde_json::json!("Stopped")),
("/pools/0/progress/versions", serde_json::json!(0)),
] {
let mut status = complete.clone();
*status.pointer_mut(pointer).expect("completion fixture field") = value;
invalid.push(status);
}
let mut pending = complete.clone();
pending["stopPropagation"] = serde_json::json!({ "pendingTerminalReload": true });
invalid.push(pending);
let mut failed = complete.clone();
failed["pools"][0]["lastError"] = serde_json::json!("migration failed");
invalid.push(failed);
for status in invalid {
let (outcome, _) = mock_rebalance_stop(StatusCode::NOT_FOUND, REBALANCE_NOT_STARTED, status.clone()).await?;
assert!(outcome.is_err(), "unproven completion accepted: {status}");
}
for (code, body) in [
(StatusCode::FORBIDDEN, REBALANCE_NOT_STARTED),
(StatusCode::INTERNAL_SERVER_ERROR, REBALANCE_NOT_STARTED),
(StatusCode::NOT_FOUND, "<Error><Code>NoSuchBucket</Code></Error>"),
] {
let (outcome, requests) = mock_rebalance_stop(code, body, complete.clone()).await?;
assert!(outcome.is_err(), "unrelated error accepted: {code} {body}");
assert_eq!(requests, ["POST /rustfs/admin/v3/rebalance/stop"]);
}
Ok(())
}
#[test]
fn version_ids_normalize_null_spellings() {
for spelling in [None, Some(""), Some("null"), Some("00000000-0000-0000-0000-000000000000")] {
@@ -28,7 +28,7 @@ use super::harness::{
wait_for_rebalance_active, wait_for_rebalance_complete,
};
use super::scanner_heal_topology_fixture::{
LiveWorkload, MOVEMENT_ACTIVE_TIMEOUT, MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL,
LiveWorkload, MOVEMENT_ACTIVE_TIMEOUT, MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL, STAGE_AFTER_ENTRY_ADMISSION,
STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP, STAGE_BEFORE_TARGET_COMMIT, TopologyManifest, TopologyRun,
assert_bucket_durability_strict, assert_every_node_serves, assert_probe_objects, assert_process_durability_strict,
assert_seed_pool_census_unchanged, assert_single_pool_placement, cancel_decommission, capture_seed_pool_census,
@@ -274,6 +274,7 @@ async fn wait_for_rebalance_stopped_or_completed(run: &TopologyRun, rebalance_id
#[tokio::test]
async fn scanner_heal_topology_rebalance_survives_live_writes_stop_and_restart() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("rebalance").await?;
let manifest = seed_manifest(&run).await?;
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
@@ -288,23 +289,52 @@ async fn scanner_heal_topology_rebalance_survives_live_writes_stop_and_restart()
.map(|entry| (entry.key.clone(), entry.sha256.clone()))
.collect();
let first_id = start_rebalance(&run.dist.cluster).await?;
wait_for_rebalance_active(&run.dist.cluster, &first_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
let during_rebalance = put_probe_objects(&run.dist, &scratch, "during-rebalance", 4).await?;
let workload = tokio::spawn(run_live_workload(LiveWorkload {
clients: run.dist.clients()?,
bucket: scratch.clone(),
prefix: "live".to_string(),
writers: LIVE_WRITERS,
rounds: LIVE_ROUNDS,
baseline_bucket: manifest.plain_bucket.clone(),
baseline: baseline.clone(),
pause: Duration::from_millis(100),
}));
sleep(Duration::from_secs(2)).await;
stop_rebalance(&run.dist.cluster).await?;
let (first_outcome, first_status) = wait_for_rebalance_stopped_or_completed(&run, &first_id).await?;
let ops = workload.await??;
// Keep an admitted entry on the source until stop cancels admission. Start
// propagation may await that entry, so observe and stop before awaiting ACK.
let first_key = manifest.plain_key("external", 0)?;
run.movement_barrier
.arm_movement("rebalance", &manifest.plain_bucket, &first_key, STAGE_AFTER_ENTRY_ADMISSION)?;
let observe_first = async {
let (observed_id, reached) = tokio::time::timeout(MOVEMENT_ACTIVE_TIMEOUT, async {
let reached = run.movement_barrier.wait_reached(MOVEMENT_ACTIVE_TIMEOUT).await?;
let status = rebalance_status_json(&run.dist.cluster).await?;
let observed_id = status
.get("id")
.and_then(Value::as_str)
.ok_or("rebalance status omitted id")?;
wait_for_rebalance_active(&run.dist.cluster, observed_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((observed_id.to_string(), reached))
})
.await??;
let probes = put_probe_objects(&run.dist, &scratch, "during-rebalance", 4).await?;
let workload = run_live_workload(LiveWorkload {
clients: run.dist.clients()?,
bucket: scratch.clone(),
prefix: "live".to_string(),
writers: LIVE_WRITERS,
rounds: LIVE_ROUNDS,
baseline_bucket: manifest.plain_bucket.clone(),
baseline: baseline.clone(),
pause: Duration::from_millis(100),
});
let stop = async {
sleep(Duration::from_secs(2)).await;
stop_rebalance(&run.dist.cluster, &observed_id).await?;
let (outcome, status) = wait_for_rebalance_stopped_or_completed(&run, &observed_id).await?;
if outcome != "stopped" {
return Err("rebalance completed before stop; mid-run stop was not exercised".into());
}
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((outcome, status))
};
let (ops, (outcome, status)) = tokio::try_join!(workload, stop)?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((observed_id, reached, probes, ops, outcome, status))
};
let (first_id, (observed_id, first_reached, during_rebalance, ops, first_outcome, first_status)) =
tokio::try_join!(start_rebalance(&run.dist.cluster), observe_first)?;
if first_id != observed_id {
return Err(format!("rebalance start acknowledged {first_id}, but the stopped run was {observed_id}").into());
}
run.movement_barrier.disarm()?;
let live_state = expected_live_state(&ops);
for node_idx in 0..run.dist.cluster.nodes.len() {
@@ -333,8 +363,40 @@ async fn scanner_heal_topology_rebalance_survives_live_writes_stop_and_restart()
verify_from_every_node(&run.dist, &manifest, "after restart following the stop").await?;
verify_live_state(&run.dist.client(0)?, &scratch, &live_state, "after restart following the stop").await?;
let second_id = start_rebalance(&run.dist.cluster).await?;
wait_for_rebalance_active(&run.dist.cluster, &second_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
// A short second run can finish before the start response reaches us.
// Hold one remaining source entry until its active state is observed.
let mut second_key = None;
for entry in manifest.plain_entries() {
if pools_holding(&run.dist, &manifest.plain_bucket, &entry.key, None)?.contains(&SEED_POOL) {
second_key = Some(entry.key.clone());
break;
}
}
let second_key = second_key.ok_or("no seed-pool object remains for the second rebalance")?;
let second_barrier = run.movement_barrier.next_phase();
second_barrier.arm_movement("rebalance", &manifest.plain_bucket, &second_key, STAGE_BEFORE_SOURCE_CLEANUP)?;
let observe_second = async {
let reached = second_barrier.wait_reached(MOVEMENT_ACTIVE_TIMEOUT).await?;
let status = rebalance_status_json(&run.dist.cluster).await?;
let observed_id = status
.get("id")
.and_then(Value::as_str)
.ok_or("rebalance status omitted id")?;
if observed_id == first_id {
return Err("the second rebalance reused the stopped run id".into());
}
wait_for_rebalance_active(&run.dist.cluster, observed_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
second_barrier.disarm()?;
second_barrier.release_movement()?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((observed_id.to_string(), reached))
};
// Worker propagation can hold the start response until movement drains.
let (second_id, (observed_id, second_reached)) = tokio::try_join!(start_rebalance(&run.dist.cluster), async {
tokio::time::timeout(MOVEMENT_ACTIVE_TIMEOUT, observe_second).await?
})?;
if second_id != observed_id {
return Err(format!("rebalance start acknowledged {second_id}, but the active run was {observed_id}").into());
}
wait_for_rebalance_complete(&run.dist.cluster, &second_id, MOVEMENT_COMPLETE_TIMEOUT).await?;
verify_from_every_node(&run.dist, &manifest, "after the second rebalance completed").await?;
verify_live_state(&run.dist.client(3)?, &scratch, &live_state, "after the second rebalance completed").await?;
@@ -351,8 +413,8 @@ async fn scanner_heal_topology_rebalance_survives_live_writes_stop_and_restart()
&json!({
"gate": "G-REB",
"manifest": manifest_summary(&manifest),
"first_run": { "id": first_id, "outcome": first_outcome, "status": first_status, "versions_held_by_two_pools_after_stop": retained_after_stop },
"second_run": { "id": second_id, "versions_off_seed_pool": moved },
"first_run": { "id": first_id, "outcome": first_outcome, "status": first_status, "versions_held_by_two_pools_after_stop": retained_after_stop, "barrier_object": first_key, "barrier": first_reached },
"second_run": { "id": second_id, "versions_off_seed_pool": moved, "barrier_object": second_key, "barrier": second_reached },
"live_ops": ops,
"probe_placement_during_rebalance": during_placement,
"process_durability_strict_nodes": strict_nodes,
@@ -33,7 +33,7 @@ mod tests {
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::error::Error;
use std::io::{Read, Write};
use std::io::{Read, Seek, SeekFrom, Write};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -1654,6 +1654,89 @@ mod tests {
TargetEndpointBlackhole,
}
async fn release_heal_commit_barrier_after_shutdown(
log: &Path,
log_offset: u64,
barrier: &Path,
) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
// Observe within the existing graceful process-stop budget.
timeout(Duration::from_secs(45), async {
loop {
let mut file = std::fs::File::open(log)?;
file.seek(SeekFrom::Start(log_offset))?;
let mut tail = String::new();
file.read_to_string(&mut tail)?;
if let Some(event) = tail
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|event| {
event["event"] == "server_shutdown_state"
&& event["component"] == "main"
&& event["subsystem"] == "startup"
&& event["state"] == "stopping"
})
{
// Local heal has stopped and HTTP shutdown has been signalled.
// Let admitted peer RPCs finish; keeping this synthetic barrier
// until process exit would make the fixture block its own drain.
std::fs::remove_file(barrier)?;
return Ok(event);
}
sleep(Duration::from_millis(10)).await;
}
})
.await?
}
#[tokio::test(start_paused = true)]
async fn heal_commit_barrier_waits_for_new_shutdown_event() -> Result<(), Box<dyn Error + Send + Sync>> {
let directory = tempfile::tempdir()?;
let log = directory.path().join("server.log");
let barrier = directory.path().join("barrier");
let event = serde_json::json!({
"event": "server_shutdown_state", "component": "main", "subsystem": "startup", "state": "stopping"
});
std::fs::write(&log, format!("{event}\n"))?;
std::fs::write(&barrier, "bucket/prefix/")?;
let log_offset = log.metadata()?.len();
let mut writer = std::fs::OpenOptions::new().append(true).open(&log)?;
writeln!(writer, "{}", serde_json::json!({"event": "shutdown_signal_received"}))?;
writeln!(writer, "{}", serde_json::json!({"event": "server_shutdown_state", "state": "stopping"}))?;
let release = release_heal_commit_barrier_after_shutdown(&log, log_offset, &barrier);
tokio::pin!(release);
assert!(
futures::poll!(release.as_mut()).is_pending(),
"old or unrelated events must not release the barrier"
);
assert!(barrier.is_file(), "the partial-rebuild barrier must remain armed before shutdown");
tokio::time::advance(Duration::from_secs(6)).await;
assert!(
futures::poll!(release.as_mut()).is_pending(),
"background shutdown may take longer than five seconds within the process-stop budget"
);
writeln!(writer, "{event}")?;
tokio::time::advance(Duration::from_millis(10)).await;
assert_eq!(release.await?, event, "retain the actual new shutdown witness");
assert!(!barrier.exists(), "shutdown must release the synthetic hold");
Ok(())
}
#[tokio::test(start_paused = true)]
async fn heal_commit_barrier_missing_shutdown_fails_without_releasing() -> Result<(), Box<dyn Error + Send + Sync>> {
let directory = tempfile::tempdir()?;
let log = directory.path().join("server.log");
let barrier = directory.path().join("barrier");
std::fs::write(&log, "")?;
std::fs::write(&barrier, "bucket/prefix/")?;
let error = release_heal_commit_barrier_after_shutdown(&log, 0, &barrier)
.await
.expect_err("missing shutdown evidence must fail the fixture");
assert!(error.is::<tokio::time::error::Elapsed>());
assert!(barrier.is_file(), "a missing shutdown witness must not release repairs");
Ok(())
}
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
let server_binary = rustfs_binary_path();
let evidence_case = match scenario {
@@ -1791,6 +1874,7 @@ mod tests {
let mut created_online_objects = Vec::with_capacity(online_object_count);
let mut expected_manifests = Vec::with_capacity(online_object_count);
let mut unclean_shutdown_marker_observed = None;
let mut shutdown_barrier_release = None;
let mut attempt_count = 0usize;
let max_online_attempts = online_object_count.saturating_mul(topology.total_drives().max(1));
while expected_manifests.len() < online_object_count {
@@ -2373,7 +2457,21 @@ mod tests {
| InterruptionScenario::BackgroundTargetRestartEc84MultiSet
| InterruptionScenario::BackgroundCoordinatorRestart
) {
cluster.stop_node_gracefully(interruption_node).await?;
if interruption_node == 1
&& let Some(barrier) = &commit_barrier
{
let target_log = PathBuf::from(format!("{log_dir}/node1.log"));
let log_offset = target_log.metadata()?.len();
let (stopped, released) = tokio::join!(
cluster.stop_node_gracefully(interruption_node),
release_heal_commit_barrier_after_shutdown(&target_log, log_offset, barrier),
);
// Await both branches even on error so the child is reaped.
stopped?;
shutdown_barrier_release = Some(released?);
} else {
cluster.stop_node_gracefully(interruption_node).await?;
}
} else {
cluster.stop_node(interruption_node)?;
}
@@ -2415,7 +2513,9 @@ mod tests {
}
}
}
if let Some(barrier) = &commit_barrier {
if shutdown_barrier_release.is_none()
&& let Some(barrier) = &commit_barrier
{
std::fs::remove_file(barrier)?;
}
cluster.start_node_from_binary(interruption_node, &server_binary).await?;
@@ -2746,6 +2846,7 @@ mod tests {
"all_peers_bound_to_generation_window": true,
"pid_before": target_pid, "pid_after": restarted_pid,
"unclean_shutdown_marker": unclean_shutdown_marker_observed.unwrap_or(false),
"graceful_barrier_release": shutdown_barrier_release,
"objects": evidence_objects, "node_listings": node_listings,
});
if let Some(outage_write) = outage_write_diagnostic {
+3
View File
@@ -283,6 +283,9 @@ mod ssec_copy_test;
#[cfg(test)]
mod multipart_storage_class_test;
#[cfg(test)]
mod multipart_upload_id_test;
#[cfg(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
}
}
+9
View File
@@ -94,3 +94,12 @@ pub(crate) mod scanner_peer_bucket {
pub(crate) use ecstore_erasure::{ReedSolomonEncoder, calc_shard_size};
pub(crate) use ecstore_rpc::PeerRestClient;
}
/// Storage handles used to seed a pre-validation bucket record while the fixture server is stopped.
#[cfg(test)]
pub(crate) mod bucket_tag_fixture {
pub(crate) use rustfs_ecstore::api::bucket::metadata::load_bucket_metadata;
pub(crate) use rustfs_ecstore::api::layout::{DisksLayout, EndpointServerPools};
pub(crate) use rustfs_ecstore::api::runtime::InstanceContext;
pub(crate) use rustfs_ecstore::api::storage::{ECStore, init_local_disks_with_instance_ctx};
}
+147 -29
View File
@@ -360,12 +360,26 @@ impl OpaMock {
pub(crate) async fn expect_bucket_tags(&mut self, action: &str, bucket: &str, department: Option<&str>) -> TestResult {
let payload = self.next_request().await?;
Self::assert_bucket_tags(&payload, action, bucket, department);
Ok(())
}
async fn expect_versioned_tag_permission(&mut self, bucket: &str, object: &str, version: &str) -> TestResult {
let payload = self.next_request().await?;
Self::assert_bucket_tags(&payload, "s3:GetObjectVersionTagging", bucket, Some("finance"));
assert_eq!(payload["input"]["resource"]["object"], object);
assert_eq!(payload["input"]["context"]["conditions"]["versionid"], serde_json::json!([version]));
Ok(())
}
fn assert_bucket_tags(payload: &Value, action: &str, bucket: &str, department: Option<&str>) {
let input = &payload["input"];
assert_eq!(input["identity"]["account"], "opabuckettags");
assert_eq!(input["identity"]["is_owner"], false);
assert_eq!(input["action"], action);
assert_eq!(input["resource"]["bucket"], bucket);
let expected = department.map(|value| serde_json::json!([value]));
assert_eq!(input["context"]["conditions"].get("ExistingBucketTag/department"), expected.as_ref());
Ok(())
}
async fn wait_for_validation(&mut self) -> TestResult {
@@ -678,22 +692,61 @@ async fn test_list_buckets_opa_contract() -> TestResult {
Ok(())
}
/// Stop and reap the directory's owning server before opening the fixture store.
async fn seed_legacy_duplicate_bucket_tags(env: &mut RustFSTestEnvironment, bucket: &str) -> TestResult {
use crate::storage_api::bucket_tag_fixture::{
DisksLayout, ECStore, EndpointServerPools, InstanceContext, init_local_disks_with_instance_ctx, load_bucket_metadata,
};
let server = env
.process
.as_mut()
.ok_or("missing server process before offline fixture write")?;
server.kill()?;
server.wait()?;
env.process = None;
let layout = DisksLayout::from_volumes(std::slice::from_ref(&env.temp_dir))?;
let (pools, _) = EndpointServerPools::create_server_endpoints(&env.address, &layout).await?;
let context = std::sync::Arc::new(InstanceContext::new());
init_local_disks_with_instance_ctx(&context, pools.clone()).await?;
let store = ECStore::new_with_instance_ctx(env.address.parse()?, pools, Default::default(), context.clone()).await?;
let _cancel = context
.background_cancel_token()
.expect("store binds its shutdown token")
.drop_guard();
let mut metadata = load_bucket_metadata(store.clone(), bucket).await?;
let incarnation = metadata.bucket_incarnation_id;
let policy = metadata.policy_config_json.clone();
assert!(!incarnation.is_nil());
assert!(!policy.is_empty(), "the bucket-policy Allow must survive legacy seeding");
// Encode the duplicate tag set with the same bucket record and erasure writer used by the store.
let xml = b"<Tagging><TagSet><Tag><Key>department</Key><Value>finance</Value></Tag><Tag><Key>department</Key><Value>engineering</Value></Tag></TagSet></Tagging>";
metadata.tagging_config_xml = xml.to_vec();
metadata.save_with_store(store.clone()).await?;
let persisted = load_bucket_metadata(store, bucket).await?;
assert_eq!(persisted.bucket_incarnation_id, incarnation);
assert_eq!(persisted.policy_config_json, policy);
assert_eq!(persisted.tagging_config_xml.as_slice(), xml);
Ok(())
}
#[tokio::test]
async fn test_bucket_tags_opa_contract() -> TestResult {
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Tag, Tagging, VersioningConfiguration,
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Tag, Tagging, TaggingDirective, VersioningConfiguration,
};
init_logging();
let mut opa = OpaMock::start().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_without_cleanup_with_env(&[
("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str()),
let opa_url = opa.url.clone();
let server_env = [
("RUSTFS_POLICY_PLUGIN_URL", opa_url.as_str()),
("RUSTFS_POLICY_PLUGIN_AUTH_TOKEN", OPA_AUTH_TOKEN),
("NO_PROXY", "127.0.0.1,localhost"),
])
.await?;
];
env.start_rustfs_server_without_cleanup_with_env(&server_env).await?;
let secret = uuid::Uuid::new_v4().to_string();
create_user(&env, "opabuckettags", &secret).await?;
let client = aws_sdk_s3::Client::from_conf(build_test_s3_config(&env.url, "opabuckettags", &secret, None, "e2e-bucket-tags"));
@@ -733,7 +786,7 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
.await?;
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
client
let head = client
.head_object()
.bucket(source)
.key("report")
@@ -741,6 +794,8 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
.send()
.await?;
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
assert_eq!(head.tag_count(), Some(1));
opa.expect_versioned_tag_permission(source, "report", version).await?;
client.list_objects_v2().bucket(source).send().await?;
opa.expect_bucket_tags("s3:ListBucket", source, Some("finance")).await?;
client.list_object_versions().bucket(source).send().await?;
@@ -750,28 +805,65 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
admin.create_bucket().bucket(destination).send().await?;
set_department(&admin, destination, "engineering").await?;
let copy_source = format!("{source}/report?versionId={version}");
let error = client
.copy_object()
.bucket(destination)
.key("copied")
.copy_source(&copy_source)
.send()
.await
.expect_err("source tags must not authorize a different destination");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
opa.expect_bucket_tags("s3:PutObject", destination, Some("engineering"))
.await?;
for directive in [TaggingDirective::Copy, TaggingDirective::Replace] {
let error = client
.copy_object()
.bucket(destination)
.key("copied")
.copy_source(&copy_source)
.set_tagging_directive((directive == TaggingDirective::Replace).then_some(TaggingDirective::Replace))
.set_tagging((directive == TaggingDirective::Replace).then(|| "department=legal".to_owned()))
.send()
.await
.expect_err("source tags must not authorize a different destination");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
if directive == TaggingDirective::Copy {
opa.expect_versioned_tag_permission(source, "report", version).await?;
}
opa.expect_bucket_tags("s3:PutObject", destination, Some("engineering"))
.await?;
let missing = admin
.head_object()
.bucket(destination)
.key("copied")
.send()
.await
.expect_err("denied COPY and REPLACE must leave no destination object");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
}
set_department(&admin, destination, "finance").await?;
client
.copy_object()
.bucket(destination)
.key("copied")
.copy_source(&copy_source)
.send()
.await?;
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
for directive in [TaggingDirective::Copy, TaggingDirective::Replace] {
client
.copy_object()
.bucket(destination)
.key("copied")
.copy_source(&copy_source)
.set_tagging_directive((directive == TaggingDirective::Replace).then_some(TaggingDirective::Replace))
.set_tagging((directive == TaggingDirective::Replace).then(|| "department=legal".to_owned()))
.send()
.await?;
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
// The access hook checks preliminary tags, then the copy use case checks
// the exact source reader's tags before publishing the destination.
for _ in 0..2 {
if directive == TaggingDirective::Copy {
opa.expect_versioned_tag_permission(source, "report", version).await?;
}
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
}
let tags = admin.get_object_tagging().bucket(destination).key("copied").send().await?;
assert_eq!(tags.tag_set().len(), 1);
assert_eq!(tags.tag_set()[0].key(), "department");
assert_eq!(
tags.tag_set()[0].value(),
if directive == TaggingDirective::Copy {
"engineering"
} else {
"legal"
}
);
}
let upload = client
.create_multipart_upload()
@@ -847,6 +939,14 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("engineering"))
.await?;
let missing = admin
.head_object()
.bucket(destination)
.key("denied-source")
.send()
.await
.expect_err("a denied source must leave no destination object");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
set_department(&client, source, "finance")
.await
.expect_err("requested tags must not authorize their own mutation");
@@ -885,7 +985,7 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
opa.expect_bucket_tags("s3:GetObject", destination, Some("engineering"))
.await?;
admin
let invalid_write = admin
.put_bucket_tagging()
.bucket(destination)
.tagging(
@@ -895,7 +995,25 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
.build()?,
)
.send()
.await
.expect_err("new duplicate tags must be rejected before publication");
assert_eq!(invalid_write.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidTag"));
assert_eq!(invalid_write.raw_response().map(|response| response.status().as_u16()), Some(400));
set_department(&admin, source, "finance").await?;
seed_legacy_duplicate_bucket_tags(&mut env, destination).await?;
env.start_rustfs_server_without_cleanup_with_env(&server_env).await?;
// Prove this process can authorize the same caller and read a healthy bucket's persisted tags.
let read = client
.get_object()
.bucket(source)
.key("report")
.version_id(version)
.send()
.await?;
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
let error = client
.get_object()
.bucket(destination)
@@ -48,6 +48,7 @@ const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
const G09_EVIDENCE_DIR_ENV: &str = "RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR";
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
const STABLE_1_0_1_COMMIT: &str = "6de965ae3c965a78ff819fbcd7acd4aa44177d92";
const STABLE_1_0_4_COMMIT: &str = "46337942d55f3a8e6a97b147ca98d9ea8115555d";
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
const PLAIN_BUCKET: &str = "upgrade-plain-data";
@@ -954,7 +955,8 @@ async fn exercise_mixed_cluster(
phase: &str,
current_node: usize,
previous_node: usize,
) -> TestResult {
previous_has_delete_recovery: bool,
) -> Result<Option<String>, BoxError> {
let clients = cluster.create_all_clients()?;
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
wait_for_drive_readmission(&clients, MIXED_BUCKET, phase, DRIVE_READMISSION_TIMEOUT).await?;
@@ -1009,6 +1011,30 @@ async fn exercise_mixed_cluster(
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
);
// 1.0.4 already implements the delete decision protocol. Use a separate
// old-writer key so the original seed still spans the entire rollout.
if previous_has_delete_recovery {
let key = format!(".upgrade-delete/{phase}/previous-writer");
previous_client
.put_object()
.bucket(MIXED_BUCKET)
.key(&key)
.body(ByteStream::from_static(UPGRADE_READINESS_BODY))
.send()
.await?;
for client in &clients {
assert_eq!(read_object(client, MIXED_BUCKET, &key, None).await?.1, UPGRADE_READINESS_BODY);
}
upgrade_probe_client(current_client)
.delete_object()
.bucket(MIXED_BUCKET)
.key(&key)
.send()
.await?;
assert_mixed_deletes_visible(&clients, std::slice::from_ref(&key)).await?;
return Ok(Some(key));
}
// Every online participant must understand the delete decision protocol.
// An upgrade phase can serve reads/writes while intentionally refusing a
// modern DELETE; rejecting it must leave the old writer's seed intact.
@@ -1029,6 +1055,31 @@ async fn exercise_mixed_cluster(
);
}
Ok(None)
}
async fn assert_mixed_deletes_visible(clients: &[Client], keys: &[String]) -> TestResult {
for (node, client) in clients.iter().enumerate() {
for key in keys {
let error = upgrade_probe_client(client)
.get_object()
.bucket(MIXED_BUCKET)
.key(key)
.send()
.await
.expect_err("a successful mixed-version DELETE must remain visible through every reader");
assert_eq!(
error.raw_response().map(|response| response.status().as_u16()),
Some(404),
"node {node}: {key}"
);
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("NoSuchKey"),
"node {node}: {key}"
);
}
}
Ok(())
}
@@ -2240,6 +2291,9 @@ async fn direct_upgrade_from_previous_release_preserves_object_contracts() -> Te
async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
// Bind the expected protocol to the verified release source, never to the
// DELETE result itself. The older CI source pins retain their 503 contract.
let previous_has_delete_recovery = binary_source_revision(&previous_binary).await? == STABLE_1_0_4_COMMIT;
let evidence_context = g09_evidence_context(&previous_binary).await?;
let current_binary = rustfs_binary_path();
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
@@ -2260,7 +2314,10 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
cluster.stop_node(0)?;
cluster.start_node_from_binary(0, &current_binary).await?;
exercise_mixed_cluster(&cluster, "one-current-node", 0, 1).await?;
let mut mixed_deleted_keys = Vec::new();
if let Some(key) = exercise_mixed_cluster(&cluster, "one-current-node", 0, 1, previous_has_delete_recovery).await? {
mixed_deleted_keys.push(key);
}
// One controlled out-of-policy attempt proves fail-closed behavior below
// native write quorum; positive MPU writers remain paused while mixed.
assert_mixed_pending_refusal(&cluster, &pending, &sources).await?;
@@ -2268,7 +2325,9 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
for (node_idx, phase) in [(1, "two-current-nodes"), (2, "one-previous-node")] {
cluster.stop_node(node_idx)?;
cluster.start_node_from_binary(node_idx, &current_binary).await?;
exercise_mixed_cluster(&cluster, phase, 0, 3).await?;
if let Some(key) = exercise_mixed_cluster(&cluster, phase, 0, 3, previous_has_delete_recovery).await? {
mixed_deleted_keys.push(key);
}
}
cluster.stop_node(3)?;
@@ -2278,6 +2337,7 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
let completed_pending = finish_pending_multipart(&clients, &pending, pending_parts.concat()).await?;
exercise_homogeneous_multipart(&cluster, "homogeneous-current").await?;
verify_load_bodies(&clients, "homogeneous-previous", "multipart").await?;
assert_mixed_deletes_visible(&clients, &mixed_deleted_keys).await?;
for (node_idx, client) in clients.iter().enumerate() {
assert_eq!(
@@ -2326,6 +2386,7 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
cluster.start_with_binary(&previous_binary).await?;
prepare_release_baseline(&mut cluster, &previous_binary, "rollback").await?;
let old_clients = cluster.create_all_clients()?;
assert_mixed_deletes_visible(&old_clients, &mixed_deleted_keys).await?;
assert_resumed_multipart(&old_clients, &completed_pending).await?;
for phase in ["homogeneous-previous", "homogeneous-current"] {
verify_load_bodies(&old_clients, phase, "multipart").await?;
@@ -2390,6 +2451,8 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
"phases": phases,
"objects_per_phase": objects_per_phase,
"mixed_load": "plain-put",
"mixed_delete_protocol_supported": previous_has_delete_recovery,
"mixed_deleted_keys": mixed_deleted_keys,
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
"assertions": [
@@ -2422,7 +2485,11 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
"16x16 multipart load runs in homogeneous previous, current and rollback phases",
"one-current-node Complete returns typed 503 and preserves actual source authority and parts",
"the same legacy upload completes and replays once fully upgraded without adding a version",
"modern DELETE fails closed and preserves every byte while old peers remain"
if previous_has_delete_recovery {
"mixed-version DELETE succeeds and stays absent through every reader after upgrade and rollback"
} else {
"modern DELETE fails closed and preserves every byte while old peers remain"
}
],
}),
)?;
+19 -8
View File
@@ -192,6 +192,7 @@ pub mod bucket {
table_catalog_path_hash, unreadable_config_refusal,
};
pub use crate::bucket::metadata::{BUCKET_DURABILITY_CONFIG, BUCKET_ON_DEMAND_MIGRATION_CONFIG};
pub use crate::bucket::metadata::{MAX_BUCKET_TAGS, validate_tag_set_entries};
}
pub mod config_parse_mode {
@@ -331,8 +332,8 @@ pub mod bucket {
pub mod utils {
pub use crate::bucket::utils::{
check_bucket_and_object_names, check_list_objs_args, check_object_name_for_length_and_slash,
check_valid_bucket_name_strict, deserialize, has_bad_path_component, is_meta_bucketname, is_valid_object_prefix,
serialize,
check_valid_bucket_name_strict, deserialize, has_bad_path_component, has_xml_invalid_control_characters,
is_meta_bucketname, is_valid_object_prefix, serialize,
};
}
@@ -451,9 +452,15 @@ pub mod disk {
pub use crate::cluster::rpc::background_reader_stream::BackgroundReaderStream;
pub use crate::disk::background_memory::BackgroundEncodeBuffer;
pub use crate::disk::disk_store::get_object_disk_read_timeout;
#[cfg(feature = "test-util")]
pub use crate::disk::disk_store::wait_for_multipart_index_bootstrap;
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
#[cfg(feature = "test-util")]
pub use crate::disk::local::{local_durability_modes, set_local_rename_data_fail_after_metadata_commit};
pub use crate::disk::multipart_index::{
MULTIPART_INDEX_RPC_MAX_BYTES, MULTIPART_INDEX_RPC_MAX_REQUEST_BYTES, MultipartIndexPage, MultipartIndexRpcReply,
MultipartIndexRpcRequest,
};
pub use crate::disk::multipart_source::{
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceResponse,
@@ -466,13 +473,15 @@ pub mod disk {
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
pub use crate::disk::{
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration, CurrentDeleteTransition,
BoundedDeleteRecoveryDiscovery, BoundedDeleteRecoveryInspection, BoundedDeleteRecoverySettlement,
BoundedDeleteRecoverySettlementReply, CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration,
CurrentDeleteTransition, DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES, DELETE_RECOVERY_MAX_INSPECTION_ENTRIES,
DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest,
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
POOL_META_NATIVE_CAS_METADATA_SUFFIX, PartTransactionAction, PoolMetaNativeBackup, PoolMetaNativeInspection,
PoolMetaNativeObject, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEAL_REPLACEMENT_RECOVERY_DIR, HEALING_MARKER_PATH,
NsScannerOpenRequest, OldCurrentSize, POOL_META_NATIVE_CAS_METADATA_SUFFIX, PartTransactionAction, PoolMetaNativeBackup,
PoolMetaNativeInspection, PoolMetaNativeObject, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse,
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET,
ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken,
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path, new_disk,
@@ -617,7 +626,7 @@ pub mod rebalance {
pub mod rio {
pub use crate::io_support::rio::{
DecryptReader, DynReader, EncryptReader, HardLimitReader, HashReader, ReadStream, Reader, WriteEncryption, WritePlan,
boxed_reader, compression_metadata_value, wrap_reader,
boxed_reader, compression_metadata_value, validate_compression_scheme, wrap_reader,
};
}
@@ -654,7 +663,9 @@ pub mod set_disk {
#[cfg(feature = "test-util")]
pub mod test_util {
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
pub use crate::bucket::quota::reservation::{
fail_next_quota_ledger_save_for_test, initialize_quota_ledger_for_test, retry_quota_reconciliation_for_test,
};
pub use crate::set_disk::ConditionalReadBenchmarkMetadataGuard;
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
+325 -5
View File
@@ -16,7 +16,8 @@ use crate::bucket::metadata::BucketMetadata;
use crate::bucket::metadata_sys::get_bucket_targets_config;
use crate::bucket::metadata_sys::get_replication_config;
use crate::bucket::remote_s3_client::{
PathStyle, REPLICATION_TARGET_RETRY_POLICY, RemoteCredentials, RemoteS3EndpointSpec, build_remote_s3_client,
PathStyle, REPLICATION_TARGET_RETRY_POLICY, RemoteCredentials, RemoteS3EndpointSpec, RemoteS3RetryPolicy,
UploadResponseDeadline, build_remote_s3_client,
};
use crate::bucket::replication::{ObjectLockIntegrity, object_lock_put_integrity, replication_etags_match};
use crate::bucket::replication::{ReplicationStatusType, ReplicationTargetConfigBridge};
@@ -119,6 +120,7 @@ impl From<&BucketTarget> for RemoteS3EndpointSpec {
ca_cert_pem: (!target.ca_cert_pem.trim().is_empty()).then(|| target.ca_cert_pem.clone()),
connect_timeout: None,
read_timeout: None,
operation_timeout: Some(TargetRequestTimeouts::from_env().request),
// Replication has no retry budget of its own on the request path,
// so it keeps the SDK's standard three attempts; stating it here
// pins the behaviour to this line instead of an SDK default.
@@ -1191,6 +1193,7 @@ impl BucketTargetSys {
health_check_duration: target.health_check_duration,
replicate_sync: target.replication_sync,
client: Arc::new(client),
timeouts: TargetRequestTimeouts::from_env(),
})
}
@@ -1922,6 +1925,83 @@ impl std::fmt::Display for S3ClientError {
}
}
/// Replication request budgets, snapshotted when a target client is built.
/// Streamed GET bodies are not subject to the upload-response budget.
#[derive(Debug, Clone, Copy)]
pub struct TargetRequestTimeouts {
request: Duration,
complete: Duration,
minimum_transfer_rate: u64,
}
impl Default for TargetRequestTimeouts {
fn default() -> Self {
Self {
request: Duration::from_secs(60),
complete: Duration::from_secs(15 * 60),
minimum_transfer_rate: 1024 * 1024,
}
}
}
impl TargetRequestTimeouts {
fn from_env() -> Self {
let defaults = Self::default();
let seconds = |name: &str, fallback: Duration| {
let value = rustfs_utils::get_env_u64(name, fallback.as_secs());
Duration::from_secs(if (1..=86400).contains(&value) {
value
} else {
fallback.as_secs()
})
};
let minimum_transfer_rate =
rustfs_utils::get_env_u64("RUSTFS_REPLICATION_MIN_TRANSFER_RATE_BYTES_PER_SEC", defaults.minimum_transfer_rate);
Self {
request: seconds("RUSTFS_REPLICATION_REQUEST_TIMEOUT_SECS", defaults.request),
complete: seconds("RUSTFS_REPLICATION_COMPLETE_TIMEOUT_SECS", defaults.complete),
minimum_transfer_rate: if minimum_transfer_rate == 0 {
defaults.minimum_transfer_rate
} else {
minimum_transfer_rate
},
}
}
fn upload_timeout(self, size: i64) -> Result<Duration, S3ClientError> {
let size = u64::try_from(size).map_err(|_| S3ClientError::new("replication upload size must be non-negative"))?;
// The SDK can make three attempts. Leave transmission headroom for
// all three; the separate response deadline still bounds a trickling
// target after each upload finishes.
let attempts = match REPLICATION_TARGET_RETRY_POLICY {
RemoteS3RetryPolicy::Disabled => 1,
RemoteS3RetryPolicy::Standard { max_attempts } => u64::from(max_attempts.max(1)),
};
let seconds = size
.div_ceil(self.minimum_transfer_rate)
.checked_mul(attempts)
.ok_or_else(|| S3ClientError::new("replication upload timeout exceeds supported duration"))?;
let timeout = self
.request
.checked_add(Duration::from_secs(seconds))
.ok_or_else(|| S3ClientError::new("replication upload timeout exceeds supported duration"))?;
if Instant::now().checked_add(timeout).is_none() {
return Err(S3ClientError::new("replication upload timeout exceeds supported duration"));
}
Ok(timeout)
}
fn upload_override(self, size: i64) -> Result<aws_sdk_s3::config::Builder, S3ClientError> {
Ok(aws_sdk_s3::config::Builder::new()
.timeout_config(
aws_smithy_types::timeout::TimeoutConfig::builder()
.operation_timeout(self.upload_timeout(size)?)
.build(),
)
.interceptor(UploadResponseDeadline(self.request)))
}
}
#[derive(Debug)]
pub struct TargetClient {
pub endpoint: String,
@@ -1935,6 +2015,7 @@ pub struct TargetClient {
pub health_check_duration: Duration,
pub replicate_sync: bool,
pub client: Arc<S3Client>,
pub timeouts: TargetRequestTimeouts,
}
impl TargetClient {
@@ -2360,12 +2441,11 @@ impl TargetClient {
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
});
let mut config = self.timeouts.upload_override(size)?;
if forwards_source_checksum {
operation = operation.config_override(
aws_sdk_s3::config::Builder::new()
.request_checksum_calculation(aws_sdk_s3::config::RequestChecksumCalculation::WhenRequired),
);
config = config.request_checksum_calculation(aws_sdk_s3::config::RequestChecksumCalculation::WhenRequired);
}
operation = operation.config_override(config);
match operation.send().await {
Ok(output) => {
// Under SSE-KMS/DSSE or SSE-C the target's ETag is not the MD5
@@ -2501,6 +2581,7 @@ impl TargetClient {
}
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
})
.config_override(self.timeouts.upload_override(size)?)
.send()
.await
{
@@ -2538,6 +2619,13 @@ impl TargetClient {
}
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
})
.config_override(
aws_sdk_s3::config::Builder::new().timeout_config(
aws_smithy_types::timeout::TimeoutConfig::builder()
.operation_timeout(self.timeouts.complete)
.build(),
),
)
.send()
.await
{
@@ -2966,11 +3054,241 @@ mod tests {
health_check_duration: Duration::from_secs(5),
replicate_sync: false,
client: Arc::new(client),
timeouts: TargetRequestTimeouts::default(),
},
request_headers,
)
}
#[derive(Debug, Clone)]
struct TimedResponseConnector {
upload_delay: Duration,
response_delay: Duration,
prefix_bytes: usize,
response: bytes::Bytes,
frames: Arc<std::sync::atomic::AtomicUsize>,
dropped: Arc<std::sync::atomic::AtomicUsize>,
}
struct ResponseDrop(Arc<std::sync::atomic::AtomicUsize>);
impl Drop for ResponseDrop {
fn drop(&mut self) {
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
impl SmithyHttpConnector for TimedResponseConnector {
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
let settings = self.clone();
HttpConnectorFuture::new(async move {
http_body_util::BodyExt::collect(request.into_body())
.await
.map_err(aws_smithy_runtime_api::client::result::ConnectorError::io)?;
tokio::time::sleep(settings.upload_delay).await;
let guard = ResponseDrop(Arc::clone(&settings.dropped));
let stream = futures::stream::unfold(
(settings.prefix_bytes, Some(settings.response), true, guard),
move |(remaining, mut body, first, guard)| {
let frames = Arc::clone(&settings.frames);
async move {
if remaining == 0 && body.is_none() {
return None;
}
if !first {
tokio::time::sleep(settings.response_delay).await;
}
let bytes = if remaining > 0 {
bytes::Bytes::from_static(b" ")
} else {
body.take()?
};
frames.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some((
Ok::<_, std::io::Error>(http_body::Frame::data(bytes)),
(remaining.saturating_sub(1), body, false, guard),
))
}
},
)
.fuse();
let body = SdkBody::from_body_1_x(http_body_util::StreamBody::new(stream));
let mut response =
HttpResponse::new(aws_smithy_runtime_api::http::StatusCode::try_from(200u16).expect("status"), body);
response.headers_mut().insert("etag", "\"test-etag\"");
Ok(response)
})
}
}
fn timed_response_target(
upload_delay: Duration,
response_delay: Duration,
prefix_bytes: usize,
response: &'static [u8],
) -> (TargetClient, Arc<std::sync::atomic::AtomicUsize>, Arc<std::sync::atomic::AtomicUsize>) {
let frames = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let dropped = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let connector = SharedHttpConnector::new(TimedResponseConnector {
upload_delay,
response_delay,
prefix_bytes,
response: bytes::Bytes::from_static(response),
frames: Arc::clone(&frames),
dropped: Arc::clone(&dropped),
});
let http_client = http_client_fn(move |_settings, _components| connector.clone());
let (mut target, _) = header_recording_target_client(Vec::new());
target.timeouts = TargetRequestTimeouts {
request: Duration::from_secs(2),
complete: Duration::from_secs(10),
minimum_transfer_rate: 1024 * 1024,
};
target.client = Arc::new(S3Client::from_conf(
target
.client
.config()
.to_builder()
.http_client(http_client)
.retry_config(aws_sdk_s3::config::retry::RetryConfig::disabled())
.timeout_config(
aws_smithy_types::timeout::TimeoutConfig::builder()
.operation_timeout(target.timeouts.request)
.build(),
)
.build(),
));
(target, frames, dropped)
}
#[test]
fn replication_upload_budgets_preserve_large_transfers_and_reject_invalid_sizes() {
let policy = TargetRequestTimeouts::default();
assert_eq!(policy.upload_timeout(0).expect("empty upload"), Duration::from_secs(60));
assert_eq!(
policy.upload_timeout(5 * 1024 * 1024 * 1024).expect("maximum single PUT"),
Duration::from_secs(15420)
);
assert!(policy.upload_timeout(-1).is_err());
let slow = TargetRequestTimeouts {
minimum_transfer_rate: 1,
..policy
};
assert!(slow.upload_timeout(i64::MAX).is_err());
}
#[tokio::test(start_paused = true)]
async fn replication_upload_response_deadline_is_independent_of_size_and_checksum_override() {
for operation in ["put", "forwarded-checksum", "part"] {
let (target, frames, dropped) = timed_response_target(Duration::from_secs(4), Duration::from_millis(500), 100, b"");
let body = vec![b'x'; 2 * 1024 * 1024];
let size = i64::try_from(body.len()).expect("size");
let started = tokio::time::Instant::now();
let error = if operation == "part" {
target
.put_object_part(
"bucket",
"key",
"upload",
1,
size,
ByteStream::from(body),
&PutObjectPartOptions::default(),
)
.await
.expect_err("response deadline")
} else {
let mut opts = PutObjectOptions::default();
if operation == "forwarded-checksum" {
opts.user_metadata.insert(
"x-amz-checksum-sha256".to_owned(),
"OoJ3yNhRwv3wwtZoGqEIPrPX9xwTnfLl+ka0wStN1g0=".to_owned(),
);
}
target
.put_object("bucket", "key", size, ByteStream::from(body), &opts)
.await
.expect_err("response deadline")
};
assert!(
started.elapsed() >= Duration::from_secs(6),
"upload gets its full independent window: {error}"
);
assert!(
started.elapsed() < target.timeouts.upload_timeout(size).expect("budget"),
"response must end before the larger total deadline"
);
assert!(
frames.load(std::sync::atomic::Ordering::SeqCst) >= 2,
"trickle delivered progress before failure"
);
assert_eq!(dropped.load(std::sync::atomic::Ordering::SeqCst), 1, "transport body released");
}
}
#[tokio::test(start_paused = true)]
async fn replication_slow_upload_preserves_transfer_window_and_streaming_get_body() {
let (target, _, _) = timed_response_target(Duration::from_secs(4), Duration::from_millis(100), 5, b"");
let body = vec![b'x'; 2 * 1024 * 1024];
let size = i64::try_from(body.len()).expect("size");
target
.put_object("bucket", "key", size, ByteStream::from(body), &PutObjectOptions::default())
.await
.expect("slow upload, prompt response");
let (target, _, dropped) = timed_response_target(Duration::ZERO, Duration::from_secs(1), 5, b"payload");
let response = target
.get_object("bucket", "key", None, None, None, HeaderMap::new())
.await
.expect("GET headers");
let started = tokio::time::Instant::now();
let body = response
.body
.collect()
.await
.expect("streamed GET remains outside upload deadline")
.into_bytes();
assert!(started.elapsed() > target.timeouts.request);
assert_eq!(&body[..], b" payload");
assert_eq!(dropped.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test(start_paused = true)]
async fn replication_complete_processing_whitespace_has_a_separate_bounded_window() {
const RESULT: &[u8] = b"<CompleteMultipartUploadResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><ETag>\"etag\"</ETag></CompleteMultipartUploadResult>";
for prefix in [5, 100] {
let (target, frames, dropped) = timed_response_target(Duration::ZERO, Duration::from_secs(1), prefix, RESULT);
let started = tokio::time::Instant::now();
let result = target
.complete_multipart_upload(
"bucket",
"key",
"upload",
vec![CompletedPart::builder().part_number(1).e_tag("etag").build()],
&PutObjectOptions::default(),
)
.await;
if prefix == 5 {
assert!(result.is_ok(), "legal processing whitespace: {result:?}");
} else {
assert!(result.is_err());
assert!(started.elapsed() <= Duration::from_secs(11));
}
assert!(started.elapsed() > target.timeouts.request);
assert!(frames.load(std::sync::atomic::Ordering::SeqCst) >= 2);
assert_eq!(dropped.load(std::sync::atomic::Ordering::SeqCst), 1);
}
}
#[tokio::test(start_paused = true)]
async fn replication_control_operation_deadline_covers_response_trickle() {
let (target, frames, dropped) =
timed_response_target(Duration::ZERO, Duration::from_millis(500), 100, b"<VersioningConfiguration/>");
let started = tokio::time::Instant::now();
assert!(target.get_bucket_versioning("bucket").await.is_err());
assert!(started.elapsed() <= Duration::from_secs(3));
assert!(frames.load(std::sync::atomic::Ordering::SeqCst) >= 2);
assert_eq!(dropped.load(std::sync::atomic::Ordering::SeqCst), 1);
}
fn streaming_test_body(payload: &'static [u8]) -> ByteStream {
let stream = tokio_util::io::ReaderStream::new(std::io::Cursor::new(payload));
let body = http_body_util::StreamBody::new(futures::StreamExt::map(stream, |r| r.map(http_body::Frame::data)));
@@ -3279,6 +3597,7 @@ mod tests {
health_check_duration: Duration::from_secs(5),
replicate_sync: false,
client: Arc::new(client),
timeouts: TargetRequestTimeouts::default(),
},
request_uris,
)
@@ -3591,6 +3910,7 @@ mod tests {
health_check_duration: Duration::from_secs(5),
replicate_sync: false,
client: Arc::new(client),
timeouts: TargetRequestTimeouts::default(),
})
}
@@ -5487,6 +5487,14 @@ pub async fn put_restore_opts(
if !oi.user_tags.is_empty() {
meta.insert(AMZ_OBJECT_TAGGING.to_string(), (*oi.user_tags).clone());
}
if let Some(expires) = oi.expires {
meta.insert(
"expires".to_string(),
expires
.format(&time::format_description::well_known::Rfc3339)
.map_err(std::io::Error::other)?,
);
}
let restore_expiry = lifecycle::expected_expiry_time(OffsetDateTime::now_utc(), rreq.days.unwrap_or(1));
meta.insert(
metadata_keys::RESTORE.to_string(),
@@ -5506,7 +5514,6 @@ pub async fn put_restore_opts(
// computed MD5 is not the object's public plaintext ETag.
preserve_etag: oi.etag.clone(),
shard_integrity_write_mode: Some(oi.shard_integrity_write_mode()),
//expires: oi.expires,
..Default::default()
})
}
@@ -7144,6 +7151,25 @@ mod tests {
0,
"tier generation lease should release after EOF"
);
let unread = get_transitioned_object_reader_with_tier_manager(
&object_info.bucket,
&object_info.name,
&None,
&HeaderMap::new(),
&object_info,
&ObjectOptions::default(),
&manager,
None,
)
.await
.expect("open an unread transitioned response");
assert_eq!(TierConfigMgr::active_operation_lease_count(&manager, &tier).await, 1);
drop(unread);
assert_eq!(
TierConfigMgr::active_operation_lease_count(&manager, &tier).await,
0,
"canceling an unread response must release the tier lease"
);
}
#[cfg(feature = "test-util")]
+71
View File
@@ -256,6 +256,38 @@ pub const BUCKET_POLICY_CONFIG: &str = "policy.json";
pub const BUCKET_NOTIFICATION_CONFIG: &str = "notification.xml";
pub const BUCKET_LIFECYCLE_CONFIG: &str = "lifecycle.xml";
pub const BUCKET_SSECONFIG: &str = "bucket-encryption.xml";
/// Shared tag-entry validation for object requests and persisted bucket tags.
pub fn validate_tag_set_entries(tag_set: &[s3s::dto::Tag]) -> std::result::Result<(), &'static str> {
fn is_valid_tag_text(value: &str) -> bool {
static VALID_TAG_TEXT: std::sync::OnceLock<std::result::Result<regex::Regex, regex::Error>> = std::sync::OnceLock::new();
VALID_TAG_TEXT
.get_or_init(|| regex::Regex::new(r"^[\p{L}\p{N}\p{Z}+\-=._:/@]*$"))
.as_ref()
.is_ok_and(|pattern| pattern.is_match(value))
}
let mut keys = std::collections::HashSet::with_capacity(tag_set.len());
for tag in tag_set {
let key = tag
.key
.as_deref()
.filter(|key| !key.is_empty())
.ok_or("Tag key cannot be empty")?;
if key.encode_utf16().count() > 128 || !is_valid_tag_text(key) {
return Err("The TagKey you have provided is invalid");
}
let value = tag.value.as_deref().ok_or("Tag value cannot be null")?;
if value.encode_utf16().count() > 256 || !is_valid_tag_text(value) {
return Err("The TagValue you have provided is invalid");
}
if !keys.insert(key) {
return Err("Cannot provide multiple Tags with the same key");
}
}
Ok(())
}
pub const MAX_BUCKET_TAGS: usize = 50;
pub const BUCKET_TAGGING_CONFIG: &str = "tagging.xml";
pub const BUCKET_QUOTA_CONFIG_FILE: &str = "quota.json";
pub const OBJECT_LOCK_CONFIG: &str = "object-lock.xml";
@@ -990,6 +1022,20 @@ impl BucketMetadata {
self.encryption_config_updated_at = updated;
}
BUCKET_TAGGING_CONFIG => {
if !data.is_empty() {
let tagging = deserialize::<Tagging>(&data).map_err(|_| {
Error::InvalidArgument(self.name.clone(), String::new(), "Invalid bucket tagging XML".to_owned())
})?;
if tagging.tag_set.len() > MAX_BUCKET_TAGS {
return Err(Error::InvalidArgument(
self.name.clone(),
String::new(),
"Cannot have more than 50 tags per bucket".to_owned(),
));
}
validate_tag_set_entries(&tagging.tag_set)
.map_err(|message| Error::InvalidArgument(self.name.clone(), String::new(), message.to_owned()))?;
}
self.tagging_config_xml = data;
// Drop the parsed form (like lifecycle above) so clearing the
// payload can't leave stale parsed tags to be cached.
@@ -1784,6 +1830,31 @@ mod test {
/// backlog#2292: a replicated config is stamped with the source
/// `updated_at` it was given, not the local clock, while the plain
/// `update_config` entry keeps stamping the local clock.
#[test]
fn bucket_tag_updates_reject_unreadable_shapes_without_replacing_metadata() {
let mut metadata = BucketMetadata::new("validated-tags");
let valid = br#"<Tagging><TagSet><Tag><Key>team</Key><Value></Value></Tag></TagSet></Tagging>"#.to_vec();
metadata
.update_config(BUCKET_TAGGING_CONFIG, valid.clone())
.expect("empty value is valid");
let updated = metadata.tagging_config_updated_at;
for invalid in [
"<Tagging><TagSet><Tag><Key>team</Key></Tag></TagSet></Tagging>".to_owned(),
"<Tagging><TagSet><Tag><Key></Key><Value>x</Value></Tag></TagSet></Tagging>".to_owned(),
"<Tagging><TagSet><Tag><Key>team</Key><Value>x</Value></Tag><Tag><Key>team</Key><Value>y</Value></Tag></TagSet></Tagging>".to_owned(),
format!("<Tagging><TagSet>{}</TagSet></Tagging>", (0..51).map(|i| format!("<Tag><Key>k{i}</Key><Value>v</Value></Tag>")).collect::<String>()),
] {
assert!(metadata.update_config_at(BUCKET_TAGGING_CONFIG, invalid.into_bytes(), updated + time::Duration::hours(1)).is_err());
assert_eq!(metadata.tagging_config_xml, valid);
assert_eq!(metadata.tagging_config_updated_at, updated);
}
metadata.tagging_config_xml = b"legacy corrupt tags".to_vec();
metadata
.update_config(BUCKET_TAGGING_CONFIG, Vec::new())
.expect("deletion repairs legacy corruption");
assert!(metadata.tagging_config_xml.is_empty());
}
#[test]
fn update_config_at_stamps_the_given_time_and_update_config_stamps_now() {
let source_time = OffsetDateTime::now_utc() - time::Duration::hours(3);
+11 -5
View File
@@ -3428,7 +3428,7 @@ mod tests {
),
(
BUCKET_TAGGING_CONFIG,
r#"<Tagging><TagSet><Tag><Key>environment</Key><Value>测试-🦀</Value></Tag></TagSet></Tagging>"#.as_bytes(),
r#"<Tagging><TagSet><Tag><Key>environment</Key><Value>测试-rust</Value></Tag></TagSet></Tagging>"#.as_bytes(),
),
(BUCKET_REPLICATION_CONFIG, NEW_WRITER_REPLICATION_XML),
(
@@ -4556,8 +4556,13 @@ mod tests {
std::fs::create_dir_all(dir.path().join(bucket)).expect("bucket volume should be created");
}
let mut bm = BucketMetadata::new(bucket);
bm.update_config(config_file, raw.to_vec())
.expect("raw config should be stored");
if config_file == crate::bucket::metadata::BUCKET_TAGGING_CONFIG {
// Model legacy persisted corruption without passing the write-time validator.
bm.tagging_config_xml = raw.to_vec();
} else {
bm.update_config(config_file, raw.to_vec())
.expect("raw config should be stored");
}
sys.persist_new_and_set(bm).await.expect("initial metadata should persist");
}
@@ -5738,7 +5743,8 @@ mod tests {
// update only needs one interleaving to show up.
const ROUNDS: usize = 8;
for round in 0..ROUNDS {
let tagging = format!("<Tagging><Round>{round}</Round></Tagging>").into_bytes();
let tagging =
format!("<Tagging><TagSet><Tag><Key>round</Key><Value>{round}</Value></Tag></TagSet></Tagging>").into_bytes();
let policy = format!(r#"{{"Version":"2012-10-17","Round":{round}}}"#).into_bytes();
let start = Arc::new(tokio::sync::Barrier::new(2));
@@ -5811,7 +5817,7 @@ mod tests {
let blocked = tokio::spawn({
let node_b = node_b.clone();
async move { update_with_sys(node_b, bucket, BUCKET_TAGGING_CONFIG, b"<Tagging/>".to_vec()).await }
async move { update_with_sys(node_b, bucket, BUCKET_TAGGING_CONFIG, b"<Tagging><TagSet/></Tagging>".to_vec()).await }
});
// Long enough for the write to have finished had it not waited: the
@@ -119,6 +119,21 @@ async fn durable_quota_reporting_retires_degraded_floors_without_changing_ledger
// Exercise the real storage entry point before testing the new
// reporting semantics: the same ledger must admit and refuse
// these writes even when a scanner floor claims a million bytes.
if protocol == 1 {
let mut bootstrap = PutObjReader::from_vec(vec![0x5a; 512]);
assert!(
matches!(
store
.put_object(&bucket, "bootstrap", &mut bootstrap, &ObjectOptions::default())
.await,
Err(StorageError::SlowDown)
),
"a missing authoritative ledger must not admit a write from scanner reporting"
);
crate::bucket::quota::reservation::retry_quota_reconciliation_for_test(store.clone(), &bucket)
.await
.expect("bootstrap authoritative usage through the production worker");
}
let mut too_large = PutObjReader::from_vec(vec![0x5a; 8192]);
assert!(
matches!(
File diff suppressed because it is too large Load Diff
+87 -2
View File
@@ -40,7 +40,9 @@ use aws_smithy_runtime_api::client::http::{
HttpConnector as SmithyHttpConnector, HttpConnectorFuture, SharedHttpConnector, http_client_fn,
};
use aws_smithy_runtime_api::client::interceptors::Intercept;
use aws_smithy_runtime_api::client::interceptors::context::BeforeTransmitInterceptorContextMut;
use aws_smithy_runtime_api::client::interceptors::context::{
BeforeDeserializationInterceptorContextMut, BeforeTransmitInterceptorContextMut,
};
use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
use aws_smithy_runtime_api::client::result::ConnectorError;
use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
@@ -155,6 +157,8 @@ pub struct RemoteS3EndpointSpec {
pub ca_cert_pem: Option<String>,
pub connect_timeout: Option<Duration>,
pub read_timeout: Option<Duration>,
/// Entire SDK call, including non-streaming response bodies and retries.
pub operation_timeout: Option<Duration>,
/// How many wire requests one logical call may cost. Every consumer
/// declares it; see [`RemoteS3RetryPolicy`].
pub retry: RemoteS3RetryPolicy,
@@ -252,6 +256,78 @@ pub(crate) fn remote_sdk_credentials(credentials: &RemoteCredentials, now: Syste
Ok(builder.build())
}
/// Only installed on non-streaming upload operations. The budget begins at
/// response headers, independently of the potentially long request upload.
#[derive(Debug)]
pub(crate) struct UploadResponseDeadline(pub Duration);
impl Intercept for UploadResponseDeadline {
fn name(&self) -> &'static str {
"RustfsUploadResponseDeadline"
}
fn modify_before_deserialization(
&self,
context: &mut BeforeDeserializationInterceptorContextMut<'_>,
_runtime_components: &RuntimeComponents,
_cfg: &mut ConfigBag,
) -> Result<(), BoxError> {
let response = context.response_mut();
let inner = std::mem::replace(response.body_mut(), SdkBody::taken());
*response.body_mut() = SdkBody::from_body_1_x(UploadResponseBody {
inner: Some(inner),
deadline: Box::pin(tokio::time::sleep(self.0)),
});
Ok(())
}
}
struct UploadResponseBody {
inner: Option<SdkBody>,
deadline: std::pin::Pin<Box<tokio::time::Sleep>>,
}
impl http_body::Body for UploadResponseBody {
type Data = bytes::Bytes;
type Error = BoxError;
fn poll_frame(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
use std::future::Future as _;
use std::task::Poll;
if self.inner.is_none() {
return Poll::Ready(None);
}
// The absolute response budget never renews on data, empty frames,
// or trailers. A retry gets its own bounded response window.
if self.deadline.as_mut().poll(cx).is_ready() {
self.inner.take();
return Poll::Ready(Some(Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"replication response body deadline exceeded",
)
.into())));
}
let Some(inner) = self.inner.as_mut() else {
return Poll::Ready(None);
};
let result = std::pin::Pin::new(inner).poll_frame(cx);
if matches!(result, Poll::Ready(None) | Poll::Ready(Some(Err(_)))) {
self.inner.take();
}
result
}
fn is_end_stream(&self) -> bool {
self.inner.as_ref().is_none_or(http_body::Body::is_end_stream)
}
fn size_hint(&self) -> http_body::SizeHint {
self.inner.as_ref().map(http_body::Body::size_hint).unwrap_or_default()
}
}
/// Appends a caller-identifying token to the SDK `User-Agent`. Runs after
/// signing: SigV4 excludes `user-agent` from the canonical request, so the
/// signature stays valid.
@@ -309,7 +385,7 @@ pub async fn build_remote_s3_config(spec: &RemoteS3EndpointSpec) -> Result<aws_s
config_builder = config_builder.http_client(http_client);
}
if spec.connect_timeout.is_some() || spec.read_timeout.is_some() {
if spec.connect_timeout.is_some() || spec.read_timeout.is_some() || spec.operation_timeout.is_some() {
let mut timeouts = TimeoutConfig::builder();
if let Some(connect_timeout) = spec.connect_timeout {
timeouts = timeouts.connect_timeout(connect_timeout);
@@ -317,6 +393,9 @@ pub async fn build_remote_s3_config(spec: &RemoteS3EndpointSpec) -> Result<aws_s
if let Some(read_timeout) = spec.read_timeout {
timeouts = timeouts.read_timeout(read_timeout);
}
if let Some(operation_timeout) = spec.operation_timeout {
timeouts = timeouts.operation_timeout(operation_timeout);
}
config_builder = config_builder.timeout_config(timeouts.build());
}
@@ -674,6 +753,7 @@ mod tests {
ca_cert_pem: None,
connect_timeout: None,
read_timeout: None,
operation_timeout: None,
retry: RemoteS3RetryPolicy::Disabled,
user_agent_suffix: "",
}
@@ -1027,11 +1107,16 @@ mod tests {
spec.user_agent_suffix = "RustFS-Test/0.0";
spec.connect_timeout = Some(Duration::from_secs(5));
spec.read_timeout = Some(Duration::from_secs(5));
spec.operation_timeout = Some(Duration::from_secs(12));
let config = build_remote_s3_config(&spec)
.await
.expect("spec should build")
.http_client(http_client)
.build();
assert_eq!(
config.timeout_config().and_then(TimeoutConfig::operation_timeout),
Some(Duration::from_secs(12))
);
S3Client::from_conf(config)
.head_bucket()
.bucket("bucket")
@@ -16,8 +16,8 @@ use super::replication_config_store::ReplicationConfigStore;
use super::replication_error_boundary::{Error as EcstoreError, is_err_object_not_found, is_err_version_not_found};
use super::replication_filemeta_boundary::{
MrfOpKind, MrfReplicateEntry, REPLICATE_HEAL_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicatedTargetInfo,
ReplicationStatusType, ReplicationType, ReplicationWorkerOperation, ResyncDecision, replicate_decision_for_admitted_targets,
replication_statuses_map, version_purge_statuses_map,
ReplicationGenerationSnapshot, ReplicationStatusType, ReplicationType, ReplicationWorkerOperation, ResyncDecision,
replicate_decision_for_admitted_targets, replication_statuses_map, version_purge_statuses_map,
};
use super::replication_lock_boundary::ReplicationLockTiming;
use super::replication_logging::{EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REPLICATION};
@@ -1581,7 +1581,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
/// Queues a replica task
pub async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission {
// A version that is already queued or being uploaded is not driven a
// A source generation already queued or being uploaded is not driven a
// second time: the scanner heal pass sees it as PENDING until the
// first upload lands and would otherwise re-queue it every cycle
// (backlog#2362). The key is released when the worker finishes, or
@@ -2404,28 +2404,47 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
}
/// Object versions currently queued or being uploaded, keyed by bucket,
/// object name and version. `queue_replica_task` admits a version only once
/// object name, version and source generation. `queue_replica_task` admits a generation only once
/// while it is in flight; the scanner heal pass and MRF replays that arrive
/// in the meantime are `Skipped` instead of driving a second complete upload
/// (backlog#2362). Entries are removed when the worker finishes the task or
/// when no worker accepted it.
#[derive(Debug, Default)]
pub(crate) struct ReplicationInFlight {
keys: std::sync::Mutex<std::collections::HashSet<(String, String, Option<uuid::Uuid>)>>,
keys: std::sync::Mutex<std::collections::HashSet<ReplicationInFlightKey>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ReplicationInFlightKey {
bucket: String,
object: String,
version_id: Option<uuid::Uuid>,
generation: ReplicationGenerationSnapshot,
}
impl From<&ReplicateObjectInfo> for ReplicationInFlightKey {
fn from(info: &ReplicateObjectInfo) -> Self {
Self {
bucket: info.bucket.clone(),
object: info.name.clone(),
version_id: info.version_id,
generation: info.replication_generation.clone(),
}
}
}
impl ReplicationInFlight {
fn lock(&self) -> std::sync::MutexGuard<'_, std::collections::HashSet<(String, String, Option<uuid::Uuid>)>> {
fn lock(&self) -> std::sync::MutexGuard<'_, std::collections::HashSet<ReplicationInFlightKey>> {
self.keys.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Claim `ri`; `false` when the same version is already in flight.
/// Claim `ri`; a metadata mutation is independent of its earlier generation.
fn try_begin(&self, ri: &ReplicateObjectInfo) -> bool {
self.lock().insert((ri.bucket.clone(), ri.name.clone(), ri.version_id))
self.lock().insert(ReplicationInFlightKey::from(ri))
}
fn finish(&self, ri: &ReplicateObjectInfo) {
self.lock().remove(&(ri.bucket.clone(), ri.name.clone(), ri.version_id));
self.lock().remove(&ReplicationInFlightKey::from(ri));
}
#[cfg(test)]
@@ -2438,26 +2457,21 @@ impl ReplicationInFlight {
/// including when replication panics.
struct ReplicationInFlightGuard {
in_flight: Arc<ReplicationInFlight>,
key: ReplicateObjectInfo,
key: ReplicationInFlightKey,
}
impl ReplicationInFlightGuard {
fn new(in_flight: Arc<ReplicationInFlight>, ri: &ReplicateObjectInfo) -> Self {
Self {
in_flight,
key: ReplicateObjectInfo {
bucket: ri.bucket.clone(),
name: ri.name.clone(),
version_id: ri.version_id,
..Default::default()
},
key: ReplicationInFlightKey::from(ri),
}
}
}
impl Drop for ReplicationInFlightGuard {
fn drop(&mut self) {
self.in_flight.finish(&self.key);
self.in_flight.lock().remove(&self.key);
}
}
@@ -4054,6 +4068,48 @@ mod tests {
assert_eq!(current_queue(&pool, "in-flight-bucket").await, (3, 3 * 4096));
}
#[tokio::test]
async fn queue_replica_task_admits_metadata_changes_during_an_initial_upload() {
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
let (tx, mut rx) = mpsc::channel(4);
pool.workers.write().await.push(tx);
let initial = ReplicateObjectInfo {
bucket: "pending-tags-bucket".to_string(),
name: "object".to_string(),
version_id: Some(uuid::Uuid::new_v4()),
size: 4096,
replication_generation: ReplicationGenerationSnapshot {
mutation_id: Some(uuid::Uuid::new_v4().to_string()),
..Default::default()
},
..Default::default()
};
let metadata = ReplicateObjectInfo {
op_type: ReplicationType::Metadata,
user_tags: String::new(),
replication_generation: ReplicationGenerationSnapshot {
mutation_id: Some(uuid::Uuid::new_v4().to_string()),
..Default::default()
},
..initial.clone()
};
assert_eq!(pool.queue_replica_task(initial.clone()).await, ReplicationQueueAdmission::Queued);
assert_eq!(pool.queue_replica_task(metadata.clone()).await, ReplicationQueueAdmission::Queued);
assert_eq!(pool.queue_replica_task(metadata.clone()).await, ReplicationQueueAdmission::Skipped);
assert_eq!(pool.in_flight.len(), 2);
drop(ReplicationInFlightGuard::new(pool.in_flight.clone(), &initial));
assert_eq!(pool.in_flight.len(), 1);
assert_eq!(pool.queue_replica_task(metadata.clone()).await, ReplicationQueueAdmission::Skipped);
for expected in [&initial, &metadata] {
let ReplicationOperation::Object(queued) = rx.recv().await.expect("queued operation") else {
panic!("expected object replication");
};
assert_eq!(queued.replication_generation, expected.replication_generation);
}
drop(ReplicationInFlightGuard::new(pool.in_flight.clone(), &metadata));
assert_eq!(pool.in_flight.len(), 0);
}
#[tokio::test]
async fn queue_replica_task_releases_the_version_when_no_worker_accepts_it() {
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
@@ -6609,6 +6609,7 @@ mod tests {
health_check_duration: std::time::Duration::from_secs(5),
replicate_sync: false,
client: Arc::new(aws_sdk_s3::Client::from_conf(config)),
timeouts: Default::default(),
})
}
+12
View File
@@ -259,6 +259,10 @@ pub fn check_object_name_for_length_and_slash(bucket: &str, object: &str) -> Res
pub fn check_new_object_args(bucket: &str, object: &str, data_movement: bool) -> Result<()> {
check_put_object_args(bucket, object)?;
if !data_movement && !is_meta_bucketname(bucket) && has_xml_invalid_control_characters(object) {
return Err(StorageError::ObjectNameInvalid(bucket.to_owned(), object.to_owned()));
}
if cfg!(target_os = "windows") && !data_movement && !is_meta_bucketname(bucket) && object.contains('\\') {
return Err(StorageError::InvalidArgument(
bucket.to_owned(),
@@ -270,6 +274,14 @@ pub fn check_new_object_args(bucket: &str, object: &str, data_movement: bool) ->
Ok(())
}
/// New public keys must remain representable in an unencoded XML listing.
/// Existing keys still use the read/delete validators so they can be removed.
pub fn has_xml_invalid_control_characters(object: &str) -> bool {
object
.chars()
.any(|character| character < ' ' && !matches!(character, '\t' | '\n' | '\r'))
}
pub fn check_copy_obj_args(bucket: &str, object: &str) -> Result<()> {
check_bucket_and_object_names(bucket, object)
}
+238 -33
View File
@@ -34,7 +34,7 @@ use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinSet;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, warn};
use tracing::{Instrument, debug, error, warn};
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_METACACHE: &str = "metacache";
@@ -224,6 +224,8 @@ pub struct ListPathRawOptions {
pub recursive: bool,
pub incl_deleted: bool,
pub skip_hidden_prefix_check: bool,
pub hidden_entry_budget: usize,
pub pending_prefix: Option<String>,
pub filter_prefix: Option<String>,
pub forward_to: Option<String>,
pub min_disks: usize,
@@ -260,6 +262,8 @@ impl Clone for ListPathRawOptions {
recursive: self.recursive,
incl_deleted: self.incl_deleted,
skip_hidden_prefix_check: self.skip_hidden_prefix_check,
hidden_entry_budget: self.hidden_entry_budget,
pending_prefix: self.pending_prefix.clone(),
filter_prefix: self.filter_prefix.clone(),
forward_to: self.forward_to.clone(),
min_disks: self.min_disks,
@@ -289,6 +293,8 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
recursive: opts.recursive,
incl_deleted: opts.incl_deleted,
skip_hidden_prefix_check: opts.skip_hidden_prefix_check,
hidden_entry_budget: opts.hidden_entry_budget,
pending_prefix: opts.pending_prefix.clone(),
report_notfound: opts.report_not_found,
filter_prefix: opts.filter_prefix.clone(),
forward_to: opts.forward_to.clone(),
@@ -301,13 +307,15 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
}
/// How a merged raw listing stream ended.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ListPathRawOutcome {
/// At least one producer stopped at its per-disk limit instead of at the
/// end of its namespace, so the closed stream is not proof of exhaustion.
/// Carried in-band by the metacache stream trailer, so remote producers
/// report it as well.
pub limit_reached: bool,
/// Inclusive sorted boundary proved by every reader before a bounded stop.
pub scan_cut: Option<String>,
}
pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> disk::error::Result<()> {
@@ -363,10 +371,15 @@ async fn list_path_raw_inner(
let producer_errs: Arc<[OnceLock<DiskError>]> = (0..opts.disks.len()).map(|_| OnceLock::new()).collect::<Vec<_>>().into();
let cancel_rx = CancellationToken::new();
let walk_options = Arc::new(walk_dir_options(&opts));
for (disk_idx, disk) in opts.disks.iter().enumerate() {
let opdisk = disk.clone();
let opts_clone = opts.clone();
let walk_options = walk_options.clone();
#[cfg(test)]
let test_reader_behavior = opts.test_reader_behaviors.get(disk_idx).cloned();
#[cfg(test)]
let test_disk_io_context_observed = opts.test_disk_io_context_observed.clone();
let fallback_claim_tracker = fallback_claim_tracker.clone();
let fds_clone = fds.clone();
#[cfg(test)]
@@ -376,17 +389,17 @@ async fn list_path_raw_inner(
let disk_io_context_clone = disk_io_context.clone();
let (rd, wr) = tokio::io::duplex(64 * 1024);
readers.push(MetacacheReader::new(rd));
jobs.spawn(with_optional_disk_io_context(disk_io_context_clone, async move {
let producer = with_optional_disk_io_context(disk_io_context_clone, async move {
let mut wr = PublishedBytesWriter::new(wr);
#[cfg(test)]
if let Some(observer) = &opts_clone.test_disk_io_context_observed {
if let Some(observer) = &test_disk_io_context_observed {
observer.store(
crate::disk::current_disk_io_context().is_some_and(|context| context.class == DiskIoClass::Background),
std::sync::atomic::Ordering::Release,
);
}
#[cfg(test)]
let test_primary_error = if let Some(behavior) = opts_clone.test_reader_behaviors.get(disk_idx).cloned() {
let test_primary_error = if let Some(behavior) = test_reader_behavior {
match behavior {
TestReaderBehavior::Eof => return Ok(()),
TestReaderBehavior::Entries(entries) => {
@@ -443,8 +456,6 @@ async fn list_path_raw_inner(
None
};
let wakl_opts = walk_dir_options(&opts_clone);
let mut need_fallback = false;
let mut last_err = None;
#[cfg(test)]
@@ -459,7 +470,7 @@ async fn list_path_raw_inner(
if !need_fallback && let Some(disk) = opdisk {
let primary_walk_started = std::time::Instant::now();
match disk.walk_dir(wakl_opts, &mut wr).await {
match disk.walk_dir((*walk_options).clone(), &mut wr).await {
Ok(_res) => {
rustfs_io_metrics::record_stage_duration(
"metacache_walk_dir_primary",
@@ -483,8 +494,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "walk_dir_missing_path",
error = ?err,
@@ -495,8 +506,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "walk_dir_failed",
error = ?err,
@@ -598,8 +609,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "fallback_disk_missing_for_path",
error = ?err,
@@ -610,8 +621,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "fallback_disk_missing",
error = ?err,
@@ -626,7 +637,7 @@ async fn list_path_raw_inner(
}
let fallback_walk_started = std::time::Instant::now();
match disk.as_ref().walk_dir(walk_dir_options(&opts_clone), &mut wr).await {
match disk.as_ref().walk_dir((*walk_options).clone(), &mut wr).await {
Ok(_r) => {
rustfs_io_metrics::record_stage_duration(
"metacache_walk_dir_fallback",
@@ -652,8 +663,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "fallback_walk_dir_missing_path",
error = ?err,
@@ -664,8 +675,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "fallback_walk_dir_failed",
error = ?err,
@@ -687,12 +698,13 @@ async fn list_path_raw_inner(
// warn!("list_path_raw: while need_fallback done");
Ok(())
}));
});
jobs.spawn(producer.in_current_span());
}
let revjob_rx = rx.clone();
let mut revjobs = JoinSet::new();
revjobs.spawn(async move {
let merge = async move {
// Consumer-side peek timeout, and a caveat worth understanding
// (rustfs/backlog#1217).
//
@@ -722,6 +734,8 @@ async fn list_path_raw_inner(
// Sticky across rounds: once any reader's trailer says its producer
// stopped at the per-disk limit, the merged stream may hide more keys.
let mut limit_reached = false;
let mut scan_cut = None;
let mut last_scanned_key: Option<String> = None;
loop {
let mut current = MetaCacheEntry::default();
@@ -733,7 +747,7 @@ async fn list_path_raw_inner(
// );
if revjob_rx.is_cancelled() {
return Ok(ListPathRawOutcome { limit_reached });
return Ok(ListPathRawOutcome { limit_reached, scan_cut });
}
let mut top_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
@@ -793,7 +807,12 @@ async fn list_path_raw_inner(
continue;
}
// eof
limit_reached |= r.limit_reached() == Some(true);
if r.limit_reached() == Some(true) {
limit_reached = true;
scan_cut = Some(last_scanned_key.clone().ok_or_else(|| {
DiskError::other("bounded listing reader ended without a scan boundary")
})?);
}
at_eof += 1;
// warn!("list_path_raw: peek eof, disk: {}", i);
continue;
@@ -867,7 +886,7 @@ async fn list_path_raw_inner(
*r = MetacacheReader::new(detached_rd);
continue;
}
PeekOutcome::Cancelled => return Ok(ListPathRawOutcome { limit_reached }),
PeekOutcome::Cancelled => return Ok(ListPathRawOutcome { limit_reached, scan_cut }),
}
};
@@ -926,7 +945,7 @@ async fn list_path_raw_inner(
if let Some(finished_fn) = opts.finished.as_ref() {
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached, scan_cut }),
_ = finished_fn(&errs) => {}
}
}
@@ -976,6 +995,14 @@ async fn list_path_raw_inner(
return Err(classify_listing_quorum_failure(&failures));
}
// The next head from a bounded reader is unknown. The previous
// globally consumed group is a safe cut; consuming another reader's
// later head would turn this absence of evidence into a skipped key.
// Check quorum failures above before accepting the bounded result.
if limit_reached {
break;
}
// Break if all at EOF or error.
if at_eof + has_err == readers.len() {
if has_err > 0
@@ -983,7 +1010,7 @@ async fn list_path_raw_inner(
{
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached, scan_cut }),
_ = finished_fn(&errs) => {}
}
}
@@ -994,6 +1021,7 @@ async fn list_path_raw_inner(
break;
}
last_scanned_key = Some(current.name.clone());
if agree == readers.len() && !opts.preserve_replica_metadata {
for r in readers.iter_mut() {
if !r.skip_current() {
@@ -1005,7 +1033,7 @@ async fn list_path_raw_inner(
// warn!("list_path_raw: agreed_fn start, current: {:?}", &current.name);
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached, scan_cut }),
result = agreed_fn(current) => result?
}
// warn!("list_path_raw: agreed_fn done");
@@ -1029,13 +1057,14 @@ async fn list_path_raw_inner(
if let Some(partial_fn) = opts.partial.as_ref() {
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached, scan_cut }),
_ = partial_fn(MetaCacheEntries(top_entries), &errs) => {}
}
}
}
Ok(ListPathRawOutcome { limit_reached })
});
Ok(ListPathRawOutcome { limit_reached, scan_cut })
};
revjobs.spawn(merge.in_current_span());
let merge_started = std::time::Instant::now();
let Some(revjob) = revjobs.join_next().await else {
@@ -1199,6 +1228,24 @@ mod tests {
assert!(!options.skip_total_timeout);
}
#[test]
fn cloned_reader_options_preserve_hidden_scan_budget_and_pending_prefix() {
let request = ListPathRawOptions {
bucket: "versioned".to_owned(),
hidden_entry_budget: 100,
pending_prefix: Some("pending/".to_owned()),
forward_to: Some("pending/0099".to_owned()),
..Default::default()
};
// Both walks are built once from the shared options; the per-job clone
// must carry every field that the walk derives from the request.
let job = request.clone();
let walk = walk_dir_options(&job);
assert_eq!(walk.hidden_entry_budget, 100);
assert_eq!(walk.pending_prefix.as_deref(), request.pending_prefix.as_deref());
assert_eq!(walk.forward_to.as_deref(), Some("pending/0099"));
}
#[tokio::test]
async fn list_path_raw_empty_disks_returns_read_quorum() {
let err = list_path_raw(CancellationToken::new(), ListPathRawOptions::default())
@@ -1249,6 +1296,163 @@ mod tests {
);
}
#[tokio::test]
async fn list_path_raw_fanout_retains_only_linear_disk_references() {
let root = tempfile::tempdir().expect("create disk root");
let endpoint = crate::disk::endpoint::Endpoint::try_from(root.path().to_str().expect("UTF-8 disk path"))
.expect("parse disk endpoint");
let local = Arc::new(
crate::disk::local::LocalDisk::new(&endpoint, false)
.await
.expect("open local disk"),
);
let wrapper = crate::disk::disk_store::LocalDiskWrapper::new(local, false);
let disk = Arc::new(crate::disk::Disk::Local(Box::new(wrapper)));
let baseline = Arc::strong_count(&disk);
for primary_count in [1, 4, 16] {
for fallback_count in [0, 2] {
let entered: Vec<_> = (0..primary_count).map(|_| Arc::new(tokio::sync::Notify::new())).collect();
let stopped: Vec<_> = (0..primary_count).map(|_| Arc::new(tokio::sync::Notify::new())).collect();
let behaviors = entered
.iter()
.zip(&stopped)
.map(|(entered, stopped)| TestReaderBehavior::StallWithSignals {
entered: entered.clone(),
stopped: stopped.clone(),
})
.collect();
let cancel = CancellationToken::new();
let listing = tokio::spawn(list_path_raw(
cancel.clone(),
ListPathRawOptions {
disks: vec![Some(disk.clone()); primary_count],
fallback_disks: vec![Some(disk.clone()); fallback_count],
min_disks: primary_count,
test_reader_behaviors: behaviors,
peek_timeout: Some(Duration::from_secs(60)),
..Default::default()
},
));
for signal in &entered {
timeout(Duration::from_secs(2), signal.notified())
.await
.expect("every producer must enter the real fanout before counting references");
}
// The merge owns the configured primary/fallback slots, each producer
// owns its primary, and the shared fallback queue owns each fallback.
let expected = baseline + 2 * (primary_count + fallback_count);
let retained = Arc::strong_count(&disk);
cancel.cancel();
timeout(Duration::from_secs(2), listing)
.await
.expect("cancellation must stop the listing")
.expect("listing task must join")
.expect("external cancellation must finish cleanly");
for signal in &stopped {
timeout(Duration::from_secs(2), signal.notified())
.await
.expect("cancellation must drop every producer");
}
assert_eq!(Arc::strong_count(&disk), baseline, "cancellation must release every fanout reference");
assert_eq!(
retained, expected,
"fanout must retain linear references for {primary_count} primary and {fallback_count} fallback slots"
);
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn list_path_raw_producer_and_merge_preserve_request_span() {
use tracing_subscriber::{Layer, layer::SubscriberExt};
struct ProducerSpan(Arc<AtomicBool>);
#[derive(Default)]
struct StateField(bool);
impl tracing::field::Visit for StateField {
fn record_debug(&mut self, _: &tracing::field::Field, _: &dyn std::fmt::Debug) {}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0 |= field.name() == "state" && value == "walk_dir_missing_path";
}
}
impl<S> Layer<S> for ProducerSpan
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
{
fn on_event(&self, event: &tracing::Event<'_>, context: tracing_subscriber::layer::Context<'_, S>) {
let mut state = StateField::default();
event.record(&mut state);
if state.0
&& context
.lookup_current()
.is_some_and(|span| span.name() == "flink_list_request")
{
self.0.store(true, std::sync::atomic::Ordering::Release);
}
}
}
// The subscriber is thread-local; keep the spawned tasks on this runtime thread.
let producer_observed = Arc::new(AtomicBool::new(false));
let subscriber = tracing_subscriber::registry().with(ProducerSpan(producer_observed.clone()));
let _subscriber = tracing::subscriber::set_default(subscriber);
let request = tracing::info_span!("flink_list_request", request_id = "list-span-regression");
let root = tempfile::tempdir().expect("create disk root");
let endpoint = crate::disk::endpoint::Endpoint::try_from(root.path().to_str().expect("UTF-8 disk path"))
.expect("parse disk endpoint");
let local = Arc::new(
crate::disk::local::LocalDisk::new(&endpoint, false)
.await
.expect("open local disk"),
);
let wrapper = crate::disk::disk_store::LocalDiskWrapper::new(local, false);
let disk = Arc::new(crate::disk::Disk::Local(Box::new(wrapper)));
let error = list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![Some(disk)],
bucket: "missing-bucket".to_owned(),
min_disks: 1,
..Default::default()
},
)
.instrument(request.clone())
.await
.expect_err("missing bucket must fail the real disk producer");
assert_eq!(error, DiskError::VolumeNotFound);
assert!(
producer_observed.load(std::sync::atomic::Ordering::Acquire),
"disk error must retain the request span"
);
let merge_observed = Arc::new(AtomicBool::new(false));
let merge_sink = merge_observed.clone();
let request_id = request.id();
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None],
min_disks: 1,
test_reader_behaviors: vec![TestReaderBehavior::Entries(vec![fallback_test_entry()])],
agreed: Some(Box::new(move |_| {
merge_sink.store(tracing::Span::current().id() == request_id, std::sync::atomic::Ordering::Release);
Box::pin(async { Ok(()) })
})),
..Default::default()
},
)
.instrument(request)
.await
.expect("merge should deliver the test entry");
assert!(
merge_observed.load(std::sync::atomic::Ordering::Acquire),
"merge callback must retain the request span"
);
}
#[tokio::test]
async fn scanner_joinset_walk_uses_the_local_disk_background_gate() {
let root = tempfile::tempdir().expect("local disk root should be created");
@@ -1316,6 +1520,7 @@ mod tests {
outcome.limit_reached,
"a reader whose trailer reports a limit stop must mark the merged stream as possibly incomplete"
);
assert_eq!(outcome.scan_cut.as_deref(), Some("bucket/object"));
}
#[tokio::test]
+44 -1
View File
@@ -1588,7 +1588,13 @@ fn verify_tonic_replay_scope_signature(audience: &str, path: &str, headers: &Hea
if boot_epoch != tonic_rpc_boot_epoch() {
return Err(std::io::Error::other("RPC boot epoch is stale"));
}
check_and_record_tonic_nonce(nonce, signed_at, path)
check_and_record_tonic_nonce(nonce, signed_at, path)?;
// A body-bound request also carries a usable v2 signature. Consume both
// nonces so stripping the replay-scope headers cannot authorize it again.
if content_sha256 != UNSIGNED_PAYLOAD {
verify_tonic_rpc_signature_with_strictness(audience, path, headers, true)?;
}
Ok(())
}
/// Verify gRPC authentication, preferring v2 without downgrade on malformed v2 metadata.
@@ -2579,6 +2585,43 @@ mod tests {
assert_eq!(path_error.to_string(), "Invalid RPC replay scope signature");
}
#[test]
fn replay_scope_consumes_the_body_bound_v2_nonce() {
ensure_test_rpc_secret();
let audience = "node-a:9000";
let path = "/node_service.NodeService/RecoverDeleteTransaction";
let digest = "a".repeat(64);
for v3_first in [true, false] {
let mut headers =
gen_tonic_signature_headers(audience, "node_service.NodeService", "RecoverDeleteTransaction", Some(&digest))
.expect("body-bound v2 headers should build");
let timestamp = headers[TIMESTAMP_HEADER].to_str().expect("timestamp").to_string();
headers.extend(
gen_tonic_replay_scope_headers(audience, path, &timestamp, &digest, tonic_rpc_boot_epoch())
.expect("replay-scope headers should build"),
);
let mut downgraded = headers.clone();
for header in [
RPC_REPLAY_SCOPE_VERSION_HEADER,
RPC_REPLAY_SCOPE_SIGNATURE_HEADER,
RPC_REPLAY_SCOPE_NONCE_HEADER,
RPC_BOOT_EPOCH_HEADER,
] {
downgraded.remove(header);
}
let (first, replay) = if v3_first {
(&headers, &downgraded)
} else {
(&downgraded, &headers)
};
verify_tonic_rpc_signature_with_policy(audience, path, first, true, false, false)
.expect("the first delivery must be accepted");
let error = verify_tonic_rpc_signature_with_policy(audience, path, replay, true, false, false)
.expect_err("a mutation cannot replay through the other signature generation");
assert_eq!(error.to_string(), "RPC request replay detected");
}
}
#[test]
fn replay_scope_rejects_partial_metadata_and_stale_epoch_without_fallback() {
ensure_test_rpc_secret();
@@ -2014,9 +2014,21 @@ impl PeerRestClient {
}
result
})
.await
.unwrap_or_else(|_| Err(Error::other(format!("load_bucket_metadata({bucket}) timed out"))));
self.finalize_result(result).await
.await;
self.settle_bucket_metadata_reload(bucket, result).await
}
async fn settle_bucket_metadata_reload(
&self,
bucket: &str,
result: std::result::Result<Result<()>, tokio::time::error::Elapsed>,
) -> Result<()> {
match result {
Ok(result) => self.finalize_result(result).await,
// A local operation deadline does not establish that the peer is
// unreachable. In particular, it must not gate IAM revocations.
Err(_) => Err(Error::other(format!("load_bucket_metadata({bucket}) timed out"))),
}
}
async fn load_bucket_metadata_once(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
@@ -4753,6 +4765,31 @@ mod tests {
assert!(client.offline.load(Ordering::Acquire));
}
#[tokio::test]
async fn bucket_metadata_deadline_does_not_gate_healthy_peer_notifications() {
let client = test_peer_client();
let elapsed = tokio::time::timeout(std::time::Duration::ZERO, std::future::pending::<Result<()>>()).await;
let error = client
.settle_bucket_metadata_reload("slow-policy", elapsed)
.await
.expect_err("local budget expires");
assert!(error.to_string().contains("timed out"));
assert!(
!client.offline.load(Ordering::Acquire),
"local work must not suppress subsequent IAM RPCs"
);
client
.settle_bucket_metadata_reload("healthy", Ok(Ok(())))
.await
.expect("peer reply");
let error = client
.settle_bucket_metadata_reload("unreachable", Ok(Err(Error::from(tonic::Status::unavailable("connection refused")))))
.await
.expect_err("real transport failure");
assert!(error.to_string().contains("connection refused"));
assert!(client.offline.load(Ordering::Acquire), "transport failures must still enter recovery");
}
#[tokio::test]
async fn peer_rest_client_finalize_result_keeps_online_for_business_errors() {
let client = test_peer_client();
+428 -1
View File
@@ -1082,7 +1082,10 @@ impl RemoteDisk {
if payload.len() > operation.max_wire_payload_bytes() {
return Err(Error::other("delete recovery operation exceeds payload limit"));
}
let settle_timeout = if matches!(&operation, DeleteRecoveryRpcOperation::Settle { .. }) {
let settle_timeout = if matches!(
&operation,
DeleteRecoveryRpcOperation::Settle { .. } | DeleteRecoveryRpcOperation::SettleBounded(_)
) {
Some(crate::disk::get_delete_recovery_timeout()?)
} else {
None
@@ -2716,6 +2719,54 @@ impl RemoteDisk {
#[async_trait::async_trait]
impl DiskAPI for RemoteDisk {
async fn list_multipart_index_roots(
&self,
bucket: &str,
marker: Option<&str>,
) -> Result<crate::disk::multipart_index::MultipartIndexPage> {
use crate::disk::multipart_index::{MULTIPART_INDEX_RPC_MAX_BYTES, MultipartIndexRpcReply, MultipartIndexRpcRequest};
let expected_disk = self
.id
.lock()
.await
.filter(|id| !id.is_nil())
.ok_or(DiskError::DiskNotFound)?;
let query = MultipartIndexRpcRequest::new(bucket, marker);
let endpoint = self.endpoint.to_string();
let canonical = query.canonical_request(&endpoint)?;
let opts = serde_json::to_string(&query)?;
self.execute_with_timeout(
|| async {
let mut client = self.get_client().await?;
let mut request = Request::new(DiskInfoRequest {
disk: endpoint.clone(),
opts: opts.clone(),
});
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
let response = client.disk_info(request).await?.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
if response.disk_info.len() > MULTIPART_INDEX_RPC_MAX_BYTES {
return Err(DiskError::FileCorrupt);
}
// An old server ignores the additive query and returns ordinary DiskInfo.
// It cannot satisfy the dedicated reply schema or the fresh challenge.
let reply: MultipartIndexRpcReply =
serde_json::from_str(&response.disk_info).map_err(|_| DiskError::MethodNotAllowed)?;
reply.validate(&query, expected_disk)?;
crate::cluster::rpc::verify_tonic_rpc_response_proof(
&query.canonical_response(&endpoint, &reply.page)?,
&reply.proof,
)
.map_err(Error::other)?;
Ok(reply.page)
},
get_drive_disk_info_timeout(),
)
.await
}
async fn supports_guarded_delete_versions(&self) -> Result<()> {
let response = self.guarded_delete_versions_rpc("", Vec::new(), true).await?;
if !response.errors.is_empty() || !response.item_errors.is_empty() {
@@ -4299,6 +4350,38 @@ impl DiskAPI for RemoteDisk {
}
}
async fn discover_delete_transactions_bounded(
&self,
volume: &str,
object: &str,
max_entries: u32,
) -> Result<crate::disk::BoundedDeleteRecoveryDiscovery> {
// Older peers reject this variant. Do not retry through the unbounded protocol;
// they are reported as unsupported participants (rolling-upgrade contract).
match self
.recover_delete_transaction(
volume,
DeleteRecoveryRpcOperation::DiscoverBounded {
object: object.to_owned(),
max_entries,
},
)
.await
{
Ok(DeleteRecoveryRpcReply::DiscoveredBounded(page)) => Ok(page),
Ok(_) => Err(Error::FileCorrupt),
Err(error)
if matches!(error, Error::MethodNotAllowed | Error::UnsupportedDisk)
|| matches!(&error, Error::Io(source) if crate::cluster::rpc::client::embedded_tonic_status(source)
.is_some_and(|status| matches!(status.code(), tonic::Code::Unimplemented | tonic::Code::InvalidArgument))) =>
{
// No paginated fallback through the unbounded protocol; the peer
// is an unsupported participant until it is upgraded.
Err(Error::UnsupportedDisk)
}
Err(error) => Err(error),
}
}
async fn discover_delete_transactions(
&self,
volume: &str,
@@ -4334,6 +4417,62 @@ impl DiskAPI for RemoteDisk {
_ => Err(Error::other("unexpected delete discovery reply")),
}
}
async fn settle_delete_transaction_bounded(
&self,
volume: &str,
request: crate::disk::BoundedDeleteRecoverySettlement,
_namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<crate::disk::BoundedDeleteRecoverySettlementReply> {
match self
.recover_delete_transaction(volume, DeleteRecoveryRpcOperation::SettleBounded(request))
.await
{
Ok(DeleteRecoveryRpcReply::SettledBounded(reply)) => Ok(reply),
Ok(_) => Err(Error::FileCorrupt),
Err(error)
if matches!(error, Error::MethodNotAllowed | Error::UnsupportedDisk)
|| matches!(&error, Error::Io(source) if crate::cluster::rpc::client::embedded_tonic_status(source)
.is_some_and(|status| matches!(status.code(), tonic::Code::Unimplemented | tonic::Code::InvalidArgument))) =>
{
// No paginated fallback through the unbounded protocol; the peer
// is an unsupported participant until it is upgraded.
Err(Error::UnsupportedDisk)
}
Err(error) => Err(error),
}
}
async fn inspect_delete_transaction_bounded(
&self,
volume: &str,
object: &str,
transaction: Uuid,
max_entries: u32,
) -> Result<crate::disk::BoundedDeleteRecoveryInspection> {
match self
.recover_delete_transaction(
volume,
DeleteRecoveryRpcOperation::InspectBounded {
object: object.to_owned(),
transaction,
max_entries,
},
)
.await
{
Ok(DeleteRecoveryRpcReply::InspectedBounded(page)) => Ok(page),
Ok(_) => Err(Error::FileCorrupt),
Err(error)
if matches!(error, Error::MethodNotAllowed | Error::UnsupportedDisk)
|| matches!(&error, Error::Io(source) if crate::cluster::rpc::client::embedded_tonic_status(source)
.is_some_and(|status| matches!(status.code(), tonic::Code::Unimplemented | tonic::Code::InvalidArgument))) =>
{
// No paginated fallback through the unbounded protocol; the peer
// is an unsupported participant until it is upgraded.
Err(Error::UnsupportedDisk)
}
Err(error) => Err(error),
}
}
async fn inspect_delete_transaction(
&self,
volume: &str,
@@ -4854,6 +4993,113 @@ mod tests {
}
}
#[tokio::test]
async fn bounded_delete_discovery_lost_remote_reply_leaves_no_cursor() {
runtime_sources::ensure_test_rpc_secret();
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("test peer must bind");
let directory = tempfile::tempdir().expect("temporary disk");
let endpoint = Endpoint::try_from(directory.path().to_str().expect("disk path")).expect("endpoint");
let disk = Arc::new(
crate::disk::local::LocalDisk::new(&endpoint, false)
.await
.expect("local disk"),
);
disk.make_volume("bucket").await.expect("bucket");
let prefix = directory.path().join("bucket/prefix");
std::fs::create_dir(&prefix).expect("prefix");
for index in 0..64 {
std::fs::create_dir(prefix.join(format!("child-{index}"))).expect("legitimate child");
}
let fixture = Arc::new(TestBoundedDiscovery {
disk: Arc::clone(&disk),
response_gate: TestFormatReadGate::default(),
});
*peer.peer.bounded_discovery.lock().expect("bounded fixture") = Some(Arc::clone(&fixture));
let remote = remote_disk_for_addr(&peer.addr).await;
let task = tokio::spawn(async move { remote.discover_delete_transactions_bounded("bucket", "prefix", 8).await });
tokio::time::timeout(Duration::from_secs(5), fixture.response_gate.entered.notified())
.await
.expect("disk completed bounded scan");
task.abort();
assert!(task.await.expect_err("caller cancelled").is_cancelled());
assert_eq!(disk.public_delete_discovery_sessions_for_test(), 0);
fixture.response_gate.release.cancel();
peer.stop().await;
assert_eq!(disk.public_delete_discovery_sessions_for_test(), 0);
assert!(prefix.join("child-63").is_dir());
}
#[tokio::test]
async fn multipart_index_old_disk_info_handler_is_not_a_ready_empty_index() {
runtime_sources::ensure_test_rpc_secret();
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("old peer must bind");
let remote = remote_disk_for_addr(&peer.addr).await;
remote
.set_disk_id(Some(Uuid::new_v4()))
.await
.expect("expected disk identity");
assert!(matches!(
remote.list_multipart_index_roots("bucket", None).await,
Err(DiskError::MethodNotAllowed)
));
assert_eq!(
peer.peer.disk_info_calls.load(Ordering::Acquire),
1,
"exercise the actual legacy JSON handler"
);
peer.stop().await;
}
#[tokio::test]
async fn bounded_delete_discovery_old_peer_rejects_without_paginated_fallback() {
runtime_sources::ensure_test_rpc_secret();
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("old peer must bind");
let remote = remote_disk_for_addr(&peer.addr).await;
assert_eq!(
remote
.discover_delete_transactions_bounded("bucket", "prefix", 4096)
.await
.expect_err("old peer cannot prove a bounded inventory"),
DiskError::UnsupportedDisk
);
let transaction = Uuid::new_v4();
assert_eq!(
remote
.inspect_delete_transaction_bounded("bucket", "prefix", transaction, 65536)
.await
.expect_err("old peer cannot inspect with a budget"),
DiskError::UnsupportedDisk
);
assert_eq!(
remote
.settle_delete_transaction_bounded(
"bucket",
crate::disk::BoundedDeleteRecoverySettlement {
object: "prefix".to_owned(),
transaction,
expected_fingerprint: [0; 32],
action: DeleteRecoveryAction::PublishCommit,
max_entries: 65536,
},
None
)
.await
.expect_err("old peer cannot settle with a budget"),
DiskError::UnsupportedDisk
);
assert_eq!(
*peer.peer.unsupported_methods.lock().expect("observed methods"),
vec!["/node_service.NodeService/RecoverDeleteTransaction"; 3]
);
peer.stop().await;
}
#[tokio::test]
async fn multipart_source_recovery_old_peer_rejects_without_generic_fallback() {
runtime_sources::ensure_test_rpc_secret();
@@ -5745,10 +5991,18 @@ mod tests {
object_read_all_disks: Arc<StdMutex<Vec<String>>>,
format_data: Bytes,
read_all_data: Bytes,
fail_format_reads: Arc<AtomicBool>,
next_format_read_gate: Arc<StdMutex<Option<Arc<TestFormatReadGate>>>>,
bounded_discovery: Arc<StdMutex<Option<Arc<TestBoundedDiscovery>>>>,
unsupported_methods: Arc<StdMutex<Vec<String>>>,
}
#[derive(Debug)]
struct TestBoundedDiscovery {
disk: Arc<crate::disk::local::LocalDisk>,
response_gate: TestFormatReadGate,
}
#[derive(Debug, Default)]
struct TestFormatReadGate {
entered: tokio::sync::Notify,
@@ -5764,7 +6018,9 @@ mod tests {
object_read_all_disks: Arc::default(),
format_data,
read_all_data,
fail_format_reads: Arc::default(),
next_format_read_gate: Arc::default(),
bounded_discovery: Arc::default(),
unsupported_methods: Arc::default(),
}
}
@@ -5864,6 +6120,56 @@ mod tests {
Ok(grpc.unary(method, request).await)
})
}
"/node_service.NodeService/RecoverDeleteTransaction"
if self.peer.bounded_discovery.lock().expect("bounded fixture").is_some() =>
{
#[derive(Clone)]
struct BoundedSvc(Arc<TestBoundedDiscovery>);
impl tonic::server::UnaryService<RecoverDeleteTransactionRequest> for BoundedSvc {
type Response = rustfs_protos::proto_gen::node_service::RecoverDeleteTransactionResponse;
type Future = Pin<Box<dyn Future<Output = std::result::Result<Response<Self::Response>, Status>> + Send>>;
fn call(&mut self, request: Request<RecoverDeleteTransactionRequest>) -> Self::Future {
let fixture = Arc::clone(&self.0);
Box::pin(async move {
let request = request.into_inner();
let operation: DeleteRecoveryRpcOperation =
rustfs_protos::decode_delete_recovery_payload(&request.operation)
.map_err(|_| Status::invalid_argument("invalid bounded request"))?;
let DeleteRecoveryRpcOperation::DiscoverBounded { object, max_entries } = operation else {
return Err(Status::invalid_argument("unexpected fallback"));
};
let page = fixture
.disk
.discover_delete_transactions_bounded(&request.volume, &object, max_entries)
.await
.map_err(|_| Status::internal("bounded discovery failed"))?;
// Lose the response only after the real disk operation has completed.
fixture.response_gate.entered.notify_one();
fixture.response_gate.release.cancelled().await;
let result = rmp_serde::to_vec_named(&DeleteRecoveryRpcReply::DiscoveredBounded(page))
.map_err(|_| Status::internal("encode bounded reply"))?;
Ok(Response::new(rustfs_protos::proto_gen::node_service::RecoverDeleteTransactionResponse {
success: true,
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
result: result.into(),
error: None,
}))
})
}
}
let fixture = self
.peer
.bounded_discovery
.lock()
.expect("bounded fixture")
.clone()
.expect("configured fixture");
Box::pin(async move {
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec);
Ok(grpc.unary(BoundedSvc(fixture), request).await)
})
}
"/node_service.NodeService/ReadAll" => {
#[derive(Clone)]
struct ReadAllSvc(AuthenticatedReadPeer);
@@ -5887,6 +6193,9 @@ mod tests {
gate.entered.notify_one();
gate.release.cancelled().await;
}
if peer.fail_format_reads.load(Ordering::Acquire) {
return Err(Status::unavailable("format read temporarily unavailable"));
}
peer.format_data.clone()
} else {
peer.object_read_all_disks
@@ -8852,6 +9161,124 @@ mod tests {
peer.stop().await;
}
#[tokio::test]
#[serial(remote_disk_recovery_probe)]
async fn endpoint_reconnect_failure_preserves_registered_remote_recovery() {
temp_env::async_with_vars(
[
(crate::disk::disk_store::ENV_RUSTFS_DRIVE_ACTIVE_MONITORING, Some("true")),
(rustfs_config::ENV_DRIVE_RETURNING_SUCCESS_THRESHOLD, Some("1")),
],
async {
runtime_sources::ensure_test_rpc_secret();
let mut reference = crate::layout::format::FormatV3::new(1, 1);
reference.erasure.this = reference.erasure.sets[0][0];
let peer = TestGrpcPeer::spawn(
Bytes::from(reference.to_json().expect("format should serialize")),
Bytes::from_static(b"recovered-peer-data"),
)
.await
.expect("authenticated regression peer is required");
let set = missing_read_set(&peer, reference).await;
let disk = crate::disk::new_disk(
&set.set_endpoints[0],
&DiskOption {
cleanup: false,
health_check: true,
},
)
.await
.expect("registered remote disk should construct");
let crate::disk::Disk::Remote(remote) = disk.as_ref() else {
panic!("fixture must create a remote disk");
};
assert!(remote.health_check);
remote.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
set.disks.write().await[0] = Some(Arc::clone(&disk));
peer.peer.fail_format_reads.store(true, Ordering::Release);
set.connect_disks().await;
peer.peer.fail_format_reads.store(false, Ordering::Release);
let cancelled_after_failure = remote.cancel_token.is_cancelled();
assert!(peer.peer.read_all_calls() > 0, "reconnect must attempt the failing format RPC");
assert!(
Arc::ptr_eq(&disk, set.disks.read().await[0].as_ref().expect("existing slot must remain")),
"a failed reconnect must not publish its unvalidated candidate"
);
remote.spawn_recovery_monitor_if_needed();
let recovery = time::timeout(Duration::from_secs(5), async {
while !disk.is_online().await {
tokio::task::yield_now().await;
}
disk.read_all("bucket", "object").await
})
.await;
disk.close().await.expect("stop the registered disk's monitoring");
peer.stop().await;
assert!(!cancelled_after_failure, "failed replacement must retain the registered monitor");
assert_eq!(
recovery
.expect("failed reconnect must leave the registered disk able to recover without another sweep")
.expect("the recovered registered disk must serve real I/O"),
Bytes::from_static(b"recovered-peer-data")
);
},
)
.await;
}
#[tokio::test]
#[serial(remote_disk_recovery_probe)]
async fn endpoint_reconnect_stops_old_remote_monitor_only_after_publication() {
runtime_sources::ensure_test_rpc_secret();
let mut reference = crate::layout::format::FormatV3::new(1, 1);
reference.erasure.this = reference.erasure.sets[0][0];
let peer = TestGrpcPeer::spawn(
Bytes::from(reference.to_json().expect("format should serialize")),
Bytes::from_static(b"replacement-peer-data"),
)
.await
.expect("authenticated regression peer is required");
let set = missing_read_set(&peer, reference).await;
let disk = crate::disk::new_disk(
&set.set_endpoints[0],
&DiskOption {
cleanup: false,
health_check: true,
},
)
.await
.expect("registered remote disk should construct");
let crate::disk::Disk::Remote(remote) = disk.as_ref() else {
panic!("fixture must create a remote disk");
};
remote.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
set.disks.write().await[0] = Some(Arc::clone(&disk));
let gate = peer.peer.pause_next_format_read();
let pending_set = Arc::clone(&set);
let pending = tokio::spawn(async move { pending_set.connect_disks().await });
time::timeout(Duration::from_secs(5), gate.entered.notified())
.await
.expect("replacement must reach the format validation barrier");
let cancelled_before_publication = remote.cancel_token.is_cancelled();
gate.release.cancel();
pending.await.expect("validated replacement must finish publication");
let replacement = set.disks.read().await[0].clone().expect("replacement must occupy the slot");
let readback = replacement.read_all("bucket", "object").await;
replacement.close().await.expect("stop replacement monitoring");
peer.stop().await;
assert!(
!cancelled_before_publication,
"the registered monitor must survive pending format validation"
);
assert!(!Arc::ptr_eq(&disk, &replacement), "validated replacement must be published");
assert!(remote.cancel_token.is_cancelled(), "the displaced monitor must stop after publication");
assert_eq!(
readback.expect("replacement must serve real I/O"),
Bytes::from_static(b"replacement-peer-data")
);
}
#[tokio::test]
#[serial(remote_disk_recovery_probe)]
async fn endpoint_reconnect_admits_ready_drive_while_another_format_probe_is_blocked() {
+527 -56
View File
@@ -18,6 +18,7 @@ use crate::cluster::rpc::client::{
use crate::cluster::rpc::set_tonic_rolling_mutation_body_digest;
use async_trait::async_trait;
use bytes::Bytes;
use futures::{FutureExt, future::BoxFuture};
use rustfs_lock::{
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
types::{LockId, LockMetadata, LockPriority},
@@ -32,16 +33,17 @@ use rustfs_protos::{
};
use std::collections::HashMap;
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::pin::Pin;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time::{Instant, timeout};
use tonic::service::interceptor::InterceptedService;
use tonic::{Request, Response};
use tracing::{debug, info, warn};
const EVENT_NAMESPACE_LOCK_RELEASE: &str = "namespace_lock_release";
const EVENT_REMOTE_LOCK_RPC_CLEANUP: &str = "remote_lock_rpc_cleanup";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_REMOTE_LOCK: &str = "remote_lock";
@@ -49,18 +51,38 @@ fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(req
set_tonic_rolling_mutation_body_digest(request)
}
/// Work to run if an RPC that already timed out for its caller completes later.
/// Work to run if an RPC completes after its caller stops waiting.
type LateCompletion<T> = Option<Box<dyn FnOnce(T) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send>>;
type LateFailure = LateCompletion<()>;
type RpcFuture<T> = BoxFuture<'static, std::thread::Result<std::result::Result<T, tonic::Status>>>;
struct RpcCleanup<T> {
late: LateCompletion<T>,
late_failure: LateFailure,
/// Own admission before connecting as well as while an RPC is in flight.
struct LockRequestGuard<'a> {
client: &'a RemoteClient,
op: &'static str,
admission: Option<LockRequestAdmission>,
}
impl<T> RpcCleanup<T> {
fn new(late: LateCompletion<T>, late_failure: LateFailure, admission: Option<LockRequestAdmission>) -> Self {
impl LockRequestGuard<'_> {
fn into_admission(mut self) -> Option<LockRequestAdmission> {
self.admission.take()
}
}
impl Drop for LockRequestGuard<'_> {
fn drop(&mut self) {
self.client.release_lock_request(self.op, self.admission.take());
}
}
struct RpcCleanup<'a, T> {
late: LateCompletion<T>,
late_failure: LateFailure,
admission: Option<LockRequestGuard<'a>>,
}
impl<'a, T> RpcCleanup<'a, T> {
fn new(late: LateCompletion<T>, late_failure: LateFailure, admission: Option<LockRequestGuard<'a>>) -> Self {
Self {
late,
late_failure,
@@ -69,6 +91,25 @@ impl<T> RpcCleanup<T> {
}
}
/// A dropped caller hands its RPC and cleanup to the same bounded owner as a timeout.
struct InFlightRpc<'a, T: Send + 'static> {
client: &'a RemoteClient,
op: &'static str,
resource_summary: &'a str,
future: Option<RpcFuture<T>>,
cleanup: Option<RpcCleanup<'a, T>>,
}
impl<T: Send + 'static> Drop for InFlightRpc<'_, T> {
fn drop(&mut self) {
if let Some(future) = self.future.take()
&& let Some(cleanup) = self.cleanup.take()
{
self.client.detach_rpc(self.op, self.resource_summary, future, cleanup);
}
}
}
/// The liveness window is this many RPC deadlines: a peer that completed a
/// lock RPC within it is slow, not gone, and keeps its channel on a timeout.
const LOCK_RPC_LIVENESS_WINDOW_DEADLINES: u32 = 2;
@@ -137,6 +178,9 @@ fn with_lock_peer_health<R>(addr: &str, update: impl FnOnce(&mut LockPeerChannel
let mut peers = lock_peer_channel_health()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(health) = peers.get_mut(addr) {
return update(health);
}
update(peers.entry(addr.to_string()).or_default())
}
@@ -158,6 +202,15 @@ fn reset_lock_peer_health_for_test(addr: &str) {
.remove(addr);
}
#[cfg(test)]
type EvictionPause = (tokio::sync::oneshot::Sender<()>, tokio::sync::oneshot::Receiver<()>);
#[cfg(test)]
fn eviction_pauses_for_test() -> &'static Mutex<HashMap<String, EvictionPause>> {
static PAUSES: OnceLock<Mutex<HashMap<String, EvictionPause>>> = OnceLock::new();
PAUSES.get_or_init(Mutex::default)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EvictionTrigger {
/// The caller's deadline expired while the stream was still open.
@@ -506,6 +559,14 @@ impl RemoteClient {
admission
}
fn admit_lock_request_guard(&self, op: &'static str) -> Option<LockRequestGuard<'_>> {
self.admit_lock_request(op).map(|admission| LockRequestGuard {
client: self,
op,
admission: Some(admission),
})
}
fn release_lock_request(&self, op: &'static str, admission: Option<LockRequestAdmission>) {
if is_request_breaker_operation(op) && admission.is_some() {
with_lock_peer_health(&self.addr, |health| {
@@ -587,6 +648,17 @@ impl RemoteClient {
trigger: EvictionTrigger,
deadline: Duration,
) {
#[cfg(test)]
let pause = eviction_pauses_for_test()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&self.addr);
#[cfg(test)]
if let Some((entered, resume)) = pause {
let _ = entered.send(());
let _ = resume.await;
}
let now = Instant::now();
let cooldown = Self::eviction_cooldown();
let liveness_window = Self::liveness_window(deadline);
@@ -617,22 +689,26 @@ impl RemoteClient {
);
}
/// Keep a timed-out RPC running instead of cancelling its stream.
/// Keep an abandoned RPC running instead of cancelling its stream.
///
/// Dropping the future sends `RST_STREAM`; under load those resets pile up
/// in the server's pending-accept queue until it answers `GOAWAY
/// too_many_resets` and kills every stream on the connection. A detached
/// stream ends on its own within the internode RPC timeout, the number per
/// peer is bounded, and a lock granted after its caller gave up is released.
fn detach_timed_out_rpc<T: Send + 'static>(
fn detach_rpc<T: Send + 'static>(
&self,
op: &'static str,
resource_summary: &str,
handle: JoinHandle<std::result::Result<T, tonic::Status>>,
late: LateCompletion<T>,
late_failure: LateFailure,
admission: Option<LockRequestAdmission>,
future: RpcFuture<T>,
cleanup: RpcCleanup<'_, T>,
) {
let RpcCleanup {
late,
late_failure,
admission,
} = cleanup;
let admission = admission.and_then(LockRequestGuard::into_admission);
let limit = Self::detached_rpc_limit();
let admitted = with_lock_peer_health(&self.addr, |health| {
if health.detached_rpcs >= limit {
@@ -643,25 +719,29 @@ impl RemoteClient {
}
});
if !admitted {
handle.abort();
drop(future);
self.release_lock_request(op, admission);
if let Some(late_failure) = late_failure {
drop(tokio::spawn(late_failure(())));
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "aborted");
debug!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "detached_budget_exhausted",
addr = %self.addr,
op,
resource_summary,
limit,
"Cancelled timed-out remote lock RPC because the detached stream budget is exhausted"
"Abandoned remote lock RPC cancelled"
);
return;
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "detached");
let addr = self.addr.clone();
tokio::spawn(async move {
let outcome = handle.await;
let outcome = future.await;
with_lock_peer_health(&addr, |health| {
health.detached_rpcs = health.detached_rpcs.saturating_sub(1);
if is_request_breaker_operation(op) && admission.is_some() {
@@ -688,19 +768,31 @@ impl RemoteClient {
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "error");
debug!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "late_rpc_failed",
addr = %addr,
op,
tonic_code = ?status.code(),
tonic_message = status.message(),
"Detached remote lock RPC failed after its caller timed out"
"Abandoned remote lock RPC failed"
);
}
Err(join_error) => {
Err(_) => {
if let Some(late_failure) = late_failure {
late_failure(()).await;
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "join_error");
debug!(addr = %addr, op, error = %join_error, "Detached remote lock RPC task ended abnormally");
debug!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "late_rpc_panicked",
addr = %addr,
op,
"Abandoned remote lock RPC panicked"
);
}
}
});
@@ -788,16 +880,28 @@ impl RemoteClient {
self.release_late_release();
rustfs_io_metrics::lock_metrics::record_remote_lock_late_release(outcome);
if outcome == "released" {
debug!(addr = %self.addr, count = original_count, "Released remote locks granted after their caller timed out");
debug!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "late_grants_released",
addr = %self.addr,
count = original_count,
"Released remote locks granted after their caller stopped waiting"
);
} else {
let (log_warning, suppressed_timeouts) = self.timeout_log_decision("late_release", Self::rpc_timeout());
if log_warning {
warn!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "late_grants_unreleased",
addr = %self.addr,
count = original_count,
outcome,
suppressed_timeouts,
"Could not release every remote lock granted after its caller timed out; the server lease will expire it"
"Could not release every remote lock granted after its caller stopped waiting; the server lease will expire it"
);
} else {
debug!(addr = %self.addr, count = original_count, outcome, suppressed_timeouts, "Suppressed repeated late remote lock release warning");
@@ -811,25 +915,47 @@ impl RemoteClient {
resource_summary: &str,
deadline: Duration,
future: Fut,
cleanup: RpcCleanup<T>,
cleanup: RpcCleanup<'_, T>,
) -> std::result::Result<T, LockError>
where
Fut: Future<Output = std::result::Result<T, tonic::Status>> + Send + 'static,
T: Send + 'static,
{
let RpcCleanup {
late,
let mut rpc = InFlightRpc {
client: self,
op,
resource_summary,
future: Some(AssertUnwindSafe(future).catch_unwind().boxed()),
cleanup: Some(cleanup),
};
let outcome = match rpc.future.as_mut() {
Some(future) => tokio::select! {
// A response becoming ready on the deadline tick must not turn an
// expired caller budget into a transport error and channel eviction.
biased;
_ = tokio::time::sleep(deadline) => None,
outcome = future => Some(outcome),
},
None => return Err(LockError::internal("remote lock RPC has no in-flight future")),
};
if outcome.is_some() {
rpc.future.take();
}
let Some(RpcCleanup {
late: _,
late_failure,
admission,
} = cleanup;
let mut handle = tokio::spawn(future);
match timeout(deadline, &mut handle).await {
Ok(Ok(Ok(response))) => {
self.record_rpc_success(op, admission);
self.release_lock_request(op, admission);
}) = rpc.cleanup.as_mut()
else {
return Err(LockError::internal("remote lock RPC has no cleanup owner"));
};
let token = admission.as_ref().and_then(|guard| guard.admission);
let result = match outcome {
Some(Ok(Ok(response))) => {
self.record_rpc_success(op, token);
Ok(response)
}
Ok(Ok(Err(err))) => {
Some(Ok(Err(err))) => {
let reason = err.to_string();
if err.code() == tonic::Code::DeadlineExceeded {
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
@@ -862,12 +988,11 @@ impl RemoteClient {
"Suppressed repeated remote lock RPC deadline"
);
}
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
.await;
self.release_lock_request(op, admission);
if let Some(late_failure) = late_failure {
if let Some(late_failure) = late_failure.take() {
drop(tokio::spawn(late_failure(())));
}
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
.await;
return Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline));
}
// Only evict (and re-dial) the cached channel when the failure is a genuine
@@ -928,30 +1053,32 @@ impl RemoteClient {
);
}
if transport_failure {
// Ambiguous-grant cleanup owns its work before channel eviction can suspend.
if let Some(late_failure) = late_failure.take() {
drop(tokio::spawn(late_failure(())));
}
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Transport, deadline)
.await;
}
self.release_lock_request(op, admission);
if transport_failure && let Some(late_failure) = late_failure {
drop(tokio::spawn(late_failure(())));
}
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
}
Ok(Err(join_error)) => {
Some(Err(_)) => {
warn!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "rpc_panicked",
addr = %self.addr,
op,
resource_summary,
error = %join_error,
"Remote lock RPC task ended abnormally"
"Remote lock RPC panicked"
);
self.release_lock_request(op, admission);
if let Some(late_failure) = late_failure {
if let Some(late_failure) = late_failure.take() {
drop(tokio::spawn(late_failure(())));
}
Err(LockError::internal(format!("{op} RPC task failed: {join_error}")))
Err(LockError::internal(format!("{op} RPC future panicked")))
}
Err(_) => {
None => {
let reason = format!("RPC timed out after {deadline:?}");
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
self.record_request_timeout(op, deadline);
@@ -983,12 +1110,17 @@ impl RemoteClient {
"Suppressed repeated remote lock RPC timeout"
);
}
// Continue draining the abandoned stream while channel eviction waits.
drop(rpc);
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
.await;
self.detach_timed_out_rpc(op, resource_summary, handle, late, late_failure, admission);
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline))
return Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline));
}
};
if rpc.future.is_none() {
rpc.cleanup.take();
}
result
}
fn rpc_timeout_failure_response(request: &LockRequest, err: &LockError) -> LockResponse {
@@ -1079,7 +1211,7 @@ impl RemoteClient {
#[async_trait]
impl LockClient for RemoteClient {
async fn acquire_lock(&self, request: &LockRequest) -> Result<LockResponse> {
let admission = match self.admit_lock_request("lock") {
let admission = match self.admit_lock_request_guard("lock") {
Some(admission) => admission,
None => return Ok(Self::rpc_backoff_failure_response(request)),
};
@@ -1090,7 +1222,6 @@ impl LockClient for RemoteClient {
Ok(client) => client,
Err(err) => {
self.record_request_timeout("lock", rpc_timeout);
self.release_lock_request("lock", Some(admission));
return Err(err);
}
};
@@ -1098,14 +1229,12 @@ impl LockClient for RemoteClient {
let args = match serde_json::to_string(&bounded_request) {
Ok(args) => args,
Err(err) => {
self.release_lock_request("lock", Some(admission));
return Err(LockError::internal(format!("Failed to serialize request: {err}")));
}
};
let mut req = Request::new(GenerallyLockRequest { args });
req.set_timeout(rpc_timeout);
if let Err(err) = attach_lock_mutation_body_digest(&mut req) {
self.release_lock_request("lock", Some(admission));
return Err(err.into());
}
let late = self.late_release_hook(request.lock_id.clone());
@@ -1147,7 +1276,7 @@ impl LockClient for RemoteClient {
return Ok(Vec::new());
}
let admission = match self.admit_lock_request("lock_batch") {
let admission = match self.admit_lock_request_guard("lock_batch") {
Some(admission) => admission,
None => return Ok(Self::rpc_backoff_failure_batch(requests)),
};
@@ -1161,7 +1290,6 @@ impl LockClient for RemoteClient {
Ok(client) => client,
Err(err) => {
self.record_request_timeout("lock_batch", rpc_timeout);
self.release_lock_request("lock_batch", Some(admission));
return Err(err);
}
};
@@ -1175,14 +1303,12 @@ impl LockClient for RemoteClient {
{
Ok(args) => args,
Err(err) => {
self.release_lock_request("lock_batch", Some(admission));
return Err(err);
}
};
let mut req = Request::new(BatchGenerallyLockRequest { args });
req.set_timeout(rpc_timeout);
if let Err(err) = attach_lock_mutation_body_digest(&mut req) {
self.release_lock_request("lock_batch", Some(admission));
return Err(err.into());
}
let late = self.late_release_batch_hook(requests.iter().map(|request| request.lock_id.clone()).collect());
@@ -1710,6 +1836,351 @@ mod tests {
assert!(acquired_lock_ids(&lock_ids, &[]).is_empty());
}
#[tokio::test]
async fn test_remote_client_cancelled_connection_releases_request_admission() {
ensure_test_rpc_secret();
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind pending-handshake peer");
let addr = format!("http://{}", listener.local_addr().expect("pending-handshake peer address"));
let client = RemoteClient::new(addr.clone());
let request = test_lock_request(Duration::from_secs(30));
for batch in [false, true] {
reset_lock_peer_health_for_test(&addr);
with_lock_peer_health(&addr, |health| {
health.request_backoff_until = Some(Instant::now() - Duration::from_secs(1));
});
let mut acquisition = Box::pin(async {
if batch {
client.acquire_locks_batch(std::slice::from_ref(&request)).await.map(|_| ())
} else {
client.acquire_lock(&request).await.map(|_| ())
}
});
std::future::poll_fn(|cx| {
assert!(acquisition.as_mut().poll(cx).is_pending(), "peer must wait for its HTTP/2 handshake");
std::task::Poll::Ready(())
})
.await;
let pending = lock_peer_health_for_test(&addr);
assert_eq!(pending.request_in_flight, 1, "pending connection owns one request admission");
assert!(pending.request_probe_generation.is_some(), "the request is the half-open probe");
drop(acquisition);
let cancelled = lock_peer_health_for_test(&addr);
assert_eq!(cancelled.request_in_flight, 0, "cancellation must return request admission");
assert!(cancelled.request_probe_generation.is_none(), "cancellation must unblock the next probe");
let probe = client.admit_lock_request("lock").expect("cancelled peer can be probed again");
client.release_lock_request("lock", Some(probe));
}
reset_lock_peer_health_for_test(&addr);
}
#[tokio::test]
async fn test_remote_client_cancelled_rpc_runs_late_grant_cleanup() {
let addr = "http://cancelled-rpc-late-grant";
reset_lock_peer_health_for_test(addr);
let client = RemoteClient::new(addr.to_string());
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (grant_tx, grant_rx) = tokio::sync::oneshot::channel();
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(async move {
let late: LateCompletion<bool> = Some(Box::new(move |granted| {
Box::pin(async move {
released_tx.send(granted).expect("late grant observer remains alive");
})
}));
client
.execute_rpc(
"lock",
"bucket/object",
Duration::from_secs(30),
async move {
started_tx.send(()).expect("caller observes RPC dispatch");
Ok(grant_rx.await.expect("test decides when peer grants lock"))
},
RpcCleanup::new(late, None, None),
)
.await
});
timeout(Duration::from_secs(1), started_rx)
.await
.expect("RPC must dispatch")
.expect("dispatch signal");
caller.abort();
assert!(caller.await.expect_err("caller was cancelled").is_cancelled());
assert_eq!(
lock_peer_health_for_test(addr).detached_rpcs,
1,
"cancelled RPC uses bounded detached ownership"
);
grant_tx.send(true).expect("cancellation must preserve the running RPC");
assert!(
timeout(Duration::from_secs(1), released_rx)
.await
.expect("late grant must be cleaned up")
.expect("cleanup result")
);
assert_eq!(lock_peer_health_for_test(addr).detached_rpcs, 0, "completed cleanup returns its slot");
reset_lock_peer_health_for_test(addr);
}
#[tokio::test]
async fn test_remote_client_cancelled_rpc_runs_ambiguous_failure_cleanup() {
let addr = "http://cancelled-rpc-late-failure";
reset_lock_peer_health_for_test(addr);
let client = RemoteClient::new(addr.to_string());
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (failure_tx, failure_rx) = tokio::sync::oneshot::channel();
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(async move {
let late_failure: LateFailure = Some(Box::new(move |()| {
Box::pin(async move {
released_tx.send(()).expect("ambiguous cleanup observer remains alive");
})
}));
client
.execute_rpc(
"lock_batch",
"bucket/object",
Duration::from_secs(30),
async move {
started_tx.send(()).expect("caller observes RPC dispatch");
failure_rx.await.expect("test decides when transport fails");
Err::<(), _>(tonic::Status::cancelled("transport failure after caller cancellation"))
},
RpcCleanup::new(None, late_failure, None),
)
.await
});
timeout(Duration::from_secs(1), started_rx)
.await
.expect("RPC must dispatch")
.expect("dispatch signal");
caller.abort();
assert!(caller.await.expect_err("caller was cancelled").is_cancelled());
failure_tx.send(()).expect("cancellation must preserve the running RPC");
timeout(Duration::from_secs(1), released_rx)
.await
.expect("ambiguous grant must be cleaned up")
.expect("cleanup signal");
assert_eq!(lock_peer_health_for_test(addr).detached_rpcs, 0, "failed detached RPC returns its slot");
reset_lock_peer_health_for_test(addr);
}
#[tokio::test]
async fn test_remote_client_cancelled_completed_failure_runs_cleanup() {
for (label, status) in [
("transport", tonic::Status::cancelled("ambiguous transport failure")),
("deadline", tonic::Status::deadline_exceeded("ambiguous deadline failure")),
] {
let addr = format!("http://cancelled-completed-{label}");
reset_lock_peer_health_for_test(&addr);
let client = RemoteClient::new(addr.clone());
let (evicting_tx, evicting_rx) = tokio::sync::oneshot::channel();
let (_resume_tx, resume_rx) = tokio::sync::oneshot::channel();
eviction_pauses_for_test()
.lock()
.expect("eviction pause registry")
.insert(addr.clone(), (evicting_tx, resume_rx));
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(async move {
let late_failure: LateFailure = Some(Box::new(move |()| {
Box::pin(async move {
released_tx
.send(())
.expect("completed-failure cleanup observer remains alive");
})
}));
client
.execute_rpc(
"lock",
"bucket/object",
Duration::from_secs(30),
async move { Err::<(), _>(status) },
RpcCleanup::new(None, late_failure, None),
)
.await
});
timeout(Duration::from_secs(1), evicting_rx)
.await
.expect("completed error must reach channel eviction")
.expect("eviction signal");
timeout(Duration::from_secs(1), released_rx)
.await
.expect("cleanup must transfer before channel eviction suspends")
.expect("cleanup signal");
caller.abort();
assert!(caller.await.expect_err("caller is cancelled during eviction").is_cancelled());
assert_eq!(
lock_peer_health_for_test(&addr).detached_rpcs,
0,
"completed RPC cannot consume a detached slot"
);
reset_lock_peer_health_for_test(&addr);
}
}
#[tokio::test(start_paused = true)]
async fn test_remote_client_deadline_wins_over_same_tick_transport_timeout() {
let addr = "http://same-tick-rpc-deadline";
reset_lock_peer_health_for_test(addr);
with_lock_peer_health(addr, |health| health.last_success = Some(Instant::now()));
let client = RemoteClient::new(addr.to_string());
let budget = Duration::from_millis(50);
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let late_failure: LateFailure = Some(Box::new(move |()| {
Box::pin(async move {
released_tx.send(()).expect("same-tick cleanup observer remains alive");
})
}));
let result = client
.execute_rpc(
"lock",
"bucket/object",
budget,
async move {
tokio::time::sleep(budget).await;
Err::<(), _>(tonic::Status::cancelled("inner transport deadline expired"))
},
RpcCleanup::new(None, late_failure, None),
)
.await;
assert!(
matches!(result, Err(LockError::Timeout { .. })),
"expired caller budget must retain timeout classification"
);
let health = lock_peer_health_for_test(addr);
assert_eq!(health.consecutive_timeouts, 1, "same-tick completion must follow timeout eviction policy");
assert!(
health.last_eviction.is_none(),
"a recently serving peer keeps its channel after a caller timeout"
);
timeout(Duration::from_secs(1), released_rx)
.await
.expect("same-tick ambiguous completion must trigger cleanup")
.expect("cleanup signal");
assert_eq!(
lock_peer_health_for_test(addr).detached_rpcs,
0,
"same-tick completion returns detached admission"
);
reset_lock_peer_health_for_test(addr);
}
#[tokio::test]
async fn test_remote_client_timeout_drains_late_grant_during_eviction() {
let addr = "http://timeout-late-grant-during-eviction";
reset_lock_peer_health_for_test(addr);
let client = RemoteClient::new(addr.to_string());
let (evicting_tx, evicting_rx) = tokio::sync::oneshot::channel();
let (resume_tx, resume_rx) = tokio::sync::oneshot::channel();
eviction_pauses_for_test()
.lock()
.expect("eviction pause registry")
.insert(addr.to_string(), (evicting_tx, resume_rx));
let (grant_tx, grant_rx) = tokio::sync::oneshot::channel();
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(async move {
let late: LateCompletion<bool> = Some(Box::new(move |granted| {
Box::pin(async move {
released_tx.send(granted).expect("late grant observer remains alive");
})
}));
client
.execute_rpc(
"lock",
"bucket/object",
Duration::from_millis(25),
async move { Ok(grant_rx.await.expect("test controls the late grant")) },
RpcCleanup::new(late, None, None),
)
.await
});
timeout(Duration::from_secs(1), evicting_rx)
.await
.expect("timed-out RPC must reach channel eviction")
.expect("eviction signal");
assert_eq!(
lock_peer_health_for_test(addr).detached_rpcs,
1,
"eviction waits with a detached RPC owner"
);
grant_tx
.send(true)
.expect("timed-out stream remains available for a late grant");
assert!(
timeout(Duration::from_secs(1), released_rx)
.await
.expect("late grant must be cleaned up before eviction resumes")
.expect("late cleanup result")
);
assert!(!caller.is_finished(), "channel eviction is still paused");
assert_eq!(
lock_peer_health_for_test(addr).detached_rpcs,
0,
"late completion returns detached admission"
);
resume_tx.send(()).expect("resume channel eviction");
let result = caller.await.expect("timed-out caller completes without panicking");
assert!(
matches!(result, Err(LockError::Timeout { .. })),
"caller preserves its RPC timeout result"
);
reset_lock_peer_health_for_test(addr);
}
#[tokio::test]
async fn test_remote_client_success_polls_rpc_on_callers_task() {
tokio::spawn(async {
let client = RemoteClient::new("http://direct-poll-success".to_string());
let caller_id = tokio::task::id();
let rpc_id = client
.execute_rpc(
"ping",
"health-lock-online",
Duration::from_secs(1),
async { Ok(tokio::task::id()) },
RpcCleanup::new(None, None, None),
)
.await
.expect("successful RPC must return its result");
assert_eq!(rpc_id, caller_id, "successful RPC must not allocate and schedule a separate task");
})
.await
.expect("caller task must complete");
}
#[tokio::test]
async fn test_remote_client_rpc_panic_isolated_and_cleans_up() {
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(async move {
let client = RemoteClient::new("http://direct-poll-panic".to_string());
let late_failure: LateFailure = Some(Box::new(move |()| {
Box::pin(async move {
released_tx.send(()).expect("panic cleanup observer remains alive");
})
}));
client
.execute_rpc::<(), _>(
"lock",
"bucket/object",
Duration::from_secs(1),
async { panic!("test peer RPC panic") },
RpcCleanup::new(None, late_failure, None),
)
.await
});
let result = caller.await.expect("RPC panic must not escape to its caller");
assert!(
matches!(result, Err(LockError::Internal { .. })),
"RPC panic remains an internal lock failure"
);
timeout(Duration::from_secs(1), released_rx)
.await
.expect("panic must trigger ambiguous-grant cleanup")
.expect("cleanup signal");
}
#[tokio::test]
#[serial_test::serial]
async fn test_remote_client_timeout_keeps_channel_of_recently_serving_peer() {
@@ -217,6 +217,264 @@ async fn pool_meta_native_physical_split_without_ordinary_commit_performs_no_mut
"all current heads, identities and complete backups must remain unchanged"
);
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
state
.ensure_write_safe("ordinary authority temporarily unavailable")
.expect("missing ordinary authority alone must not install a permanent corruption latch");
}
async fn interrupted_first_commit_fixture() -> (PhysicalFixture, PoolMetaCommittedCandidate) {
let fixture = fixture(3).await;
let PoolMetaReplica::Valid {
previous: Some(previous),
committed: false,
..
} = decode_pool_meta_replica(fixture.pending.clone())
else {
panic!("fixture must retain its acknowledged predecessor");
};
let physical = disks(&fixture.store).await;
let prepared = replicas(&fixture.pending, OffsetDateTime::now_utc()).await;
for (index, disk) in physical[0].iter().enumerate() {
fs::write(object_path(disk).join("xl.meta"), &prepared[index])
.await
.expect("one pool must retain an ordinary-readable PREPARE");
}
(fixture, *previous)
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_interrupted_first_commit_restores_proven_predecessor() {
let (mut fixture, previous) = interrupted_first_commit_fixture().await;
let before = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(
before[1..]
.iter()
.all(|read| matches!(read.replica, PoolMetaReplica::Unreadable(_)))
);
assert!(matches!(before[0].replica, PoolMetaReplica::Valid { committed: false, .. }));
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
assert!(
repair(&fixture.store, &mut state, &fence)
.await
.expect("ordinary PREPARE must authorize restoring only its acknowledged predecessor")
);
state
.ensure_write_safe("first commit rollback")
.expect("rollback must finish its transaction");
assert_ne!(
previous.canonical, fixture.canonical,
"the interrupted COMMIT must not become authoritative"
);
fixture.canonical = previous.canonical;
fixture.revision = previous.revision;
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_interrupted_rollback_retains_authority_and_resumes() {
let (mut fixture, previous) = interrupted_first_commit_fixture().await;
let pause = Arc::new(NativeApplyPause::default());
let reached = pause.reached.notified();
tokio::pin!(reached);
reached.as_mut().enable();
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let mut state = clean_state(&fixture);
let recovery = tokio::spawn({
let store = Arc::clone(&fixture.store);
let pause = Arc::clone(&pause);
async move {
AFTER_NATIVE_POOL_APPLY
.scope(pause, async move { repair(&store, &mut state, &fence).await })
.await
}
});
tokio::time::timeout(std::time::Duration::from_secs(10), reached)
.await
.expect("real rollback should pause after one pool is durably restored");
let partial = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(partial[0].replica, PoolMetaReplica::Valid { committed: false, .. }));
assert!(matches!(&partial[1].replica, PoolMetaReplica::Valid { canonical, committed: true, .. }
if canonical == &previous.canonical));
assert!(matches!(partial[2].replica, PoolMetaReplica::Unreadable(_)));
recovery.abort();
assert!(
recovery
.await
.expect_err("the paused repair must be cancelled")
.is_cancelled()
);
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
assert!(
repair(&fixture.store, &mut state, &fence)
.await
.expect("restart must resume from the retained ordinary PREPARE")
);
fixture.canonical = previous.canonical;
fixture.revision = previous.revision;
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_rollback_resumes_after_last_prepare_loses_read_quorum() {
let (mut fixture, previous) = interrupted_first_commit_fixture().await;
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let inspected = inspect(&fixture.store.pools, &fence)
.await
.expect("physical rollback inventory");
let replacements = plan(
&inspected,
&fixture.canonical,
fixture.revision,
&fixture.identity,
Some(&previous.canonical),
)
.await
.expect("the ordinary PREPARE must authorize its predecessor");
for (pool, count) in [(1, 4), (2, 4), (0, 2)] {
for (index, replacement) in replacements[pool].iter().enumerate().take(count) {
let snapshot = &inspected[pool].snapshots[index];
assert_eq!(
inspected[pool].disks[index]
.pool_meta_native_recovery(Request::Apply {
object: PoolMetaNativeObject::PoolBin,
expected_disk_id: snapshot.disk_id,
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
expected_backup_inventory_sha256: snapshot.inventory_sha256().expect("original inventory hash"),
replacement: replacement.clone(),
})
.await
.expect("partial rollback must use the real conditional publication and sync path"),
Response::Applied
);
}
}
let partial = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(partial[0].replica, PoolMetaReplica::Unreadable(_)));
assert!(partial[1..].iter().all(|read| matches!(&read.replica,
PoolMetaReplica::Valid { canonical, committed: true, .. } if canonical == &previous.canonical)));
let mut state = clean_state(&fixture);
assert!(
repair(&fixture.store, &mut state, &fence)
.await
.expect("restored ordinary COMMIT must finish rollback after the last PREPARE becomes split")
);
fixture.canonical = previous.canonical;
fixture.revision = previous.revision;
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_rollback_backup_does_not_poison_reused_generation() {
let (mut fixture, previous) = interrupted_first_commit_fixture().await;
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
assert!(repair(&fixture.store, &mut state, &fence).await.expect("initial rollback"));
let revision = PoolMetaRevision {
transaction_id: Some(Uuid::new_v4()),
..fixture.revision
};
let mut meta = previous.meta.clone();
meta.pools[0].last_update += Duration::seconds(1);
let canonical = encode_pool_meta_v3_envelope(&meta, revision, true, None).expect("later COMMIT");
let pending = encode_pool_meta_v3_envelope(&meta, revision, false, Some(&previous)).expect("later PREPARE");
let modified = OffsetDateTime::now_utc() + Duration::seconds(1);
let split = split(&pending, &canonical, modified).await;
let commit = replicas(&canonical, modified).await;
let prepared = replicas(&pending, modified).await;
let directory = Uuid::new_v4();
let retained = snapshots(&fixture.store).await;
for (pool_index, pool) in disks(&fixture.store).await.into_iter().enumerate() {
for (disk_index, disk) in pool.iter().enumerate() {
assert!(
!retained[pool_index][disk_index].backups.is_empty(),
"the abandoned PREPARE must remain as real on-disk evidence"
);
let object = object_path(disk);
fs::write(object.join("xl.meta"), &if pool_index < 2 { &split } else { &commit }[disk_index])
.await
.expect("install later partial COMMIT without clearing old backups");
fs::create_dir(object.join(directory.to_string()))
.await
.expect("later backup directory");
fs::write(object.join(directory.to_string()).join("xl.meta.bkp"), &prepared[disk_index])
.await
.expect("later PREPARE backup");
}
}
assert!(
repair(&fixture.store, &mut state, &fence)
.await
.expect("an ordinary later COMMIT must survive the abandoned sibling PREPARE")
);
fixture.canonical = canonical;
fixture.revision = revision;
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_rollback_rejects_retained_commit_without_mutation() {
let (fixture, _) = interrupted_first_commit_fixture().await;
let physical = disks(&fixture.store).await;
let commit = replicas(&fixture.canonical, OffsetDateTime::now_utc()).await;
let directory = Uuid::new_v4();
let object = object_path(&physical[0][0]);
fs::create_dir(object.join(directory.to_string()))
.await
.expect("retained COMMIT directory");
fs::write(object.join(directory.to_string()).join("xl.meta.bkp"), &commit[0])
.await
.expect("retain evidence that the interrupted transaction may already have committed");
let before = snapshots(&fixture.store).await;
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
assert!(repair(&fixture.store, &mut state, &fence).await.is_err());
assert_eq!(snapshots(&fixture.store).await, before, "ambiguous COMMIT evidence must remain untouched");
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_rollback_rejects_competing_prepares_without_mutation() {
let (fixture, previous) = interrupted_first_commit_fixture().await;
let pending = encode_pool_meta_v3_envelope(
&previous.meta,
PoolMetaRevision {
transaction_id: Some(Uuid::new_v4()),
..fixture.revision
},
false,
Some(&previous),
)
.expect("competing PREPARE must remain structurally valid");
let physical = disks(&fixture.store).await;
let prepared = replicas(&pending, OffsetDateTime::now_utc()).await;
for (index, disk) in physical[1].iter().enumerate() {
fs::write(object_path(disk).join("xl.meta"), &prepared[index])
.await
.expect("second pool must expose the competing transaction through ordinary reads");
}
let before = snapshots(&fixture.store).await;
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let error = repair(&fixture.store, &mut state, &fence)
.await
.expect_err("two distinct prepared transactions must not authorize a guessed rollback");
assert_eq!(
error
.pool_metadata_failure()
.expect("transaction conflict must be typed")
.kind,
crate::error::PoolMetadataFailure::RecoveryRequired
);
assert_eq!(snapshots(&fixture.store).await, before, "competing transactions must remain untouched");
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
}
struct IdentityEpochFork {
@@ -468,7 +726,7 @@ async fn pool_meta_native_physical_partial_apply_resumes_after_conditional_rejec
let inspected = inspect(&fixture.store.pools, &fence)
.await
.expect("real native snapshots should be obtained");
let replacements = plan(&inspected, &fixture.canonical, fixture.revision, &fixture.identity)
let replacements = plan(&inspected, &fixture.canonical, fixture.revision, &fixture.identity, None)
.await
.expect("physical recovery plan should validate");
let snapshot = &inspected[0].snapshots[0];
@@ -30,6 +30,7 @@ pub(super) struct NativeApplyPause {
#[cfg(test)]
tokio::task_local! {
pub(super) static AFTER_NATIVE_APPLY: Arc<NativeApplyPause>;
pub(super) static AFTER_NATIVE_POOL_APPLY: Arc<NativeApplyPause>;
}
struct InspectedPool {
@@ -168,6 +169,7 @@ async fn plan(
canonical: &[u8],
revision: PoolMetaRevision,
identity: &[u8],
rollback: Option<&[u8]>,
) -> Result<Vec<Vec<Bytes>>> {
let mut pending = Vec::<Vec<u8>>::new();
let mut predecessors = Vec::<Vec<u8>>::new();
@@ -223,23 +225,83 @@ async fn plan(
}
}
}
let mut obsolete = Vec::<Vec<u8>>::new();
for payload in &decoded {
match decode_pool_meta_replica(payload.clone()) {
PoolMetaReplica::Valid {
// The last ordinary PREPARE can itself become split during rollback. In
// that case another pool already exposes the restored ordinary COMMIT.
// Its authority permits continuing the rollback, never promoting a child
// transaction reconstructed from disk evidence.
let mut continuation: Option<(Vec<u8>, PoolMetaRevision)> = None;
if rollback.is_none() {
let mut outstanding = Vec::new();
for pool in inspected {
for (index, snapshot) in pool.snapshots.iter().enumerate() {
let raw = snapshot.current.as_ref().ok_or_else(invalid)?;
if codec::validate_inline_pool_meta_shard(raw, index, canonical).await.is_err() {
outstanding.push((index, raw));
}
}
}
for payload in &decoded {
if let PoolMetaReplica::Valid {
canonical: candidate,
revision: candidate_revision,
committed: false,
previous: Some(previous),
..
} if candidate_revision == revision && candidate == canonical => {
if !pending.contains(payload) {
pending.push(payload.clone());
} = decode_pool_meta_replica(payload.clone())
&& previous.revision == revision
&& previous.canonical == canonical
{
let mut matches = true;
for (index, raw) in &outstanding {
matches &= codec::validate_inline_pool_meta_shard(raw, *index, payload).await.is_ok()
|| codec::validate_inline_pool_meta_shard(raw, *index, &candidate).await.is_ok();
}
if !predecessors.contains(&previous.canonical) {
predecessors.push(previous.canonical);
if matches {
if !outstanding.is_empty()
&& continuation.as_ref().is_some_and(|(selected, selected_revision)| {
selected != &candidate || *selected_revision != candidate_revision
})
{
return Err(known_validation(invalid(), "native_transaction_validation"));
}
continuation.get_or_insert((candidate, candidate_revision));
}
}
}
}
let (canonical, revision, rollback) = match &continuation {
Some((transaction, next_revision)) => (transaction.as_slice(), *next_revision, Some(canonical)),
None => (canonical, revision, rollback),
};
for payload in &decoded {
if let PoolMetaReplica::Valid {
canonical: candidate,
revision: candidate_revision,
committed: false,
previous: Some(previous),
..
} = decode_pool_meta_replica(payload.clone())
&& candidate_revision == revision
&& candidate == canonical
{
pending.push(payload.clone());
if !predecessors.contains(&previous.canonical) {
predecessors.push(previous.canonical);
}
}
}
if pending.len() > 1 {
return Err(known_validation(
Error::other("native V3 prepare records disagree on the predecessor"),
"native_transaction_validation",
));
}
let mut obsolete = Vec::<Vec<u8>>::new();
for payload in &decoded {
if pending.contains(payload) {
continue;
}
match decode_pool_meta_replica(payload.clone()) {
PoolMetaReplica::Valid { revision: older, .. }
if older.is_generation_protocol()
&& older.cluster_id == revision.cluster_id
@@ -251,6 +313,23 @@ async fn plan(
// fully reconstructed and bound into the native CAS.
obsolete.push(payload.clone());
}
PoolMetaReplica::Valid {
revision: abandoned,
committed: false,
previous: Some(previous),
..
} if abandoned.is_generation_protocol()
&& abandoned.cluster_id == revision.cluster_id
&& abandoned.epoch == revision.epoch
&& abandoned.generation == revision.generation
&& abandoned.transaction_id != revision.transaction_id
&& predecessors.contains(&previous.canonical) =>
{
// A rollback retains the failed PREPARE as evidence. A later
// transaction may reuse that generation, but only a backup of
// an uncommitted sibling with the same predecessor is obsolete.
obsolete.push(payload.clone());
}
PoolMetaReplica::Valid {
canonical: candidate,
revision: candidate_revision,
@@ -272,13 +351,7 @@ async fn plan(
_ => {}
}
}
if pending.len() > 1 {
return Err(known_validation(
Error::other("native V3 prepare records disagree on the predecessor"),
"native_transaction_validation",
));
}
if pending.is_empty() {
if pending.is_empty() || rollback.is_some_and(|previous| !predecessors.iter().any(|candidate| candidate == previous)) {
return Err(invalid());
}
for payload in &decoded {
@@ -294,13 +367,25 @@ async fn plan(
for (index, snapshot) in pool.snapshots.iter().enumerate() {
let raw = snapshot.current.as_ref().ok_or_else(invalid)?;
let mut matched = false;
for payload in std::iter::once(canonical).chain(pending.iter().map(Vec::as_slice)) {
for payload in std::iter::once(canonical)
.chain(pending.iter().map(Vec::as_slice))
.chain(rollback)
{
matched |= codec::validate_inline_pool_meta_shard(raw, index, payload).await.is_ok();
}
if !matched {
return Err(invalid());
}
for backup in &snapshot.backups {
if rollback.is_some()
&& codec::validate_inline_pool_meta_shard(&backup.data, index, canonical)
.await
.is_ok()
{
// A retained COMMIT might have been authoritative before
// this interruption. Never roll it back from PREPARE alone.
return Err(invalid());
}
let mut matched = false;
for payload in std::iter::once(canonical).chain(
pending
@@ -323,7 +408,7 @@ async fn plan(
let mut replacements = Vec::with_capacity(inspected.len());
for pool in inspected {
replacements.push(
codec::encode_inline_pool_meta_replicas(&current(pool)?, canonical, modified)
codec::encode_inline_pool_meta_replicas(&current(pool)?, rollback.unwrap_or(canonical), modified)
.await
.map_err(|error| codec_validation(error, "native_metadata_validation"))?,
);
@@ -338,10 +423,12 @@ pub(super) async fn repair(
) -> Result<bool> {
fence.ensure_held()?;
let reads = read_pool_meta_replicas(store.pools.clone(), true).await;
if !reads
let unreadable = reads
.iter()
.any(|read| matches!(read.replica, PoolMetaReplica::Unreadable(_)))
{
.enumerate()
.filter_map(|(index, read)| matches!(read.replica, PoolMetaReplica::Unreadable(_)).then_some(index))
.collect::<Vec<_>>();
if unreadable.is_empty() {
return Ok(false);
}
state.ensure_write_safe("native pool metadata recovery")?;
@@ -360,8 +447,9 @@ pub(super) async fn repair(
{
return Err(invalid());
}
// Only an ordinary, successfully read COMMIT grants authority. Rebuilding
// two erasure shards below proves integrity, never a commit decision.
// Ordinary reads grant authority: a COMMIT selects its own state, while a
// PREPARE can select only its acknowledged predecessor. Reconstruction
// below proves integrity and never promotes an interrupted transaction.
let ordinary_commits = reads
.iter()
.filter_map(|read| match &read.replica {
@@ -375,7 +463,22 @@ pub(super) async fn repair(
_ => None,
})
.collect::<Vec<_>>();
if ordinary_commits.is_empty() {
let ordinary_prepares = reads
.iter()
.filter_map(|read| match &read.replica {
PoolMetaReplica::Valid {
canonical,
revision,
committed: false,
previous: Some(previous),
..
} if revision.is_generation_protocol() && previous.revision.is_generation_protocol() => {
Some((canonical.clone(), *revision, previous.canonical.clone()))
}
_ => None,
})
.collect::<Vec<_>>();
if ordinary_commits.is_empty() && ordinary_prepares.is_empty() {
return Err(invalid());
}
let selection = select_pool_meta_replica_reads(reads)
@@ -395,10 +498,20 @@ pub(super) async fn repair(
return Err(invalid());
}
let canonical = selection.canonical.as_ref().ok_or_else(invalid)?;
let mut rollback = None;
for (pending, revision, previous) in &ordinary_prepares {
if previous == canonical {
if rollback.is_some_and(|candidate| candidate != (pending, *revision)) {
return Err(latch_validation(state, known_validation(invalid(), "native_transaction_validation")));
}
rollback = Some((pending, *revision));
}
}
if !selection.revision.is_generation_protocol()
|| !ordinary_commits
.iter()
.any(|(raw, revision)| raw == canonical && *revision == selection.revision)
|| (rollback.is_none()
&& !ordinary_commits
.iter()
.any(|(raw, revision)| raw == canonical && *revision == selection.revision))
{
return Err(invalid());
}
@@ -445,13 +558,18 @@ pub(super) async fn repair(
.await
.map_err(|error| latch_validation(state, error))?;
let inspected = inspect(&store.pools, fence).await?;
let replacements = plan(&inspected, canonical, selection.revision, &identity_bytes)
let (transaction, revision) = rollback.unwrap_or((canonical, selection.revision));
let replacements = plan(&inspected, transaction, revision, &identity_bytes, rollback.map(|_| canonical.as_slice()))
.await
.map_err(|error| latch_validation(state, error))?;
fence.ensure_held()?;
let mut arm = state.arm_transaction();
arm.phase = Some("native_repair_apply");
for (pool, replacements) in inspected.iter().zip(replacements) {
let mut apply_order = inspected.iter().zip(replacements).enumerate().collect::<Vec<_>>();
// Preserve ordinary authority until all unreadable pools are repaired.
// Cancellation can then resume from a PREPARE or a restored COMMIT.
apply_order.sort_by_key(|(index, _)| !unreadable.contains(index));
for (_, (pool, replacements)) in apply_order {
for ((disk, snapshot), replacement) in pool.disks.iter().zip(&pool.snapshots).zip(replacements) {
fence.ensure_held()?;
let request = Request::Apply {
@@ -466,6 +584,11 @@ pub(super) async fn repair(
_ => return Err(invalid()),
}
}
#[cfg(test)]
if let Ok(pause) = AFTER_NATIVE_POOL_APPLY.try_with(Arc::clone) {
pause.reached.notify_one();
pause.resume.notified().await;
}
}
#[cfg(test)]
if let Ok(pause) = AFTER_NATIVE_APPLY.try_with(Arc::clone) {
@@ -627,7 +750,7 @@ mod tests {
#[tokio::test]
async fn pool_meta_native_plan_restores_physical_split_with_fresh_common_identity() {
let f = fixture().await;
let repaired = plan(&f.pools, &f.commit, f.revision, &f.identity).await.unwrap();
let repaired = plan(&f.pools, &f.commit, f.revision, &f.identity, None).await.unwrap();
assert_eq!(repaired.len(), 4);
for pool in repaired {
assert_eq!(
@@ -648,7 +771,7 @@ mod tests {
let mut f = fixture().await;
let wrong = replicas(b"different identity", f.modified).await;
f.pools[0].snapshots[0].identity = Some(wrong[0].clone());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity, None).await.is_err());
}
#[tokio::test]
@@ -671,10 +794,10 @@ mod tests {
.unwrap();
let wrong = replicas(&higher, f.modified + Duration::seconds(2)).await;
f.pools[0].snapshots[0].current = Some(wrong[0].clone());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity, None).await.is_err());
f.pools[0].snapshots[0].current = Some(replicas(&f.pending, f.modified).await[0].clone());
f.pools[0].snapshots[0].backups[0].data = wrong[0].clone();
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity, None).await.is_err());
}
#[tokio::test]
@@ -717,17 +840,17 @@ mod tests {
}
}
assert!(older_pending != f.pending);
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_ok());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity, None).await.is_ok());
}
#[tokio::test]
async fn pool_meta_native_plan_rejects_missing_or_corrupt_evidence() {
let mut f = fixture().await;
let before = f.pools[1].snapshots[2].current.take();
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity, None).await.is_err());
f.pools[1].snapshots[2].current = before;
f.pools[1].snapshots[2].backups[0].data = Bytes::from_static(b"invalid metadata");
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity, None).await.is_err());
}
}
+153 -2
View File
@@ -8380,8 +8380,9 @@ where
})
});
let result = save_config_with_opts_and_metadata(pool, object, data, &opts).await;
let may_have_mutated = !matches!(&result, Err(Error::PreconditionFailed));
if !may_have_mutated {
let write_not_started = matches!(&result, Err(Error::Io(inner)) if inner.get_ref().is_some_and(|source| source.is::<crate::set_disk::SystemMetadataWriteNotStarted>()));
let may_have_mutated = !write_not_started && !matches!(&result, Err(Error::PreconditionFailed));
if matches!(&result, Err(Error::PreconditionFailed)) {
record_pool_meta_stale_write_rejection(phase);
}
let mut source = None;
@@ -8392,6 +8393,8 @@ where
source = Some(Arc::clone(&original));
if matches!(original.as_ref(), Error::PreconditionFailed) {
Err(Error::PreconditionFailed)
} else if write_not_started {
Err(Error::other(original))
} else {
Err(Error::other(pool_metadata_error(
crate::error::PoolMetadataFailure::TransactionUnknown,
@@ -30826,6 +30829,154 @@ mod pools_tests {
);
}
#[tokio::test]
async fn pool_meta_prewrite_quorum_rejection_allows_retry_after_disks_return() {
let (dirs, disks, pool) = crate::set_disk::hermetic_set_disks_isolated(4).await;
pool.disks.write().await[2..].fill(None);
let state = super::PoolMetaWriteState::default();
let data =
super::initialized_pool_meta_identity_for_test(uuid::Uuid::new_v4(), 1).expect("encode the deployment identity");
let mut transaction = state.arm_transaction();
save_pool_meta_object_cas(
pool.clone(),
POOL_META_IDENTITY_NAME,
data.clone(),
&PoolMetaCasToken::Missing,
&PoolMetaPersistenceFence::OfflineRecovery,
"identity_cas",
&mut transaction,
)
.await
.expect_err("two of four disks cannot satisfy the identity write quorum");
drop(transaction);
for dir in &dirs {
assert!(
!dir.path()
.join(crate::disk::RUSTFS_META_BUCKET)
.join(POOL_META_IDENTITY_NAME)
.exists(),
"the rejected write must not create an identity replica"
);
}
state
.ensure_write_safe("retry after disks return")
.expect("a rejected inline write must not poison the startup transaction");
let ordinary_error = crate::config::com::save_config_with_opts(
pool.clone(),
"ordinary-config",
data.clone(),
&ObjectOptions {
max_parity: true,
no_lock: true,
..Default::default()
},
)
.await
.expect_err("ordinary writes must still fail without quorum");
assert!(
matches!(ordinary_error, Error::InsufficientWriteQuorum(_, _)),
"ordinary callers must retain their existing quorum error"
);
*pool.disks.write().await = disks.into_iter().map(Some).collect();
let mut retry = state.arm_transaction();
save_pool_meta_object_cas(
pool.clone(),
POOL_META_IDENTITY_NAME,
data.clone(),
&PoolMetaCasToken::Missing,
&PoolMetaPersistenceFence::OfflineRecovery,
"identity_cas",
&mut retry,
)
.await
.expect("the same transaction must succeed when the disks return");
retry.disarm();
drop(retry);
assert_eq!(
crate::config::com::read_config(pool, POOL_META_IDENTITY_NAME)
.await
.expect("read back the committed identity"),
data
);
state
.ensure_write_safe("after successful retry")
.expect("retry must leave writes enabled");
}
#[tokio::test]
async fn pool_meta_prewrite_quorum_rejection_keeps_an_earlier_write_armed() {
let (_dirs, disks, pool) = crate::set_disk::hermetic_set_disks_isolated(4).await;
let state = super::PoolMetaWriteState::default();
let data =
super::initialized_pool_meta_identity_for_test(uuid::Uuid::new_v4(), 1).expect("encode the deployment identity");
let mut transaction = state.arm_transaction();
let written = save_pool_meta_object_cas(
pool.clone(),
POOL_META_IDENTITY_NAME,
data.clone(),
&PoolMetaCasToken::Missing,
&PoolMetaPersistenceFence::OfflineRecovery,
"prepare_cas",
&mut transaction,
)
.await
.expect("the earlier phase must really commit");
pool.disks.write().await[2..].fill(None);
save_pool_meta_object_cas(
pool.clone(),
POOL_META_IDENTITY_NAME,
data.clone(),
&PoolMetaCasToken::Existing(written.etag.expect("committed identity has an ETag")),
&PoolMetaPersistenceFence::OfflineRecovery,
"identity_cas",
&mut transaction,
)
.await
.expect_err("the next phase must fail without a write quorum");
drop(transaction);
*pool.disks.write().await = disks.into_iter().map(Some).collect();
assert_eq!(
crate::config::com::read_config(pool, POOL_META_IDENTITY_NAME)
.await
.expect("the earlier commit must survive the rejected write"),
data
);
let error = state
.ensure_write_safe("after a committed phase")
.expect_err("a later rejection cannot discharge the earlier write");
assert_eq!(
error.pool_metadata_failure().expect("retain typed uncertainty").kind,
crate::error::PoolMetadataFailure::TransactionUnknown
);
assert_eq!(error.pool_metadata_failure().expect("retain the earlier mutation").phase, "prepare_cas");
}
#[tokio::test]
async fn pool_meta_prewrite_noninline_failure_keeps_transaction_armed() {
let (_dirs, _disks, pool) = crate::set_disk::hermetic_set_disks_isolated(4).await;
pool.disks.write().await[2..].fill(None);
let state = super::PoolMetaWriteState::default();
let mut transaction = state.arm_transaction();
save_pool_meta_object_cas(
pool,
POOL_META_IDENTITY_NAME,
vec![0x51; 2 * 1024 * 1024],
&PoolMetaCasToken::Missing,
&PoolMetaPersistenceFence::OfflineRecovery,
"identity_cas",
&mut transaction,
)
.await
.expect_err("the noninline write must fail without quorum");
drop(transaction);
assert!(
state.ensure_write_safe("after writer setup").is_err(),
"a failure after disk writers were created must remain fail-closed"
);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum IdentityRepairFault {
Healthy,
+3 -2
View File
@@ -317,8 +317,9 @@ impl Sets {
&self.ctx
}
#[cfg(test)]
pub(crate) async fn stop_endpoint_monitor_for_test(&self) {
/// Stop endpoint reconnection and wait for any in-flight pass before a test owns disk probes.
#[cfg(any(test, feature = "test-util"))]
pub async fn stop_endpoint_monitor_for_test(&self) {
if let Some(exit_signal) = &self.exit_signal {
let _ = exit_signal.send(());
// Wait for an in-flight reconnect pass as well as its receiver to exit.
@@ -31,6 +31,8 @@ use tokio::fs;
use tokio::time::Instant;
pub(crate) const BARRIER_DIR_ENV: &str = "RUSTFS_E2E_DATA_MOVEMENT_BARRIER_DIR";
/// Rebalance holds its admission fence; no version of this entry has moved.
pub(crate) const STAGE_AFTER_ENTRY_ADMISSION: &str = "after_entry_admission";
/// Every target part is staged and the target version is not yet published.
pub(crate) const STAGE_BEFORE_TARGET_COMMIT: &str = "before_target_commit";
/// The target commit is durable and the source preflight matched; the source
+98 -3
View File
@@ -55,6 +55,20 @@ use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use uuid::Uuid;
/// Join the local disk's actual bootstrap worker for deterministic coverage tests.
/// Completion may mean legacy fallback after a failed scan; callers must assert
/// the expected index result separately. Remote disks have no local worker.
#[cfg(any(test, feature = "test-util"))]
pub async fn wait_for_multipart_index_bootstrap(disk: &crate::disk::DiskStore) -> Result<()> {
match disk.as_ref() {
crate::disk::Disk::Local(local) => {
local.disk.wait_for_multipart_index_bootstrap().await;
Ok(())
}
crate::disk::Disk::Remote(_) => Err(DiskError::MethodNotAllowed),
}
}
/// Disk health status constants
const DISK_HEALTH_OK: u32 = 0;
const DISK_HEALTH_FAULTY: u32 = 1;
@@ -2443,6 +2457,7 @@ impl LocalDiskWrapper {
object: &str,
transaction: uuid::Uuid,
namespace_owner: Option<Arc<dyn Send + Sync>>,
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
) -> Result<Option<(crate::disk::DeleteRecoveryJournal, bool, bool)>> {
self.track_disk_health_mutation(
"settle_delete_transaction",
@@ -2450,7 +2465,7 @@ impl LocalDiskWrapper {
|| {
Box::pin(async {
self.disk
.try_publish_prepared_delete_local(volume, object, transaction, namespace_owner)
.try_publish_prepared_delete_local_with_budget(volume, object, transaction, namespace_owner, work_budget)
.await
})
},
@@ -2466,6 +2481,7 @@ impl LocalDiskWrapper {
transaction: uuid::Uuid,
journal: &crate::disk::DeleteRecoveryJournal,
namespace_owner: Option<Arc<dyn Send + Sync>>,
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
) -> Result<bool> {
self.track_disk_health_mutation(
"settle_delete_transaction",
@@ -2473,7 +2489,14 @@ impl LocalDiskWrapper {
|| {
Box::pin(async {
self.disk
.try_finish_and_finalize_prepared_delete_local(volume, object, transaction, journal, namespace_owner)
.try_finish_and_finalize_prepared_delete_local_with_budget(
volume,
object,
transaction,
journal,
namespace_owner,
work_budget,
)
.await
})
},
@@ -2728,6 +2751,18 @@ impl LocalDiskWrapper {
#[async_trait::async_trait]
impl DiskAPI for LocalDiskWrapper {
async fn list_multipart_index_roots(
&self,
bucket: &str,
marker: Option<&str>,
) -> Result<crate::disk::multipart_index::MultipartIndexPage> {
if self.health.is_faulty() {
return Err(DiskError::FaultyDisk);
}
self.check_disk_stale().await?;
self.disk.list_multipart_index_roots(bucket, marker).await
}
async fn supports_guarded_delete_versions(&self) -> Result<()> {
// The local implementation shares this binary's capability. Availability
// and disk identity remain mutation checks and write-quorum inputs.
@@ -2781,6 +2816,62 @@ impl DiskAPI for LocalDiskWrapper {
)
.await
}
async fn discover_delete_transactions_bounded(
&self,
volume: &str,
object: &str,
max_entries: u32,
) -> Result<crate::disk::BoundedDeleteRecoveryDiscovery> {
self.track_disk_health_with_op(
"discover_delete_transactions",
|| async {
self.disk
.discover_delete_transactions_bounded(volume, object, max_entries)
.await
},
get_max_timeout_duration(),
)
.await
}
async fn inspect_delete_transaction_bounded(
&self,
volume: &str,
object: &str,
transaction: uuid::Uuid,
max_entries: u32,
) -> Result<crate::disk::BoundedDeleteRecoveryInspection> {
self.track_disk_health_with_op(
"inspect_delete_transaction",
|| async {
self.disk
.inspect_delete_transaction_bounded(volume, object, transaction, max_entries)
.await
},
get_max_timeout_duration(),
)
.await
}
async fn settle_delete_transaction_bounded(
&self,
volume: &str,
request: crate::disk::BoundedDeleteRecoverySettlement,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<crate::disk::BoundedDeleteRecoverySettlementReply> {
self.track_disk_health_mutation(
"settle_delete_transaction",
DiskMetricMutation::Delete,
|| async {
self.disk
.settle_delete_transaction_bounded(volume, request, namespace_owner)
.await
},
crate::disk::get_delete_recovery_timeout()?,
)
.await
}
async fn discover_delete_transactions(
&self,
volume: &str,
@@ -3749,7 +3840,11 @@ mod tests {
assert!(futures::poll!(&mut operation).is_pending());
time::advance(SLOW_DISK_OPERATION_THRESHOLD - Duration::from_millis(1)).await;
assert!(futures::poll!(&mut operation).is_pending());
assert!(logs.contents().is_empty(), "normal disk latency should not warn");
assert!(
!logs.contents().contains("disk_operation_slow") && !logs.contents().contains("WARN"),
"normal disk latency should not warn: {}",
logs.contents()
);
time::advance(Duration::from_millis(1)).await;
assert!(futures::poll!(&mut operation).is_pending());
let warning = logs.contents();
+6
View File
@@ -172,6 +172,8 @@ pub enum DiskError {
#[error("guarded batch deletion is not supported by this disk")]
GuardedDeleteUnsupported,
#[error("foreground delete recovery work limit reached")]
DeleteRecoveryWorkLimit,
#[error("erasure write quorum")]
ErasureWriteQuorum,
@@ -770,6 +772,7 @@ impl Clone for DiskError {
DiskError::MethodNotAllowed => DiskError::MethodNotAllowed,
DiskError::ConditionalDeleteConflict => DiskError::ConditionalDeleteConflict,
DiskError::GuardedDeleteUnsupported => DiskError::GuardedDeleteUnsupported,
DiskError::DeleteRecoveryWorkLimit => DiskError::DeleteRecoveryWorkLimit,
DiskError::ErasureWriteQuorum => DiskError::ErasureWriteQuorum,
DiskError::ErasureReadQuorum => DiskError::ErasureReadQuorum,
DiskError::ShortWrite => DiskError::ShortWrite,
@@ -829,6 +832,7 @@ impl DiskError {
DiskError::RemoteClientUnavailable(_) => 0x2B,
DiskError::ConditionalDeleteConflict => 0x2C,
DiskError::GuardedDeleteUnsupported => 0x2D,
DiskError::DeleteRecoveryWorkLimit => 0x2E,
}
}
@@ -879,6 +883,7 @@ impl DiskError {
0x2B => Some(DiskError::RemoteClientUnavailable(String::new())),
0x2C => Some(DiskError::ConditionalDeleteConflict),
0x2D => Some(DiskError::GuardedDeleteUnsupported),
0x2E => Some(DiskError::DeleteRecoveryWorkLimit),
_ => None,
}
}
@@ -1662,6 +1667,7 @@ mod tests {
for (error, code) in [
(DiskError::ConditionalDeleteConflict, 0x2C),
(DiskError::GuardedDeleteUnsupported, 0x2D),
(DiskError::DeleteRecoveryWorkLimit, 0x2E),
] {
assert_eq!(error.to_u32(), code);
assert_eq!(DiskError::from_u32(code), Some(error.clone()));
+438 -72
View File
@@ -25,6 +25,7 @@ pub(super) mod delete_recovery;
#[cfg(all(unix, test))]
#[path = "fd_cache_tests.rs"]
mod fd_cache_tests;
mod multipart_index;
pub(in crate::disk) mod multipart_source_abort;
mod multipart_source_recovery;
pub(in crate::disk) mod multipart_source_state;
@@ -184,6 +185,13 @@ const DELETE_RESIDUE_PROBE_LIMIT: i32 = 8;
/// large residue tree, which then hides one level at a time instead.
const DELETE_RESIDUE_PROBE_READ_BUDGET: usize = 32;
/// What one bounded directory read found under a prefix.
enum PrefixListingProbe {
Visible,
Empty,
Cut(Vec<MetaCacheEntry>),
}
/// One directory read owed by the delete-residue probe.
enum ResidueProbeStep {
/// Read a bounded batch of `dir` and probe what it holds.
@@ -644,6 +652,7 @@ async fn write_delete_rollback_files(
let object_dir_buf = object_dir.to_path_buf();
let volume_str = volume.to_owned();
let inspect_object = inspect_object.map(str::to_owned);
let recovery_scope = delete_recovery::recovery_operation_scope();
os::run_blocking_namespace_operation_with_sync(first_lease, sync, move || {
for (path, _) in &removed_entries {
reject_local_disk_symlink_components(&io_root, path).map_err(std::io::Error::from)?;
@@ -702,10 +711,21 @@ async fn write_delete_rollback_files(
}
}
drop(staging_dir);
if entries.iter().any(|(name, _, _, _)| *name == DELETE_QUORUM_COMMITTED_FILE)
&& let Some(scope) = recovery_scope.as_ref()
{
scope.receipt_published();
}
let inspected = inspect_object
.map(|object| {
delete_recovery::inspection_with_markers(&object_dir_buf, &volume_str, &object, rollback_dir)
.map(|(state, _)| state)
delete_recovery::inspection_with_markers_bounded(
&object_dir_buf,
&volume_str,
&object,
rollback_dir,
recovery_scope.as_ref().map(|scope| scope.budget.as_ref()),
)
.map(|(state, _)| state)
})
.transpose();
drop((removed_entries, entries, inspect_lease));
@@ -777,7 +797,10 @@ async fn restore_delete_rollback_with_namespace_owner(
let mut remove_new_metadata = false;
match fs::read_dir(&rollback_path).await {
Ok(mut entries) => {
while let Some(entry) = entries.next_entry().await.map_err(to_file_error)? {
while let Some(entry) = delete_recovery::next_recovery_entry(&mut entries)
.await
.map_err(to_file_error)?
{
let name = entry.file_name();
if name == DELETE_MARKER_ROLLBACK_FILE {
remove_new_metadata = true;
@@ -811,7 +834,10 @@ async fn restore_delete_rollback_with_namespace_owner(
if recovery_journal.is_some() {
let mut entries = fs::read_dir(&src).await.map_err(to_file_error)?;
let mut files = Vec::new();
while let Some(entry) = entries.next_entry().await.map_err(to_file_error)? {
while let Some(entry) = delete_recovery::next_recovery_entry(&mut entries)
.await
.map_err(to_file_error)?
{
let name = entry.file_name();
let name = name.to_str().ok_or(DiskError::FileCorrupt)?;
if !entry.file_type().await.map_err(to_file_error)?.is_file()
@@ -909,10 +935,16 @@ async fn restore_delete_rollback_with_namespace_owner(
match restored {
Ok(()) => {}
Err(DiskError::FileNotFound) if remove_new_metadata => {
match os::remove_file_with_owner(xl_path, namespace_owner.clone()).await {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(to_file_error(err).into()),
let preserves_child_prefix = recovery_journal
.as_ref()
.is_some_and(delete_recovery::missing_marker_preserves_child_prefix)
&& fs::symlink_metadata(xl_path).await.is_ok_and(|metadata| metadata.is_dir());
if !preserves_child_prefix {
match os::remove_file_with_owner(xl_path, namespace_owner.clone()).await {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(to_file_error(err).into()),
}
}
}
Err(DiskError::FileNotFound) if had_staged_paths => return Err(DiskError::FileNotFound),
@@ -962,7 +994,10 @@ async fn remove_version_delete_markers(
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(to_file_error(err).into()),
};
while let Some(entry) = entries.next_entry().await.map_err(to_file_error)? {
while let Some(entry) = delete_recovery::next_recovery_entry(&mut entries)
.await
.map_err(to_file_error)?
{
if !entry.file_type().await.map_err(to_file_error)?.is_dir()
|| !entry.file_name().to_str().is_some_and(|name| Uuid::parse_str(name).is_ok())
{
@@ -7476,6 +7511,7 @@ pub struct LocalDisk {
pub root: PathBuf,
publication_root: os::PublicationRoot,
multipart_proof_authority: OnceLock<crate::disk::multipart_source::ProofVerificationContext>,
multipart_index: Arc<multipart_index::IndexOwner>,
/// I/O root pinned to the mount instance that was opened while the disk
/// was initialized. On Linux this is `/proc/self/fd/<dirfd>/.`; resolving
/// paths beneath it keeps repair I/O on that mount even if the configured
@@ -8018,7 +8054,10 @@ impl LocalDisk {
// TODO(backlog): add O_DIRECT I/O support for performance-critical paths
// TODO(backlog): populate DiskInfo in constructor
let multipart_index = multipart_index::IndexOwner::acquire(&publication_root, &root, &io_root)?;
let blocking_queue = multipart_index.attach_blocking_queue(&root, &io_root);
let mut disk = Self {
multipart_index,
root: root.clone(),
publication_root,
multipart_proof_authority: OnceLock::new(),
@@ -8054,7 +8093,7 @@ impl LocalDisk {
)
.await,
file_sync_permits: os::disk_file_sync_limiter(&root),
blocking_queue: os::register_disk_blocking_queue(&root, &io_root),
blocking_queue,
snapshot_leases: Arc::new(ParkingLotMutex::new(SnapshotLeaseRegistry::default())),
#[cfg(unix)]
pool_meta_durable_head: ParkingLotMutex::new(None),
@@ -8107,6 +8146,7 @@ impl LocalDisk {
publication_root,
#[cfg(target_os = "linux")]
disk.mount_lease.clone(),
disk.multipart_index.clone(),
exit_rx,
));
debug!(
@@ -8125,8 +8165,19 @@ impl LocalDisk {
root: PathBuf,
publication_root: os::PublicationRoot,
#[cfg(target_os = "linux")] mount_lease: Arc<std::fs::File>,
multipart_index: Arc<multipart_index::IndexOwner>,
mut exit_rx: tokio::sync::broadcast::Receiver<()>,
) {
// The multipart index bootstrap rides this task instead of its own:
// the disk lifecycle accounts for exactly this background task, and
// stopping the disk abandons the scan (a reconnect resumes it).
if let Some(bootstrap) = multipart_index.start() {
tokio::select! {
_ = bootstrap => {}
_ = exit_rx.recv() => return,
}
}
drop(multipart_index);
let start_at = Instant::now() + DELETED_OBJECTS_CLEANUP_INTERVAL;
let mut interval = interval_at(start_at, DELETED_OBJECTS_CLEANUP_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -9909,7 +9960,10 @@ impl LocalDisk {
let mut transaction_cleanup = false;
match fs::read_dir(&transaction_path).await {
Ok(mut entries) => {
while let Some(entry) = entries.next_entry().await.map_err(to_file_error)? {
while let Some(entry) = delete_recovery::next_recovery_entry(&mut entries)
.await
.map_err(to_file_error)?
{
let name = entry.file_name();
if !entry.file_type().await.map_err(to_file_error)?.is_file()
|| (name != STORAGE_FORMAT_FILE_BACKUP
@@ -10125,6 +10179,7 @@ impl LocalDisk {
let data_path = file_path.clone();
let sync = effective_durability(volume).syncs_commit_metadata();
let keep_rollback_witnesses = opt.recoverable_delete;
let recovery_scope = delete_recovery::recovery_operation_scope();
os::run_blocking_namespace_operation_with_sync(lease, sync, move || {
if let Some(check) = &multipart_check {
check.callback()()?;
@@ -10142,7 +10197,9 @@ impl LocalDisk {
let mut parts = Vec::new();
let mut markers = Vec::new();
let mut has_committed_marker = false;
for entry in entries {
for entry in
delete_recovery::recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref()))
{
let entry = entry?;
let name = entry.file_name();
let name = name.to_str().ok_or_else(corrupt)?;
@@ -10500,7 +10557,6 @@ impl LocalDisk {
}))
}
#[cfg(test)]
async fn read_listing_metadata(&self, volume: &str, object_name: &str) -> Result<ListingMetadataRead> {
let mut batch = self
.spawn_listing_metadata_batch(volume, vec![object_name.to_owned()], None)
@@ -10647,6 +10703,14 @@ impl LocalDisk {
let rollback_dir = opts.old_data_dir;
let incarnation = opts.delete_recovery_bucket_incarnation;
let xl_path = object_dir.join(STORAGE_FORMAT_FILE);
// An independent child key owns this directory. Reject before creating
// recovery evidence that could otherwise make a harmless prefix unhealable.
match fs::symlink_metadata(&xl_path).await {
Ok(metadata) if metadata.is_dir() => return Err(DiskError::IsNotRegular),
Ok(_) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(to_file_error(err).into()),
}
let mut recovery_target = None;
if let Some(rollback_dir) = rollback_dir {
if opts.recoverable_delete {
@@ -11351,6 +11415,9 @@ impl LocalDisk {
admission: Option<multipart_staging::MultipartSourceAdmission>,
) -> Result<()> {
multipart_staging::reject_reserved_anchor(path)?;
if volume == RUSTFS_META_MULTIPART_BUCKET {
self.multipart_index.record_head(path, &buf).await?;
}
let volume_dir = self.io_get_bucket_path(volume)?;
let file_path = self.io_get_object_path(volume, path)?;
check_path_length(file_path.to_string_lossy().as_ref())?;
@@ -11771,9 +11838,9 @@ impl LocalDisk {
continue;
}
let metadata =
with_walk_stall_timeout(stall, self.read_metadata(bucket, format!("{}/{}", current, entry).as_str()))
.await?;
let metadata = with_walk_stall_timeout(stall, self.read_listing_metadata(bucket, &current))
.await?
.bytes;
let entry = entry.strip_suffix(STORAGE_FORMAT_FILE).unwrap_or_default().to_owned();
let name = entry.trim_end_matches(SLASH_SEPARATOR);
@@ -11817,19 +11884,27 @@ impl LocalDisk {
});
}
let mut dir_stack: Vec<(String, bool, Option<HashSet<String>>, bool)> = Vec::with_capacity(5);
type PendingDirectory = (String, bool, Option<HashSet<String>>, bool, bool);
let mut dir_stack: Vec<PendingDirectory> = Vec::with_capacity(5);
// Explicit directory markers and real directories can resolve to the same logical path.
let schedule_dir = |dir_stack: &mut Vec<(String, bool, Option<HashSet<String>>, bool)>,
let schedule_dir = |dir_stack: &mut Vec<PendingDirectory>,
dir_name: String,
skip_object: bool,
dir_to_skip: Option<HashSet<String>>,
scan_required: bool| {
if let Some((last_dir_name, existing_skip_object, existing_dir_to_skip, existing_scan_required)) =
dir_stack.last_mut()
scan_required: bool,
verify_prefix: bool| {
if let Some((
last_dir_name,
existing_skip_object,
existing_dir_to_skip,
existing_scan_required,
existing_verify_prefix,
)) = dir_stack.last_mut()
&& *last_dir_name == dir_name
{
*existing_skip_object |= skip_object;
*existing_scan_required |= scan_required;
*existing_verify_prefix |= verify_prefix;
if let Some(existing_dir_to_skip) = existing_dir_to_skip {
if let Some(new_dir_to_skip) = &dir_to_skip {
existing_dir_to_skip.extend(new_dir_to_skip.iter().cloned());
@@ -11838,9 +11913,12 @@ impl LocalDisk {
*existing_dir_to_skip = dir_to_skip;
}
} else {
dir_stack.push((dir_name, skip_object, dir_to_skip, scan_required));
dir_stack.push((dir_name, skip_object, dir_to_skip, scan_required, verify_prefix));
}
};
let bounded_probe =
!opts.recursive && !opts.incl_deleted && !opts.skip_hidden_prefix_check && opts.hidden_entry_budget > 0;
let mut remaining_hidden = opts.hidden_entry_budget;
prefix = "".to_owned();
let mut prefetched_batch: Option<ListingMetadataBatch> = None;
let mut adaptive_batch_cap = WALK_DIR_BATCH_SIZE;
@@ -11856,7 +11934,7 @@ impl LocalDisk {
let name = path_join_buf(&[current.as_str(), entry.as_str()]);
while let Some((last_name, _, _, _)) = dir_stack.last()
while let Some((last_name, _, _, _, _)) = dir_stack.last()
&& *last_name < name
{
// A prior iteration of this same loop may have just recursed
@@ -11870,7 +11948,20 @@ impl LocalDisk {
return Ok(true);
}
let (pop, skip_object, dir_to_skip, scan_required) = dir_stack.pop().expect("operation should succeed");
let (pop, skip_object, dir_to_skip, scan_required, verify_prefix) =
dir_stack.pop().expect("operation should succeed");
if verify_prefix {
match self.probe_listing_prefix(opts, &pop, &mut remaining_hidden).await? {
PrefixListingProbe::Empty => continue,
PrefixListingProbe::Visible => {}
PrefixListingProbe::Cut(entries) => {
for entry in entries {
write_metacache_obj(out, &entry).await?;
}
return Ok(true);
}
}
}
let more_ready_in_batch = !(opts.recursive && scan_required)
&& prefetched_batch.as_ref().is_some_and(|b| b.remaining > 0 && !b.rx.is_empty());
if more_ready_in_batch {
@@ -11978,7 +12069,7 @@ impl LocalDisk {
};
let batch_names = if batch_limit > 1
&& dir_objes.is_empty()
&& dir_stack.iter().all(|(_, _, _, scan_required)| !*scan_required)
&& dir_stack.iter().all(|(_, _, _, scan_required, _)| !*scan_required)
{
entries[entry_idx..]
.iter()
@@ -12001,23 +12092,7 @@ impl LocalDisk {
}
.await
} else {
let fname = format!("{}/{}", meta.name, STORAGE_FORMAT_FILE);
with_walk_stall_timeout(stall, self.read_metadata(&opts.bucket, fname.as_str()))
.await
.map(|metadata| {
let bytes = Vec::from(metadata);
let (file_meta, data_dirs, counts_toward_limit) = parse_listing_metadata_bytes(&bytes);
#[cfg(not(test))]
let _ = file_meta;
ListingMetadataRead {
bytes,
#[cfg(test)]
file_meta,
data_dirs,
has_namespace_child_candidate: true,
counts_toward_limit,
}
})
with_walk_stall_timeout(stall, self.read_listing_metadata(&opts.bucket, &meta.name)).await
};
match metadata_read {
@@ -12063,12 +12138,20 @@ impl LocalDisk {
true,
if dir_to_skip.is_empty() { None } else { Some(dir_to_skip) },
has_namespace_child_candidate,
false,
);
} else if !is_dir_obj
&& has_namespace_child_candidate
&& self
.object_dir_has_listable_child(&opts.bucket, &meta.name, &dir_to_skip, opts.incl_deleted, stall)
.await?
&& (bounded_probe
|| self
.object_dir_has_listable_child(
&opts.bucket,
&meta.name,
&dir_to_skip,
opts.incl_deleted,
stall,
)
.await?)
{
// A plain object `a` shares its backing directory with any
// children `a/...`, and non-recursive walks never descend into
@@ -12078,7 +12161,7 @@ impl LocalDisk {
// separate real directory entry handled above.
let mut dir_name = meta.name.clone();
dir_name.push_str(SLASH_SEPARATOR);
schedule_dir(&mut dir_stack, dir_name, true, None, true);
schedule_dir(&mut dir_stack, dir_name, true, None, true, bounded_probe);
}
}
Err(err) => {
@@ -12094,7 +12177,7 @@ impl LocalDisk {
meta.name.push_str(SLASH_SEPARATOR);
// Conservative listings verify physical prefixes. Never-versioned
// buckets first rule out the data dirs a deleted version leaves behind.
let listable = if opts.recursive || opts.incl_deleted {
let listable = if opts.recursive || opts.incl_deleted || bounded_probe {
true
} else if opts.skip_hidden_prefix_check {
!self.directory_is_delete_residue(&opts.bucket, &meta.name, stall).await?
@@ -12103,7 +12186,7 @@ impl LocalDisk {
.await?
};
if listable {
schedule_dir(&mut dir_stack, meta.name, false, None, true);
schedule_dir(&mut dir_stack, meta.name, false, None, true, bounded_probe);
}
}
@@ -12125,11 +12208,23 @@ impl LocalDisk {
};
}
while let Some((dir, skip_object, dir_to_skip, scan_required)) = dir_stack.pop() {
while let Some((dir, skip_object, dir_to_skip, scan_required, verify_prefix)) = dir_stack.pop() {
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(true);
}
if verify_prefix {
match self.probe_listing_prefix(opts, &dir, &mut remaining_hidden).await? {
PrefixListingProbe::Empty => continue,
PrefixListingProbe::Visible => {}
PrefixListingProbe::Cut(entries) => {
for entry in entries {
write_metacache_obj(out, &entry).await?;
}
return Ok(true);
}
}
}
write_metacache_obj(
out,
&MetaCacheEntry {
@@ -12347,6 +12442,86 @@ impl LocalDisk {
}
}
/// Probe a prefix in the same byte order as the listing stream. Keep the
/// ordinary one-prefix result when a live object is found; only publish
/// hidden entries when their budget forces a resumable stream cut.
async fn probe_listing_prefix(
&self,
opts: &WalkDirOptions,
prefix: &str,
remaining: &mut usize,
) -> Result<PrefixListingProbe> {
if opts.forward_to.as_ref().is_some_and(|marker| prefix <= marker.as_str())
&& opts.pending_prefix.as_deref() != Some(prefix)
{
return Ok(PrefixListingProbe::Empty);
}
let root = prefix.trim_end_matches('/').to_owned();
let mut pending = std::collections::BTreeMap::from([(root.clone(), root.clone())]);
let mut hidden = Vec::new();
let mut bytes = 0usize;
let stall = opts.stall_timeout_duration();
while let Some((name, path)) = pending.pop_first() {
#[cfg(test)]
let _previous_probe_count =
DIRECTORY_LISTING_ENTRY_PROBE_COUNT.try_with(|count| count.fetch_add(1, Ordering::Relaxed));
let mut data_dirs = HashSet::new();
match with_walk_stall_timeout(stall, self.read_listing_metadata(&opts.bucket, &path)).await {
Ok(read) => {
data_dirs.extend(read.data_dirs.into_iter().map(|id| id.to_string()));
// The root object's visibility does not prove that its
// child prefix exists. Ancestor metadata still identifies
// internal data directories that must never be traversed.
if path != root && opts.forward_to.as_ref().is_none_or(|marker| &name > marker) {
if read.counts_toward_limit {
return Ok(PrefixListingProbe::Visible);
}
bytes = bytes.saturating_add(read.bytes.len());
hidden.push(MetaCacheEntry {
name: name.clone(),
metadata: read.bytes,
..Default::default()
});
*remaining = remaining.saturating_sub(1);
if *remaining == 0 || bytes >= LISTING_META_BATCH_MAX_BYTES {
return Ok(PrefixListingProbe::Cut(hidden));
}
}
if !read.has_namespace_child_candidate {
continue;
}
}
Err(Error::FileNotFound | Error::IsNotRegular) => {}
Err(err) => return Err(err),
}
let children = match with_walk_stall_timeout(stall, self.list_dir("", &opts.bucket, &path, -1)).await {
Ok(children) => children,
Err(Error::FileNotFound | Error::VolumeNotFound) => continue,
Err(err) => return Err(err),
};
for child in children {
let Some(child) = child.strip_suffix('/') else { continue };
if child.is_empty() || data_dirs.contains(child) {
continue;
}
let path = path_join_buf(&[&path, child]);
let name = decode_dir_object(&path);
if name.is_empty() {
continue;
}
if opts
.forward_to
.as_ref()
.is_some_and(|marker| &name <= marker && !marker.starts_with(&format!("{name}/")))
{
continue;
}
pending.insert(name, path);
}
}
Ok(PrefixListingProbe::Empty)
}
/// Whether anything under `dir_name` would appear in a listing. With
/// `incl_deleted`, any `xl.meta` counts (versioned listings surface
/// delete-marker-only objects too); otherwise the metadata must hold a
@@ -12389,10 +12564,9 @@ impl LocalDisk {
return Ok(true);
}
let metadata_path = path_join_buf(&[current.as_str(), STORAGE_FORMAT_FILE]);
match with_walk_stall_timeout(stall, self.read_metadata(bucket, metadata_path.as_str())).await {
match with_walk_stall_timeout(stall, self.read_listing_metadata(bucket, &current)).await {
Ok(metadata) => {
let file_meta = match FileMeta::load(&metadata) {
let file_meta = match FileMeta::load(&metadata.bytes) {
Ok(file_meta) => file_meta,
Err(_) => return Ok(true),
};
@@ -12976,6 +13150,7 @@ impl LocalDisk {
let reserved_name = format!("{RESERVED_DELETE_DATA_DIR_MARKER_PREFIX}{rollback_dir}");
let scan_object_path = object_path.clone();
let scan_reserved_name = reserved_name.clone();
let recovery_scope = delete_recovery::recovery_operation_scope();
let (matching_dirs, recovery_owned) = self
.blocking_queue
.run_cancellable(move |is_cancelled| {
@@ -12985,7 +13160,10 @@ impl LocalDisk {
Err(err) => return Err(err),
};
let mut matching = Vec::new();
for (idx, entry) in entries.enumerate() {
for (idx, entry) in
delete_recovery::recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref()))
.enumerate()
{
if idx & 63 == 0 && is_cancelled() {
return Err(std::io::Error::other("blocking namespace operation cancelled"));
}
@@ -13287,6 +13465,7 @@ impl LocalDisk {
} else {
None
};
let recovery_scope = delete_recovery::recovery_operation_scope();
let matching_data_dirs = self
.blocking_queue
.run_cancellable(move |is_cancelled| {
@@ -13306,7 +13485,10 @@ impl LocalDisk {
};
let marker_name = format!("{prefix}{rollback_dir}");
let mut matched = Vec::new();
for (idx, entry) in entries.enumerate() {
for (idx, entry) in
delete_recovery::recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref()))
.enumerate()
{
if idx & 63 == 0 && is_cancelled() {
return Err(std::io::Error::other("blocking namespace operation cancelled"));
}
@@ -13571,6 +13753,14 @@ fn measure_local_batch_stage<T>(
#[async_trait::async_trait]
impl DiskAPI for LocalDisk {
async fn list_multipart_index_roots(
&self,
bucket: &str,
marker: Option<&str>,
) -> Result<crate::disk::multipart_index::MultipartIndexPage> {
self.multipart_index_page(bucket, marker).await
}
async fn supports_guarded_delete_versions(&self) -> Result<()> {
Ok(())
}
@@ -13616,6 +13806,14 @@ impl DiskAPI for LocalDisk {
async fn has_delete_transaction_evidence(&self, volume: &str, object: &str) -> Result<bool> {
self.delete_recovery_has_evidence(volume, object).await
}
async fn discover_delete_transactions_bounded(
&self,
volume: &str,
object: &str,
max_entries: u32,
) -> Result<super::BoundedDeleteRecoveryDiscovery> {
self.delete_recovery_discover_bounded(volume, object, max_entries).await
}
async fn discover_delete_transactions(
&self,
volume: &str,
@@ -13625,6 +13823,24 @@ impl DiskAPI for LocalDisk {
) -> Result<super::DeleteRecoveryPage> {
self.delete_recovery_discover(volume, object, cursor, limit).await
}
async fn settle_delete_transaction_bounded(
&self,
volume: &str,
request: super::BoundedDeleteRecoverySettlement,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<super::BoundedDeleteRecoverySettlementReply> {
self.delete_recovery_settle_bounded(volume, request, namespace_owner).await
}
async fn inspect_delete_transaction_bounded(
&self,
volume: &str,
object: &str,
transaction: Uuid,
max_entries: u32,
) -> Result<super::BoundedDeleteRecoveryInspection> {
self.delete_recovery_inspect_bounded(volume, object, transaction, max_entries)
.await
}
async fn inspect_delete_transaction(
&self,
volume: &str,
@@ -15090,20 +15306,16 @@ impl DiskAPI for LocalDisk {
if opts.base_dir.ends_with(SLASH_SEPARATOR) {
if let Ok(data) = with_walk_stall_timeout(
stall,
self.read_metadata(
self.read_listing_metadata(
&opts.bucket,
path_join_buf(&[
format!("{}{}", opts.base_dir.trim_end_matches(SLASH_SEPARATOR), GLOBAL_DIR_SUFFIX).as_str(),
STORAGE_FORMAT_FILE,
])
.as_str(),
&format!("{}{}", opts.base_dir.trim_end_matches(SLASH_SEPARATOR), GLOBAL_DIR_SUFFIX),
),
)
.await
{
let meta = MetaCacheEntry {
name: opts.base_dir.clone(),
metadata: data.to_vec(),
metadata: data.bytes,
..Default::default()
};
write_metacache_obj(&mut out, &meta).await?;
@@ -15121,15 +15333,9 @@ impl DiskAPI for LocalDisk {
} && meta.is_file()
{
skip_current_dir_object = true;
if let Ok(meta_bytes) = with_walk_stall_deadline(
stall,
self.read_metadata(
opts.bucket.as_str(),
path_join_buf(&[opts.base_dir.as_str(), STORAGE_FORMAT_FILE]).as_str(),
),
)
.await?
&& let Ok(file_meta) = FileMeta::load(&meta_bytes)
if let Ok(meta_bytes) =
with_walk_stall_deadline(stall, self.read_listing_metadata(&opts.bucket, &opts.base_dir)).await?
&& let Ok(file_meta) = FileMeta::load(&meta_bytes.bytes)
&& let Ok(data_dirs) = file_meta.get_data_dirs()
{
for data_dir in data_dirs.iter().flatten() {
@@ -15486,7 +15692,10 @@ impl DiskAPI for LocalDisk {
check_path_length(file_path.to_string_lossy().as_ref())?;
let xl_rel_path = format!("{path}/{STORAGE_FORMAT_FILE}");
if self.multipart_source_admission(volume, &xl_rel_path, true, false)?.is_none()
// The common publisher indexes the merged owner metadata before
// a multipart head becomes durable, including legacy source heads.
if volume != RUSTFS_META_MULTIPART_BUCKET
&& self.multipart_source_admission(volume, &xl_rel_path, true, false)?.is_none()
&& !crash_inject::is_armed(CrashPoint::MetaWriteAfterTmpBeforeRename, &xl_rel_path)
&& !has_delete_version_test_failpoints()
&& !has_rename_data_test_failpoints(&xl_rel_path)
@@ -16260,7 +16469,7 @@ mod test {
assert!(slots[0][0][1].is_some());
drop(slots);
drop(entries);
let (disks, errors) = crate::store::init_format::init_disks(&endpoints, &options).await;
let (disks, errors) = crate::store::init_format::init_disks(&ctx, &endpoints, &options).await;
assert!(disks[0].is_none());
assert!(disks[1].is_some());
assert_eq!(errors[0], Some(DiskError::FileAccessDenied));
@@ -26367,6 +26576,7 @@ mod test {
root.clone(),
disk.publication_root.clone(),
disk.mount_lease.clone(),
disk.multipart_index.clone(),
exited,
));
tokio::task::yield_now().await;
@@ -26663,6 +26873,162 @@ mod test {
assert!(FileMeta::load(&corrupt_read.bytes).is_err());
}
#[tokio::test]
async fn bounded_prefix_probe_resumes_hidden_subtrees_and_keeps_live_prefix_fast_path() {
use rustfs_filemeta::MetacacheReader;
let directory = tempfile::tempdir().expect("disk");
let bucket = "bounded-prefix-probe";
let modified = OffsetDateTime::from_unix_timestamp(1_705_312_500).expect("time");
for index in 0..600 {
let name = format!("prefix/{index:04}");
let mut info = if index < 250 {
FileInfo {
name: name.clone(),
deleted: true,
..Default::default()
}
} else {
FileInfo::new(&name, 1, 1)
};
info.version_id = Some(Uuid::new_v4());
info.mod_time = Some(modified);
let mut metadata = FileMeta::default();
metadata.add_version(info).expect("version");
assert_eq!(file_meta_counts_toward_limit(&metadata), index >= 250);
let path = directory.path().join(bucket).join(&name);
fs::create_dir_all(&path).await.expect("object directory");
fs::write(path.join(STORAGE_FORMAT_FILE), metadata.marshal_msg().expect("metadata"))
.await
.expect("object metadata");
}
let endpoint = Endpoint::try_from(directory.path().to_str().expect("path")).expect("endpoint");
let disk = LocalDisk::new(&endpoint, false).await.expect("disk");
let mut marker = None;
for page in 0..3 {
let count = Arc::new(AtomicUsize::new(0));
let (reader, mut writer) = tokio::io::duplex(1024 * 1024);
DIRECTORY_LISTING_ENTRY_PROBE_COUNT
.scope(
count.clone(),
disk.walk_dir(
WalkDirOptions {
bucket: bucket.to_owned(),
recursive: false,
hidden_entry_budget: 100,
forward_to: marker.clone(),
pending_prefix: marker.as_ref().map(|_| "prefix/".to_owned()),
..Default::default()
},
&mut writer,
),
)
.await
.expect("bounded walk");
drop(writer);
let mut reader = MetacacheReader::new(reader);
let mut entries = reader.read_all().await.expect("stream");
assert!(count.load(Ordering::Relaxed) <= 102, "one page must not read the entire hidden subtree");
if page < 2 {
assert_eq!(entries.len(), 100);
assert_eq!(reader.limit_reached(), Some(true));
assert!(entries.iter_mut().all(|entry| entry.is_latest_delete_marker()));
marker = entries.last().map(|entry| entry.name.clone());
assert_eq!(marker, Some(format!("prefix/{:04}", (page + 1) * 100 - 1)));
} else {
assert_eq!(entries.iter().map(|entry| entry.name.as_str()).collect::<Vec<_>>(), ["prefix/"]);
assert_eq!(reader.limit_reached(), Some(false));
assert!(
count.load(Ordering::Relaxed) < 60,
"a live witness must keep the whole remaining subtree out of the probe"
);
}
}
}
#[tokio::test]
async fn listing_strips_inline_bodies_from_base_objects_and_directory_markers() {
use rustfs_filemeta::MetacacheReader;
let directory = tempfile::tempdir().expect("disk directory");
let bucket = "inline-listing";
let root = directory.path().join(bucket);
let mut expected = std::collections::HashMap::new();
for name in ["parent", "parent/child", "folder/", "folder/child"] {
let object_dir = root.join(encode_dir_object(name));
fs::create_dir_all(&object_dir).await.expect("object directory");
let mut info = FileInfo::new(name, 1, 1);
info.size = 128 * 1024;
info.data_dir = Some(Uuid::new_v4());
info.mod_time = Some(OffsetDateTime::now_utc());
info.data = Some(Bytes::from(vec![42; 128 * 1024]));
info.set_inline_data();
let mut meta = FileMeta::default();
meta.add_version(info).expect("inline metadata");
let full = meta.marshal_msg().expect("serialize metadata");
let envelope = read_xl_meta_no_data_sync(&mut std::io::Cursor::new(&full), full.len()).expect("metadata envelope");
assert!(full.len() > envelope.len() + 120 * 1024);
expected.insert(name.to_string(), envelope);
fs::write(object_dir.join(STORAGE_FORMAT_FILE), full)
.await
.expect("inline xl.meta");
}
let endpoint = Endpoint::try_from(directory.path().to_str().unwrap()).unwrap();
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk");
for (base, recursive) in [
("", true),
("", false),
("parent", true),
("parent/", true),
("folder/", true),
] {
let (reader, mut writer) = tokio::io::duplex(1024 * 1024);
disk.walk_dir(
WalkDirOptions {
bucket: bucket.into(),
base_dir: base.into(),
recursive,
..Default::default()
},
&mut writer,
)
.await
.expect("walk");
drop(writer);
let entries = MetacacheReader::new(reader).read_all().await.expect("listing stream");
let objects: Vec<_> = entries.iter().filter(|entry| !entry.metadata.is_empty()).collect();
let expected_names: &[&str] = match (base, recursive) {
("", true) => &["folder/", "folder/child", "parent", "parent/child"],
("", false) => &["folder/", "parent"],
("parent", true) => &["parent/", "parent/child"],
("parent/", true) => &["parent/child"],
("folder/", true) => &["folder/", "folder/child"],
_ => unreachable!(),
};
assert_eq!(
objects.iter().map(|entry| entry.name.as_str()).collect::<Vec<_>>(),
expected_names,
"base {base}, recursive {recursive}"
);
for entry in objects {
// A walk rooted at a plain object's backing directory emits its
// own metadata with a trailing slash, unlike a root-level walk.
let physical_name = if base == "parent" && entry.name == "parent/" {
"parent"
} else {
entry.name.as_str()
};
assert_eq!(
&entry.metadata,
expected.get(physical_name).expect("known object"),
"inline data leaked into listing of {} from base {base}, recursive {recursive}",
entry.name
);
}
}
// Raw metadata is still required by non-listing recovery consumers.
let raw = disk.read_metadata(bucket, "parent/xl.meta").await.expect("raw metadata");
assert!(raw.len() > 128 * 1024);
}
#[tokio::test]
async fn read_listing_metadata_bytes_covers_large_envelope_versions_and_non_recursive_walk_coexistence() {
use rustfs_filemeta::MetacacheReader;
+3
View File
@@ -486,6 +486,9 @@ impl LocalDisk {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
})?;
crate::hp_guard!("LocalDisk::rename_data");
if dst_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
self.multipart_index.record_info(dst_path, &fi).await?;
}
#[cfg(feature = "e2e-test-hooks")]
if fi.is_healing() {
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
+541 -24
View File
@@ -5,6 +5,28 @@ use crate::disk::{
};
use sha2::{Digest, Sha256};
pub(super) struct RecoveryOperationScope {
pub(super) budget: Arc<crate::object_api::DeleteRecoveryWorkBudget>,
may_publish_receipt: bool,
receipt_published: std::sync::atomic::AtomicBool,
}
tokio::task_local! {
static RECOVERY_OPERATION: Arc<RecoveryOperationScope>;
}
pub(super) fn recovery_operation_scope() -> Option<Arc<RecoveryOperationScope>> {
RECOVERY_OPERATION.try_with(Arc::clone).ok()
}
impl RecoveryOperationScope {
pub(super) fn receipt_published(&self) {
if self.may_publish_receipt {
self.receipt_published.store(true, std::sync::atomic::Ordering::Release);
}
}
}
pub(super) const PREPARED: &str = "delete-prepared";
pub(super) const TARGET: &str = "delete-target";
pub(super) const FINISHED: &str = "delete-commit-finished";
@@ -19,24 +41,35 @@ struct DiscoverySession {
touched: Instant,
}
/// Plain (non-transaction) children of an object-scoped discovery root cost one
/// budget entry per this many names: enough to bound a hostile prefix without
/// making a real folder prefix undeletable.
const PLAIN_CHILDREN_PER_BUDGET_ENTRY: u32 = 64;
impl DiscoverySession {
/// Consume one bounded step, returning ownership only when traversal remains.
fn advance(mut self, limit: u32) -> std::io::Result<(Vec<DeleteTransactionId>, Option<Self>)> {
fn advance(self, limit: u32) -> std::io::Result<(Vec<DeleteTransactionId>, Option<Self>)> {
self.advance_bounded(limit, super::super::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES)
.map(|(transactions, next, _)| (transactions, next))
}
fn advance_bounded(mut self, limit: u32, max_entries: u32) -> std::io::Result<(Vec<DeleteTransactionId>, Option<Self>, u32)> {
if self.frames.is_empty() {
match std::fs::read_dir(&self.root) {
Ok(entries) => self.frames.push((String::new(), entries)),
Err(err) if err.kind() == ErrorKind::NotFound => {
return Ok((Vec::new(), None));
return Ok((Vec::new(), None, 0));
}
Err(err) => return Err(err),
}
}
let mut transactions = BTreeSet::new();
let mut seen_candidates = HashSet::new();
let mut visited = 0usize;
let mut visited = 0u32;
let mut plain_children = 0u32;
while !self.frames.is_empty()
&& transactions.len() < usize::try_from(limit).map_err(|_| std::io::Error::from(ErrorKind::InvalidInput))?
&& visited < 4096
&& visited < max_entries
{
let (parent, entries) = self
.frames
@@ -49,7 +82,6 @@ impl DiscoverySession {
continue;
}
};
visited += 1;
let name = entry
.file_name()
.into_string()
@@ -57,6 +89,19 @@ impl DiscoverySession {
if !safe_path(&name, false) || name.contains('/') {
return Err(std::io::Error::from(ErrorKind::InvalidData));
}
// An object-scoped scan only ever descends into transaction (UUID)
// directories at the root. Plain child keys of a prefix directory are
// skipped by name without a stat; they are charged at one entry per
// readdir batch so the work stays bounded while deleting a missing key
// that names a prefix with thousands of children can still complete.
if self.object.is_some() && parent.is_empty() && !canonical_uuid(&name).is_some_and(|id| !id.is_nil()) {
plain_children += 1;
if plain_children.is_multiple_of(PLAIN_CHILDREN_PER_BUDGET_ENTRY) {
visited += 1;
}
continue;
}
visited += 1;
let relative = if parent.is_empty() {
name.clone()
} else {
@@ -156,7 +201,7 @@ impl DiscoverySession {
.map(|(object, transaction)| DeleteTransactionId { object, transaction })
.collect();
let next = (!self.frames.is_empty()).then_some(self);
Ok((transactions, next))
Ok((transactions, next, visited))
}
async fn has_evidence(mut self, disk: &LocalDisk, path: &Path) -> Result<bool> {
@@ -580,12 +625,41 @@ fn explicit_delete_referenced_data_dirs(
Ok(Some(referenced))
}
fn explicit_delete_plan(
pub(super) async fn next_recovery_entry(entries: &mut tokio::fs::ReadDir) -> std::io::Result<Option<tokio::fs::DirEntry>> {
if let Some(scope) = recovery_operation_scope() {
scope.budget.reserve(1).map_err(std::io::Error::from)?;
}
entries.next_entry().await
}
pub(super) fn recovery_entries(
mut entries: std::fs::ReadDir,
budget: Option<&crate::object_api::DeleteRecoveryWorkBudget>,
) -> impl Iterator<Item = std::io::Result<std::fs::DirEntry>> + '_ {
let mut finished = false;
std::iter::from_fn(move || {
if finished {
return None;
}
if let Some(budget) = budget
&& let Err(error) = budget.reserve(1)
{
finished = true;
return Some(Err(error.into()));
}
let entry = entries.next();
finished = entry.is_none();
entry
})
}
fn explicit_delete_plan_bounded(
root: &Path,
transaction: Uuid,
original: Option<&FileMeta>,
journal: Option<&DeleteRecoveryJournal>,
current_missing: bool,
budget: Option<&crate::object_api::DeleteRecoveryWorkBudget>,
) -> Result<Option<ExplicitDeletePlan>> {
let Some(referenced) = explicit_delete_referenced_data_dirs(original, journal, current_missing)? else {
return Ok(None);
@@ -602,7 +676,7 @@ fn explicit_delete_plan(
let mut candidates = Vec::new();
match std::fs::read_dir(&rollback) {
Ok(entries) => {
for entry in entries {
for entry in recovery_entries(entries, budget) {
let entry = entry.map_err(to_file_error)?;
let kind = entry.file_type().map_err(to_file_error)?;
let name = entry.file_name().into_string().map_err(|_| DiskError::FileCorrupt)?;
@@ -634,7 +708,7 @@ fn explicit_delete_plan(
}
match std::fs::read_dir(root) {
Ok(entries) => {
for entry in entries {
for entry in recovery_entries(entries, budget) {
let entry = entry.map_err(to_file_error)?;
let name = entry.file_name().into_string().map_err(|_| DiskError::FileCorrupt)?;
let Some(id) = canonical_uuid(&name).filter(|id| !id.is_nil()) else {
@@ -658,7 +732,7 @@ fn explicit_delete_plan(
candidates.dedup();
for dir in candidates {
let mut has_parts = false;
for entry in std::fs::read_dir(&dir).map_err(to_file_error)? {
for entry in recovery_entries(std::fs::read_dir(&dir).map_err(to_file_error)?, budget) {
let entry = entry.map_err(to_file_error)?;
if !entry.file_type().map_err(to_file_error)?.is_file() {
return Ok(None);
@@ -697,15 +771,27 @@ fn canonical_uuid(name: &str) -> Option<Uuid> {
Uuid::parse_str(name).ok().filter(|id| name == id.hyphenated().to_string())
}
pub(super) fn inspection_with_markers(
pub(super) fn missing_marker_preserves_child_prefix(journal: &DeleteRecoveryJournal) -> bool {
journal.original_digest.is_none()
&& journal.original_versions.is_empty()
&& journal.data_dirs.is_empty()
&& journal.target_digest.is_some()
&& !journal.requests.is_empty()
&& journal
.requests
.iter()
.all(|request| request.deleted && request.version_id.is_some_and(|id| !id.is_nil()))
}
pub(super) fn inspection_with_markers_bounded(
root: &Path,
volume: &str,
object: &str,
transaction: Uuid,
budget: Option<&crate::object_api::DeleteRecoveryWorkBudget>,
) -> Result<(DeleteRecoveryInspection, bool)> {
let rollback = root.join(transaction.to_string());
let before = regular_metadata_bytes(&rollback.join(STORAGE_FORMAT_FILE_BACKUP))?;
let current = regular_metadata_bytes(&root.join(STORAGE_FORMAT_FILE))?;
let prepared = regular_bytes(&rollback.join(PREPARED))?;
let target = regular_metadata_bytes(&rollback.join(TARGET))?;
let receipt_bytes = regular_bytes(&rollback.join(DELETE_QUORUM_COMMITTED_FILE))?;
@@ -739,6 +825,20 @@ pub(super) fn inspection_with_markers(
{
return Err(DiskError::FileCorrupt);
}
// A failed creation of a marker over a child named `xl.meta` did not
// replace that directory. Older releases nevertheless left a journal.
// Only this bound, originally absent generation may treat it as absence.
let current_path = root.join(STORAGE_FORMAT_FILE);
let current = if !committed
&& before.is_none()
&& (journal.as_ref().is_some_and(missing_marker_preserves_child_prefix)
|| (journal.is_none() && target.is_none() && receipt_bytes.is_none()))
&& std::fs::symlink_metadata(&current_path).is_ok_and(|metadata| metadata.is_dir())
{
None
} else {
regular_metadata_bytes(&current_path)?
};
let backup_digest = before.as_deref().map(digest);
let current_digest = current.as_deref().map(digest);
let prepared_digest = prepared.as_deref().map(digest);
@@ -781,7 +881,7 @@ pub(super) fn inspection_with_markers(
Err(err) => return Err(to_file_error(err).into()),
};
if let Some(entries) = entries {
for entry in entries {
for entry in recovery_entries(entries, budget) {
let entry = entry.map_err(to_file_error)?;
let kind = entry.file_type().map_err(to_file_error)?;
if !kind.is_file() && !kind.is_dir() {
@@ -798,7 +898,7 @@ pub(super) fn inspection_with_markers(
descriptions.push((name.clone(), kind.is_dir(), entry.metadata().map_err(to_file_error)?.len()));
if kind.is_dir() && id.is_some_and(|id| !id.is_nil()) {
has_other_uuid_dir |= id != Some(transaction);
for child in std::fs::read_dir(entry.path()).map_err(to_file_error)? {
for child in recovery_entries(std::fs::read_dir(entry.path()).map_err(to_file_error)?, budget) {
let child = child.map_err(to_file_error)?;
let kind = child.file_type().map_err(to_file_error)?;
if !kind.is_dir() && !kind.is_file() {
@@ -844,7 +944,7 @@ pub(super) fn inspection_with_markers(
}
if name == transaction.to_string() && kind.is_dir() {
has_staged_dir = true;
for file in std::fs::read_dir(child.path()).map_err(to_file_error)? {
for file in recovery_entries(std::fs::read_dir(child.path()).map_err(to_file_error)?, budget) {
let file = file.map_err(to_file_error)?;
let kind = file.file_type().map_err(to_file_error)?;
let file_name = file.file_name().into_string().map_err(|_| DiskError::FileCorrupt)?;
@@ -968,7 +1068,8 @@ pub(super) fn inspection_with_markers(
.is_some_and(|referenced| !referenced.contains(&transaction))
}
DeleteRecoveryState::LegacyAmbiguous | DeleteRecoveryState::NewPrepared => {
explicit_delete_plan(root, transaction, before_meta.as_ref(), journal.as_ref(), current.is_none())?.is_some()
explicit_delete_plan_bounded(root, transaction, before_meta.as_ref(), journal.as_ref(), current.is_none(), budget)?
.is_some()
}
_ => false,
};
@@ -1039,6 +1140,7 @@ impl LocalDisk {
let volume = volume.to_owned();
let object = object.to_owned();
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner).await;
let recovery_scope = recovery_operation_scope();
os::run_blocking_namespace_operation(lease, move || {
Ok((|| {
let entries = match std::fs::read_dir(&root) {
@@ -1048,7 +1150,7 @@ impl LocalDisk {
};
let mut predecessors = BTreeSet::new();
let mut visited = 0usize;
for entry in entries {
for entry in recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref())) {
visited += 1;
if visited > 65536 {
return Err(DiskError::DiskOngoingReq);
@@ -1201,6 +1303,42 @@ impl LocalDisk {
.map_err(|error| to_file_error(error).into())
}
#[cfg(test)]
pub(crate) fn public_delete_discovery_sessions_for_test(&self) -> usize {
DISCOVERY_SESSIONS
.lock()
.values()
.filter(|session| session.disk_root == self.io_root())
.count()
}
pub(super) async fn delete_recovery_discover_bounded(
&self,
volume: &str,
object: &str,
max_entries: u32,
) -> Result<super::super::BoundedDeleteRecoveryDiscovery> {
if !(1..=super::super::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES).contains(&max_entries) {
return Err(DiskError::InvalidPath);
}
let path = self.delete_discovery_path(volume, Some(object))?;
let session = self.new_delete_discovery_session(volume, Some(object), &path)?;
let lease = os::acquire_namespace_mutation_lease_with_owner(&path, None).await;
self.validate_delete_discovery_owner(&session)?;
// The blocking owner drops every directory handle even if the RPC waiter disappears.
// A partial inventory is never evidence of absence and never enters DISCOVERY_SESSIONS.
os::run_blocking_namespace_operation(lease, move || {
let (transactions, next, visited) = session.advance_bounded(256, max_entries)?;
Ok(super::super::BoundedDeleteRecoveryDiscovery {
transactions: if next.is_none() { transactions } else { Vec::new() },
visited,
complete: next.is_none(),
})
})
.await
.map_err(|error| to_file_error(error).into())
}
pub(super) async fn delete_recovery_discover(
&self,
volume: &str,
@@ -1248,6 +1386,42 @@ impl LocalDisk {
.await
}
pub(super) async fn delete_recovery_inspect_bounded(
&self,
volume: &str,
object: &str,
transaction: Uuid,
max_entries: u32,
) -> Result<super::super::BoundedDeleteRecoveryInspection> {
if !safe_path(object, false)
|| transaction.is_nil()
|| !(1..=super::super::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES).contains(&max_entries)
{
return Err(DiskError::InvalidPath);
}
let root = self.get_object_path(volume, object)?;
let owner = os::acquire_metadata_mutation_lease(&root, None).await;
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, Some(owner)).await;
let volume = volume.to_owned();
let object = object.to_owned();
// Same lane as the unbounded inspection: a bulk delete inspects one
// transaction per key on every drive, and the one-worker foreground
// namespace lane would serialize those behind every other mutation.
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
let budget = crate::object_api::DeleteRecoveryWorkBudget::new(max_entries);
Ok(
inspection_with_markers_bounded(&root, &volume, &object, transaction, Some(&budget)).map(|(inspection, _)| {
super::super::BoundedDeleteRecoveryInspection {
inspection,
visited: max_entries - budget.remaining(),
}
}),
)
})
.await
.map_err(to_file_error)?
}
pub(super) async fn delete_recovery_inspect(
&self,
volume: &str,
@@ -1287,12 +1461,19 @@ impl LocalDisk {
let volume = volume.to_owned();
let object = object.to_owned();
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner).await;
let scope = recovery_operation_scope();
// Journal inspections ride the durable-mutation lane (the fused delete
// prepare already does): a bulk delete publishes one transaction per
// 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))
Ok(inspection_with_markers_bounded(
&root,
&volume,
&object,
transaction,
scope.as_ref().map(|scope| scope.budget.as_ref()),
))
})
.await
.map_err(to_file_error)?
@@ -1493,6 +1674,49 @@ impl LocalDisk {
self.activate_delete_preparation(&journal, original, target, owner).await
}
pub(super) async fn delete_recovery_settle_bounded(
&self,
volume: &str,
request: super::super::BoundedDeleteRecoverySettlement,
owner: Option<Arc<dyn Send + Sync>>,
) -> Result<super::super::BoundedDeleteRecoverySettlementReply> {
if !(1..=super::super::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES).contains(&request.max_entries) {
return Err(DiskError::InvalidPath);
}
let scope = Arc::new(RecoveryOperationScope {
budget: Arc::new(crate::object_api::DeleteRecoveryWorkBudget::new(request.max_entries)),
may_publish_receipt: matches!(
&request.action,
DeleteRecoveryAction::PublishCommit | DeleteRecoveryAction::AdoptCommit { .. }
),
receipt_published: std::sync::atomic::AtomicBool::new(false),
});
let result = RECOVERY_OPERATION
.scope(
Arc::clone(&scope),
self.delete_recovery_settle(
volume,
&request.object,
request.transaction,
request.expected_fingerprint,
request.action,
owner,
),
)
.await;
let outcome = match result {
Ok(inspection) => super::super::DeleteRecoverySettlementOutcome::Inspected(Box::new(inspection)),
Err(DiskError::DeleteRecoveryWorkLimit) if scope.receipt_published.load(std::sync::atomic::Ordering::Acquire) => {
super::super::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred
}
Err(error) => return Err(error),
};
Ok(super::super::BoundedDeleteRecoverySettlementReply {
outcome,
visited: request.max_entries - scope.budget.remaining(),
})
}
pub(crate) async fn delete_recovery_settle(
&self,
volume: &str,
@@ -1691,6 +1915,7 @@ impl LocalDisk {
let plan_root = root.clone();
let missing = observed.current_digest.is_none();
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner.clone()).await;
let scope = recovery_operation_scope();
// Same durable-mutation lane as the journal inspection above: this
// plan closure runs inside every settle on the bulk-delete publish
// path.
@@ -1701,7 +1926,15 @@ impl LocalDisk {
.as_deref()
.map(FileMeta::load)
.transpose()?;
explicit_delete_plan(&plan_root, transaction, before.as_ref(), None, missing)?.ok_or(DiskError::FileCorrupt)
explicit_delete_plan_bounded(
&plan_root,
transaction,
before.as_ref(),
None,
missing,
scope.as_ref().map(|scope| scope.budget.as_ref()),
)?
.ok_or(DiskError::FileCorrupt)
})())
})
.await
@@ -1966,6 +2199,7 @@ impl LocalDisk {
let (rollback_buf, root_buf, volume_str, object_str) =
(rollback.clone(), root.clone(), volume.to_owned(), object.to_owned());
let publication_root = self.publication_root.clone();
let scope = recovery_operation_scope();
return os::run_blocking_namespace_operation_with_sync(receipt_lease, sync, move || {
let _publication_root = publication_root;
for (path, _) in &file_leases {
@@ -1998,8 +2232,14 @@ impl LocalDisk {
}
}
}
let inspected =
inspection_with_markers(&root_buf, &volume_str, &object_str, transaction).map(|(state, _)| state);
let inspected = inspection_with_markers_bounded(
&root_buf,
&volume_str,
&object_str,
transaction,
scope.as_ref().map(|scope| scope.budget.as_ref()),
)
.map(|(state, _)| state);
drop((file_leases, prune_dirs));
Ok(inspected)
})
@@ -2156,6 +2396,7 @@ impl LocalDisk {
let volume_str = volume.to_owned();
let object_str = object.to_owned();
let recovery_scope = recovery_operation_scope();
let res = os::run_blocking_namespace_operation_with_sync(
lease,
sync,
@@ -2202,7 +2443,9 @@ impl LocalDisk {
let mut predecessors = BTreeSet::new();
let mut sole_transaction = true;
if let Ok(entries) = std::fs::read_dir(&file_path) {
for (visited, entry) in entries.enumerate() {
for (visited, entry) in
recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref())).enumerate()
{
if visited >= 65536 {
return Err(DiskError::DiskOngoingReq);
}
@@ -2415,12 +2658,25 @@ impl LocalDisk {
Ok(Some(()))
}
#[cfg(test)]
pub(crate) async fn try_publish_prepared_delete_local(
&self,
volume: &str,
object: &str,
transaction: Uuid,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<Option<(DeleteRecoveryJournal, bool, bool)>> {
self.try_publish_prepared_delete_local_with_budget(volume, object, transaction, namespace_owner, None)
.await
}
pub(crate) async fn try_publish_prepared_delete_local_with_budget(
&self,
volume: &str,
object: &str,
transaction: Uuid,
namespace_owner: Option<Arc<dyn Send + Sync>>,
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
) -> Result<Option<(DeleteRecoveryJournal, bool, bool)>> {
self.reject_multipart_delete_recovery_mutation(volume, object)?;
if !safe_path(object, false) || transaction.is_nil() {
@@ -2499,7 +2755,7 @@ impl LocalDisk {
if sole_transaction {
match std::fs::read_dir(&root) {
Ok(entries) => {
for entry in entries {
for entry in recovery_entries(entries, work_budget.as_deref()) {
let entry = entry?;
let Ok(name) = entry.file_name().into_string() else {
sole_transaction = false;
@@ -2547,6 +2803,7 @@ impl LocalDisk {
.map_err(to_file_error)?
}
#[cfg(test)]
pub(crate) async fn try_finish_and_finalize_prepared_delete_local(
&self,
volume: &str,
@@ -2554,6 +2811,26 @@ impl LocalDisk {
transaction: Uuid,
journal: &DeleteRecoveryJournal,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<bool> {
self.try_finish_and_finalize_prepared_delete_local_with_budget(
volume,
object,
transaction,
journal,
namespace_owner,
None,
)
.await
}
pub(crate) async fn try_finish_and_finalize_prepared_delete_local_with_budget(
&self,
volume: &str,
object: &str,
transaction: Uuid,
journal: &DeleteRecoveryJournal,
namespace_owner: Option<Arc<dyn Send + Sync>>,
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
) -> Result<bool> {
fn ignore_not_found(res: std::io::Result<()>) -> std::io::Result<()> {
match res {
@@ -2620,7 +2897,7 @@ impl LocalDisk {
had_physical_data_dirs = true;
let mut removable = Vec::new();
let mut preserve_dir = false;
for entry in entries {
for entry in recovery_entries(entries, work_budget.as_deref()) {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
@@ -4124,6 +4401,100 @@ mod tests {
);
}
#[tokio::test]
async fn delete_marker_collision_rejects_new_journals_and_recovers_legacy_proof() {
let (temp, disk, _info, original) = fixture().await;
let root = disk.io_get_object_path("recovery", "object").expect("object path");
let staged_child = temp.path().join("child-fixture");
fs::rename(&root, &staged_child).await.expect("move intact object");
fs::create_dir(&root).await.expect("create parent prefix");
fs::rename(staged_child, root.join(STORAGE_FORMAT_FILE))
.await
.expect("seed an independent object named object/xl.meta");
let transaction = Uuid::new_v4();
let marker = FileInfo {
volume: "recovery".to_owned(),
name: "object".to_owned(),
version_id: Some(Uuid::new_v4()),
deleted: true,
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
disk.delete_version_recoverable(
"recovery",
"object",
marker.clone(),
true,
DeleteOptions {
old_data_dir: Some(transaction),
..Default::default()
},
)
.await
.expect_err("a child xl.meta directory cannot be replaced by a marker");
assert!(!root.join(transaction.to_string()).exists(), "rejection must precede journal publication");
// Reproduce the durable evidence written by releases that prepared a
// missing-object marker before attempting to replace the directory.
disk.prepare_delete_recovery(
"recovery",
"object",
None,
&[marker],
None,
DeletePreparationContext {
transaction,
bucket_incarnation: None,
namespace_owner: None,
},
)
.await
.expect("seed legacy prepared marker");
let journal_path = root.join(transaction.to_string()).join(PREPARED);
let journal_bytes = fs::read(&journal_path).await.expect("prepared journal");
let mut incompatible: DeleteRecoveryJournal = serde_json::from_slice(&journal_bytes).expect("journal");
incompatible.original_digest = Some([7; 32]);
fs::write(&journal_path, serde_json::to_vec(&incompatible).expect("serialize journal"))
.await
.expect("write incompatible evidence");
assert!(
disk.inspect_delete_transaction("recovery", "object", transaction)
.await
.is_err(),
"a journal that claims existing metadata cannot ignore a directory collision"
);
fs::write(&journal_path, journal_bytes)
.await
.expect("restore exact legacy proof");
let observed = disk
.inspect_delete_transaction("recovery", "object", transaction)
.await
.expect("inspect legacy proof");
assert_eq!(observed.state, DeleteRecoveryState::NewPrepared);
let recovered = disk
.settle_delete_transaction("recovery", "object", transaction, observed.fingerprint, DeleteRecoveryAction::Rollback)
.await
.expect("rollback legacy marker without unlinking the child directory");
assert_eq!(recovered.state, DeleteRecoveryState::Absent);
assert!(!root.join(transaction.to_string()).exists());
assert_eq!(
disk.read_all("recovery", "object/xl.meta/xl.meta")
.await
.expect("child metadata")
.as_ref(),
original
);
assert!(
disk.read_version("", "recovery", "object/xl.meta", "", &Default::default())
.await
.is_ok()
);
assert!(
discover_all_keys(&disk, Some("object")).await.is_empty(),
"no proof may keep replacement heal pending"
);
}
// Data movement copies a delete marker onto a target that holds no
// metadata yet. Rolling back that sub-quorum write removes the marker it
// published, and must not report the backup it never wrote as missing:
@@ -5767,6 +6138,152 @@ mod tests {
);
}
#[tokio::test]
async fn delete_recovery_bounded_inspection_charges_root_and_uuid_children() {
let (_temp, disk, _, _) = fixture().await;
let transaction = Uuid::new_v4();
apply(&disk, transaction).await;
let full = disk
.inspect_delete_transaction_bounded("recovery", "object", transaction, 65536)
.await
.expect("full bounded inspection");
assert!(full.visited > 1, "both root and transaction directory entries are charged");
let error = disk
.inspect_delete_transaction_bounded("recovery", "object", transaction, full.visited - 1)
.await
.expect_err("nested entry exceeds allowance");
assert_eq!(error, DiskError::DeleteRecoveryWorkLimit);
let unchanged = disk
.inspect_delete_transaction("recovery", "object", transaction)
.await
.expect("unchanged proof");
assert_eq!(full.inspection.fingerprint, unchanged.fingerprint);
}
#[tokio::test]
async fn delete_recovery_receipt_publication_distinguishes_deferred_inspection_from_write_failure() {
for write_fails in [false, true] {
let (_temp, disk, _, _) = fixture().await;
let transaction = Uuid::new_v4();
apply(&disk, transaction).await;
let full = disk
.inspect_delete_transaction_bounded("recovery", "object", transaction, 65536)
.await
.expect("measure preparation work");
let receipt = disk
.io_get_object_path("recovery", &format!("object/{transaction}/{DELETE_QUORUM_COMMITTED_FILE}"))
.expect("receipt path");
if write_fails {
let destination = receipt.clone();
set_owned_file_before_open(&receipt, move || std::fs::create_dir(&destination).expect("reject receipt rename"));
}
let result = disk
.settle_delete_transaction_bounded(
"recovery",
super::super::super::BoundedDeleteRecoverySettlement {
object: "object".to_owned(),
transaction,
expected_fingerprint: full.inspection.fingerprint,
action: DeleteRecoveryAction::PublishCommit,
max_entries: full.visited,
},
None,
)
.await;
if write_fails {
assert!(result.is_err(), "a failed receipt write never counts as publication");
assert!(receipt.is_dir());
} else {
let result = result.expect("persisted receipt is acknowledged independently of post-write inspection");
assert!(matches!(
result.outcome,
super::super::super::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred
));
assert_eq!(result.visited, full.visited);
let state = disk
.inspect_delete_transaction("recovery", "object", transaction)
.await
.expect("durable receipt");
assert_eq!(state.state, DeleteRecoveryState::QuorumCommitted);
}
}
}
#[tokio::test]
async fn delete_recovery_bounded_discovery_refuses_partial_inventory_without_public_cursor() {
let (_temp, disk, _, _) = fixture().await;
let path = disk.delete_discovery_path("recovery", Some("prefix")).expect("prefix path");
fs::create_dir_all(&path).await.expect("physical prefix");
// Transaction-shaped children are the entries a bounded probe pays for.
for _ in 0..64 {
fs::create_dir(path.join(Uuid::new_v4().to_string()))
.await
.expect("legitimate child");
}
for _ in 0..140 {
let page = disk
.discover_delete_transactions_bounded("recovery", "prefix", 8)
.await
.expect("bounded probe");
assert_eq!(page.visited, 8);
assert!(!page.complete, "an empty partial page cannot establish absence");
assert!(page.transactions.is_empty());
}
let page = disk
.discover_delete_transactions_bounded("recovery", "prefix", 128)
.await
.expect("complete probe");
assert!(page.complete);
assert_eq!(page.visited, 64);
assert!(page.transactions.is_empty());
let transaction = Uuid::new_v4();
apply(&disk, transaction).await;
let before = disk
.inspect_delete_transaction("recovery", "object", transaction)
.await
.expect("before probe");
let page = disk
.discover_delete_transactions_bounded("recovery", "object", 4096)
.await
.expect("complete evidence");
assert!(page.complete);
assert!(page.transactions.iter().any(|id| id.transaction == transaction));
let after = disk
.inspect_delete_transaction("recovery", "object", transaction)
.await
.expect("after probe");
assert_eq!(before.fingerprint, after.fingerprint);
assert!(
DISCOVERY_SESSIONS
.lock()
.values()
.all(|session| session.disk_root != disk.io_root())
);
}
#[tokio::test]
async fn delete_recovery_cancelled_bounded_discovery_drops_private_session() {
let (_temp, disk, _, _) = fixture().await;
let path = disk.delete_discovery_path("recovery", Some("object")).expect("probe scope");
let lease = os::acquire_namespace_mutation_lease_with_owner(&path, None).await;
let mut probe = Box::pin(disk.discover_delete_transactions_bounded("recovery", "object", 4096));
assert!(futures::poll!(probe.as_mut()).is_pending());
drop(probe);
assert!(
DISCOVERY_SESSIONS
.lock()
.values()
.all(|session| session.disk_root != disk.io_root())
);
drop(lease);
assert!(
disk.discover_delete_transactions_bounded("recovery", "object", 4096)
.await
.expect("subsequent probe")
.complete
);
}
#[tokio::test]
async fn delete_recovery_evidence_probes_do_not_register_public_cursors() {
let (_temp, disk, _, _) = fixture().await;
File diff suppressed because it is too large Load Diff
@@ -117,6 +117,7 @@ pub(super) struct LoadedSource {
}
pub(super) struct SourceStore {
multipart_index: Arc<super::multipart_index::IndexOwner>,
pub disk_id: Uuid,
pub physical_slot: usize,
pub disk_ids: Vec<Uuid>,
@@ -186,6 +187,7 @@ impl LocalDisk {
let logical = self.get_object_path(RUSTFS_META_MULTIPART_BUCKET, path)?;
let lease = os::acquire_namespace_mutation_lease_with_owner(&logical, owner.clone()).await;
Ok(SourceStore {
multipart_index: self.multipart_index.clone(),
disk_id,
physical_slot,
disk_ids,
@@ -755,6 +757,9 @@ impl SourceStore {
os::sync_multipart_owned_record_parents_options(source, parent, existed, sync_source_root, base, root)
}
pub async fn replace_head(&self, bytes: &[u8]) -> Result<()> {
self.multipart_index
.record_head(&format!("{}/xl.meta", self.path), bytes)
.await?;
invalidate_active_multipart_source_head(&self.source);
let path = self.source.join(STORAGE_FORMAT_FILE);
let base = self.base.clone();
+176 -4
View File
@@ -27,6 +27,7 @@ mod io_admission;
#[cfg(test)]
pub(crate) mod lifecycle_test_io;
pub mod local;
pub mod multipart_index;
pub mod multipart_source;
pub mod os;
@@ -52,6 +53,10 @@ pub const RUSTFS_META_MULTIPART_BUCKET: &str = ".rustfs.sys/multipart";
pub const RUSTFS_META_TMP_BUCKET: &str = ".rustfs.sys/tmp";
pub const RUSTFS_META_TMP_DELETED_BUCKET: &str = ".rustfs.sys/tmp/.trash";
pub const BUCKET_META_PREFIX: &str = "buckets";
/// Directory under `BUCKET_META_PREFIX` that holds drive-replacement heal
/// intents, seals and recovery proofs. It shares the bucket-metadata root with
/// a user bucket of the same name, so bucket deletion must leave it in place.
pub const HEAL_REPLACEMENT_RECOVERY_DIR: &str = "ahm-replacement";
pub const FORMAT_CONFIG_FILE: &str = "format.json";
/// Per-disk marker present while an erasure-set heal is rebuilding this disk.
/// `LocalDisk::disk_info` reports `healing = true` while the file exists.
@@ -414,10 +419,11 @@ impl Disk {
object: &str,
transaction: Uuid,
namespace_owner: Option<Arc<dyn Send + Sync>>,
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
) -> Result<Option<(DeleteRecoveryJournal, bool, bool)>> {
match self {
Self::Local(disk) => {
disk.try_publish_prepared_delete_local(volume, object, transaction, namespace_owner)
disk.try_publish_prepared_delete_local(volume, object, transaction, namespace_owner, work_budget)
.await
}
Self::Remote(_) => Ok(None),
@@ -431,11 +437,19 @@ impl Disk {
transaction: Uuid,
journal: &DeleteRecoveryJournal,
namespace_owner: Option<Arc<dyn Send + Sync>>,
work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
) -> Result<bool> {
match self {
Self::Local(disk) => {
disk.try_finish_and_finalize_prepared_delete_local(volume, object, transaction, journal, namespace_owner)
.await
disk.try_finish_and_finalize_prepared_delete_local(
volume,
object,
transaction,
journal,
namespace_owner,
work_budget,
)
.await
}
Self::Remote(_) => Ok(false),
}
@@ -512,6 +526,17 @@ impl Disk {
#[async_trait::async_trait]
impl DiskAPI for Disk {
async fn list_multipart_index_roots(
&self,
bucket: &str,
marker: Option<&str>,
) -> Result<multipart_index::MultipartIndexPage> {
match self {
Self::Local(disk) => disk.list_multipart_index_roots(bucket, marker).await,
Self::Remote(disk) => disk.list_multipart_index_roots(bucket, marker).await,
}
}
async fn supports_guarded_delete_versions(&self) -> Result<()> {
match self {
Self::Local(disk) => disk.supports_guarded_delete_versions().await,
@@ -555,6 +580,17 @@ impl DiskAPI for Disk {
Self::Remote(disk) => disk.has_delete_transaction_evidence(volume, object).await,
}
}
async fn discover_delete_transactions_bounded(
&self,
volume: &str,
object: &str,
max_entries: u32,
) -> Result<BoundedDeleteRecoveryDiscovery> {
match self {
Self::Local(disk) => disk.discover_delete_transactions_bounded(volume, object, max_entries).await,
Self::Remote(disk) => disk.discover_delete_transactions_bounded(volume, object, max_entries).await,
}
}
async fn discover_delete_transactions(
&self,
volume: &str,
@@ -567,6 +603,35 @@ impl DiskAPI for Disk {
Self::Remote(disk) => disk.discover_delete_transactions(volume, object, cursor, limit).await,
}
}
async fn settle_delete_transaction_bounded(
&self,
volume: &str,
request: BoundedDeleteRecoverySettlement,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<BoundedDeleteRecoverySettlementReply> {
match self {
Self::Local(disk) => disk.settle_delete_transaction_bounded(volume, request, namespace_owner).await,
Self::Remote(disk) => disk.settle_delete_transaction_bounded(volume, request, namespace_owner).await,
}
}
async fn inspect_delete_transaction_bounded(
&self,
volume: &str,
object: &str,
transaction: Uuid,
max_entries: u32,
) -> Result<BoundedDeleteRecoveryInspection> {
match self {
Self::Local(disk) => {
disk.inspect_delete_transaction_bounded(volume, object, transaction, max_entries)
.await
}
Self::Remote(disk) => {
disk.inspect_delete_transaction_bounded(volume, object, transaction, max_entries)
.await
}
}
}
async fn inspect_delete_transaction(
&self,
volume: &str,
@@ -1513,6 +1578,15 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
fn host_name(&self) -> String;
fn endpoint(&self) -> Endpoint;
async fn close(&self) -> Result<()>;
/// Return candidates only while this disk maintains complete boot-local coverage.
/// Unsupported or unready disks must never return an authoritative empty page.
async fn list_multipart_index_roots(
&self,
_bucket: &str,
_marker: Option<&str>,
) -> Result<multipart_index::MultipartIndexPage> {
Err(DiskError::MethodNotAllowed)
}
async fn get_disk_id(&self) -> Result<Option<Uuid>>;
async fn set_disk_id(&self, id: Option<Uuid>) -> Result<()>;
@@ -1690,6 +1764,35 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
) -> Result<DeleteRecoveryPage> {
Err(DiskError::MethodNotAllowed)
}
/// Bounded foreground inventory owns all traversal state until return or cancellation.
/// An implementation without the bounded protocol is an unsupported participant
/// (the same verdict a remote peer without it receives); callers must never fall
/// back to paginated discovery.
async fn discover_delete_transactions_bounded(
&self,
_volume: &str,
_object: &str,
_max_entries: u32,
) -> Result<BoundedDeleteRecoveryDiscovery> {
Err(DiskError::UnsupportedDisk)
}
async fn inspect_delete_transaction_bounded(
&self,
_volume: &str,
_object: &str,
_transaction: Uuid,
_max_entries: u32,
) -> Result<BoundedDeleteRecoveryInspection> {
Err(DiskError::UnsupportedDisk)
}
async fn settle_delete_transaction_bounded(
&self,
_volume: &str,
_request: BoundedDeleteRecoverySettlement,
_namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<BoundedDeleteRecoverySettlementReply> {
Err(DiskError::UnsupportedDisk)
}
/// A read-only capability check; unsupported implementations must fail closed.
async fn supports_guarded_delete_versions(&self) -> Result<()> {
Err(DiskError::GuardedDeleteUnsupported)
@@ -2075,6 +2178,13 @@ pub struct WalkDirOptions {
#[serde(default)]
pub skip_hidden_prefix_check: bool,
/// Bound hidden metadata read while proving a non-recursive prefix visible.
#[serde(default)]
pub hidden_entry_budget: usize,
/// A prefix whose visibility was not established before the preceding cut.
#[serde(default)]
pub pending_prefix: Option<String>,
// ReportNotFound will return errFileNotFound if all disks reports the BaseDir cannot be found.
pub report_notfound: bool,
@@ -2407,9 +2517,62 @@ pub enum DeleteRecoveryAction {
AdoptCommit { authority: Box<DeleteRecoveryJournal> },
}
/// A complete inventory or a work-limit refusal. No public discovery cursor is allocated.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BoundedDeleteRecoveryDiscovery {
pub transactions: Vec<DeleteTransactionId>,
pub visited: u32,
pub complete: bool,
}
pub const DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES: u32 = 4096;
pub const DELETE_RECOVERY_MAX_INSPECTION_ENTRIES: u32 = 65536;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BoundedDeleteRecoverySettlement {
pub object: String,
pub transaction: Uuid,
pub expected_fingerprint: DeleteRecoveryDigest,
pub action: DeleteRecoveryAction,
pub max_entries: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DeleteRecoverySettlementOutcome {
Inspected(Box<DeleteRecoveryInspection>),
ReceiptPublishedInspectionDeferred,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BoundedDeleteRecoverySettlementReply {
pub outcome: DeleteRecoverySettlementOutcome,
pub visited: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BoundedDeleteRecoveryInspection {
pub inspection: DeleteRecoveryInspection,
pub visited: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DeleteRecoveryRpcOperation {
SettleBounded(BoundedDeleteRecoverySettlement),
InspectBounded {
object: String,
transaction: Uuid,
max_entries: u32,
},
DiscoverBounded {
object: String,
max_entries: u32,
},
InspectOrphanDataDir {
path: String,
},
@@ -2447,7 +2610,11 @@ impl DeleteRecoveryRpcOperation {
/// Full metadata application and adoption share the existing mutation envelope.
pub fn max_wire_payload_bytes(&self) -> usize {
match self {
Self::ApplyVersion { .. }
Self::SettleBounded(BoundedDeleteRecoverySettlement {
action: DeleteRecoveryAction::AdoptCommit { .. },
..
})
| Self::ApplyVersion { .. }
| Self::ApplyVersions { .. }
| Self::Settle {
action: DeleteRecoveryAction::AdoptCommit { .. },
@@ -2463,8 +2630,11 @@ impl DeleteRecoveryRpcOperation {
pub enum DeleteRecoveryRpcReply {
OrphanDataDir { plain: bool },
Discovered(DeleteRecoveryPage),
DiscoveredBounded(BoundedDeleteRecoveryDiscovery),
Inspected(DeleteRecoveryInspection),
InspectedBounded(BoundedDeleteRecoveryInspection),
Settled(DeleteRecoveryInspection),
SettledBounded(BoundedDeleteRecoverySettlementReply),
AppliedVersion,
AppliedVersions(Vec<Option<u32>>),
Evidence { present: bool },
@@ -2942,6 +3112,8 @@ mod tests {
recursive: true,
incl_deleted: false,
skip_hidden_prefix_check: false,
hidden_entry_budget: 0,
pending_prefix: None,
report_notfound: false,
filter_prefix: Some("prefix_".to_string()),
forward_to: Some("object/path".to_string()),
+359
View File
@@ -0,0 +1,359 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
//! A candidate index never replaces multipart metadata or quorum validation.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub(crate) const RUSTFS_MULTIPART_BUCKET_KEY: &str = "x-rustfs-internal-multipart-bucket";
pub(crate) const RUSTFS_MULTIPART_OBJECT_KEY: &str = "x-rustfs-internal-multipart-object";
pub(crate) const MULTIPART_INDEX_PAGE_SIZE: usize = 256;
/// Pages one listing accepts from one disk. Pruned pages may carry few roots, so
/// the cursor alone no longer bounds a peer's answer; a disk that keeps
/// continuing past this many pages contributes no coverage.
pub(crate) const MULTIPART_INDEX_MAX_PAGES_PER_DISK: usize = 4096;
/// A bounded page from one disk's continuously maintained, boot-local index.
/// `epoch` changes whenever coverage must be rebuilt; callers must not combine
/// pages from different epochs or physical disk identities.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MultipartIndexPage {
pub bucket: String,
pub disk_id: Uuid,
pub epoch: Uuid,
pub roots: Vec<String>,
pub next_marker: Option<String>,
}
pub const MULTIPART_INDEX_RPC_MAX_BYTES: usize = 64 * 1024;
pub const MULTIPART_INDEX_RPC_MAX_REQUEST_BYTES: usize = 4096;
/// Additive operation inside the legacy read-only DiskInfo JSON envelope.
/// Old peers may ignore this field, but their ordinary response cannot decode
/// as the required signed, challenge-bound MultipartIndexRpcReply.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MultipartIndexRpcRequest {
pub disk_id: String,
pub metrics: bool,
pub noop: bool,
pub multipart_index: MultipartIndexQuery,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MultipartIndexQuery {
pub operation: String,
pub version: u32,
pub challenge: Uuid,
pub bucket: String,
pub marker: Option<String>,
pub page_cap: usize,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MultipartIndexRpcReply {
pub operation: String,
pub version: u32,
pub challenge: Uuid,
pub page: MultipartIndexPage,
pub proof: Vec<u8>,
}
impl MultipartIndexRpcRequest {
pub fn new(bucket: &str, marker: Option<&str>) -> Self {
Self {
disk_id: String::new(),
metrics: false,
noop: true,
multipart_index: MultipartIndexQuery {
operation: "list_multipart_index_roots".into(),
version: 1,
challenge: Uuid::new_v4(),
bucket: bucket.to_owned(),
marker: marker.map(str::to_owned),
page_cap: MULTIPART_INDEX_PAGE_SIZE,
},
}
}
pub fn validate(&self) -> crate::disk::error::Result<()> {
let query = &self.multipart_index;
if !self.disk_id.is_empty()
|| self.metrics
|| !self.noop
|| query.operation != "list_multipart_index_roots"
|| query.version != 1
|| query.challenge.is_nil()
|| query.bucket.is_empty()
|| query.bucket.len() > 255
|| query.bucket.contains('/')
|| matches!(query.bucket.as_str(), "." | "..")
|| query.page_cap != MULTIPART_INDEX_PAGE_SIZE
|| query.marker.as_ref().is_some_and(|marker| !valid_root(marker))
{
return Err(crate::disk::error::DiskError::MethodNotAllowed);
}
Ok(())
}
pub fn canonical_request(&self, disk: &str) -> crate::disk::error::Result<Vec<u8>> {
self.validate()?;
if disk.len() > MULTIPART_INDEX_RPC_MAX_REQUEST_BYTES {
return Err(crate::disk::error::DiskError::FileCorrupt);
}
Ok(serde_json::to_vec(&("rustfs-multipart-index-request-v1", disk, self))?)
}
pub fn canonical_response(&self, disk: &str, page: &MultipartIndexPage) -> crate::disk::error::Result<Vec<u8>> {
self.validate()?;
Ok(serde_json::to_vec(&("rustfs-multipart-index-response-v1", disk, self, page))?)
}
}
fn valid_root(root: &str) -> bool {
root.len() == 64 && root.bytes().all(|byte| byte.is_ascii_hexdigit())
}
impl MultipartIndexPage {
pub fn validate(&self, bucket: &str, marker: Option<&str>, expected_disk: Uuid) -> crate::disk::error::Result<()> {
if expected_disk.is_nil()
|| self.disk_id != expected_disk
|| self.epoch.is_nil()
|| self.bucket != bucket
|| self.roots.len() > MULTIPART_INDEX_PAGE_SIZE
|| self
.roots
.iter()
.any(|root| !valid_root(root) || marker.is_some_and(|marker| root.as_str() <= marker))
|| self.roots.windows(2).any(|pair| pair[0] >= pair[1])
// The cursor names the last examined marker; pruning may leave fewer
// live roots than examined names, so it only has to bound them.
|| self.next_marker.as_ref().is_some_and(|next| {
!valid_root(next)
|| marker.is_some_and(|marker| next.as_str() <= marker)
|| self.roots.last().is_some_and(|last| last > next)
})
{
return Err(crate::disk::error::DiskError::FileCorrupt);
}
Ok(())
}
}
impl MultipartIndexRpcReply {
/// This is structural validation only; the caller must also verify `proof`.
pub fn validate(&self, request: &MultipartIndexRpcRequest, expected_disk: Uuid) -> crate::disk::error::Result<()> {
let query = &request.multipart_index;
request.validate()?;
if self.operation != query.operation
|| self.version != query.version
|| self.challenge != query.challenge
|| self.proof.len() != 32
{
return Err(crate::disk::error::DiskError::FileCorrupt);
}
self.page.validate(&query.bucket, query.marker.as_deref(), expected_disk)
}
}
/// A disk can attest coverage only after every page belongs to the same physical
/// identity and index generation. An interrupted/changed stream contributes no
/// partial candidates to the erasure set's coverage count.
pub(crate) async fn collect_pages<F, Fut>(bucket: &str, identity: Uuid, mut fetch: F) -> crate::disk::error::Result<Vec<String>>
where
F: FnMut(Option<String>) -> Fut,
Fut: std::future::Future<Output = crate::disk::error::Result<MultipartIndexPage>>,
{
let mut epoch = None;
let mut marker = None;
let mut roots = Vec::new();
let mut pages = 0_usize;
loop {
if pages >= MULTIPART_INDEX_MAX_PAGES_PER_DISK {
return Err(crate::disk::error::DiskError::FileCorrupt);
}
pages += 1;
let page = fetch(marker.clone()).await?;
page.validate(bucket, marker.as_deref(), identity)?;
if epoch.is_some_and(|epoch| epoch != page.epoch) {
return Err(crate::disk::error::DiskError::DiskNotFound);
}
epoch = Some(page.epoch);
roots.extend(page.roots);
marker = page.next_marker;
if marker.is_none() {
return Ok(roots);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multipart_index_reply_rejects_challenge_disk_cursor_and_unbounded_pages() {
let request = MultipartIndexRpcRequest::new("bucket", None);
let id = Uuid::new_v4();
let mut reply = MultipartIndexRpcReply {
operation: request.multipart_index.operation.clone(),
version: 1,
challenge: request.multipart_index.challenge,
page: MultipartIndexPage {
bucket: "bucket".into(),
disk_id: id,
epoch: Uuid::new_v4(),
roots: vec!["a".repeat(64)],
next_marker: None,
},
proof: vec![0; 32],
};
reply.validate(&request, id).expect("valid structural envelope");
assert!(
reply.validate(&MultipartIndexRpcRequest::new("bucket", None), id).is_err(),
"old challenge cannot replay"
);
assert!(reply.validate(&request, Uuid::new_v4()).is_err(), "physical disk identity cannot change");
reply.page.next_marker = Some("9".repeat(64));
assert!(reply.validate(&request, id).is_err(), "a cursor below the last root would re-list it");
reply.page.next_marker = Some("a".repeat(64));
reply
.validate(&request, id)
.expect("a pruned short page continues from its last examined name");
reply.page.next_marker = None;
reply.page.roots = vec!["a".repeat(64); MULTIPART_INDEX_PAGE_SIZE + 1];
assert!(reply.validate(&request, id).is_err(), "peer cannot enlarge the fixed page cap");
}
#[test]
fn multipart_index_response_proof_binds_the_complete_query_and_page() {
crate::runtime::sources::ensure_test_rpc_secret();
let mut request = MultipartIndexRpcRequest::new("bucket", None);
let mut page = MultipartIndexPage {
bucket: "bucket".into(),
disk_id: Uuid::new_v4(),
epoch: Uuid::new_v4(),
roots: vec![],
next_marker: None,
};
let canonical = request.canonical_response("disk", &page).expect("canonical response");
let proof = crate::cluster::rpc::sign_tonic_rpc_response_proof(&canonical).expect("signed response");
let verify = |request: &MultipartIndexRpcRequest, page: &MultipartIndexPage| {
crate::cluster::rpc::verify_tonic_rpc_response_proof(
&request.canonical_response("disk", page).expect("canonical"),
&proof,
)
};
verify(&request, &page).expect("exact response");
request.multipart_index.marker = Some("0".repeat(64));
assert!(verify(&request, &page).is_err(), "cursor is signed");
request.multipart_index.marker = None;
page.epoch = Uuid::new_v4();
assert!(verify(&request, &page).is_err(), "boot-local coverage epoch is signed");
}
#[test]
fn multipart_index_page_cursor_bounds_roots_without_requiring_a_full_page() {
let disk = Uuid::new_v4();
let page = |roots: Vec<String>, next_marker: Option<String>| MultipartIndexPage {
bucket: "bucket".into(),
disk_id: disk,
epoch: Uuid::new_v4(),
roots,
next_marker,
};
// Pruning leaves fewer live roots than examined names; the cursor still advances.
page(vec![format!("{:064x}", 1)], Some(format!("{:064x}", 5)))
.validate("bucket", None, disk)
.expect("short page with a cursor");
page(vec![], Some(format!("{:064x}", 5)))
.validate("bucket", Some(&format!("{:064x}", 2)), disk)
.expect("all examined names pruned");
assert!(
page(vec![format!("{:064x}", 6)], Some(format!("{:064x}", 5)))
.validate("bucket", None, disk)
.is_err(),
"a root beyond the cursor would be re-listed"
);
assert!(
page(vec![], Some(format!("{:064x}", 2)))
.validate("bucket", Some(&format!("{:064x}", 2)), disk)
.is_err(),
"the cursor must advance past the request marker"
);
assert!(
page(vec![], Some("not-a-sha".to_owned()))
.validate("bucket", None, disk)
.is_err(),
"the cursor is a marker name"
);
}
#[tokio::test]
async fn multipart_index_page_collection_bounds_the_pages_one_disk_may_answer() {
let id = Uuid::new_v4();
let epoch = Uuid::new_v4();
let mut served = 0_usize;
let result = collect_pages("bucket", id, |marker| {
served += 1;
let next = marker.map_or(0_u64, |marker| u64::from_str_radix(&marker[48..], 16).expect("hex cursor") + 1);
std::future::ready(Ok(MultipartIndexPage {
bucket: "bucket".into(),
disk_id: id,
epoch,
roots: vec![],
next_marker: Some(format!("{next:064x}")),
}))
})
.await;
assert!(matches!(result, Err(crate::disk::error::DiskError::FileCorrupt)), "{result:?}");
assert_eq!(served, MULTIPART_INDEX_MAX_PAGES_PER_DISK, "the peer is cut off at the page cap");
}
#[tokio::test]
async fn multipart_index_page_collection_rejects_replaced_disk_or_restarted_epoch() {
let id = Uuid::new_v4();
let epoch = Uuid::new_v4();
for replace_disk in [false, true] {
let first = MultipartIndexPage {
bucket: "bucket".into(),
disk_id: id,
epoch,
roots: (0..MULTIPART_INDEX_PAGE_SIZE).map(|index| format!("{index:064x}")).collect(),
next_marker: Some(format!("{:064x}", MULTIPART_INDEX_PAGE_SIZE - 1)),
};
let second = MultipartIndexPage {
bucket: "bucket".into(),
disk_id: if replace_disk { Uuid::new_v4() } else { id },
epoch: if replace_disk { epoch } else { Uuid::new_v4() },
roots: vec![],
next_marker: None,
};
let mut replies = std::collections::VecDeque::from([first, second]);
let result = collect_pages("bucket", id, |marker| {
let page = replies.pop_front().expect("bounded fixture pages");
if marker.is_some() {
assert!(page.roots.is_empty());
}
std::future::ready(Ok(page))
})
.await;
// A replaced disk fails the page's identity check; a restarted
// epoch fails the cross-page continuity check.
let expected_corrupt = replace_disk;
assert!(
match &result {
Err(crate::disk::error::DiskError::FileCorrupt) => expected_corrupt,
Err(crate::disk::error::DiskError::DiskNotFound) => !expected_corrupt,
_ => false,
},
"changed physical source cannot contribute partial coverage: {result:?}"
);
assert!(replies.is_empty(), "exercise the continuation response");
}
}
}
+28
View File
@@ -2731,6 +2731,11 @@ pub(crate) struct NamespaceMutationLease {
}
impl NamespaceMutationLease {
#[cfg(test)]
pub(crate) fn blocking_queue_for_test(&self) -> Arc<DiskBlockingQueue> {
self.blocking_queue.clone()
}
pub(crate) fn attach_external_guard(&self, guard: Arc<dyn Send + Sync>) {
*self.external_guard.lock() = Some(guard);
}
@@ -5382,6 +5387,29 @@ impl PublicationRoot {
})
}
/// Distinguish reconnects from replacement mounts before reusing disk-local state.
pub(crate) fn physical_identity(&self) -> io::Result<(u64, [u8; 16])> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
#[cfg(target_os = "linux")]
let metadata = self.directory.metadata()?;
#[cfg(not(target_os = "linux"))]
let metadata = std::fs::metadata(&*self.path)?;
let mut inode = [0; 16];
inode[..8].copy_from_slice(&metadata.ino().to_le_bytes());
Ok((metadata.dev(), inode))
}
#[cfg(windows)]
{
windows_file_identity(&self.directory.handle)
}
#[cfg(not(any(unix, windows)))]
{
Err(io::Error::new(io::ErrorKind::Unsupported, "physical root identity unavailable"))
}
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
+12 -2
View File
@@ -269,6 +269,11 @@ pub enum StorageError {
/// retained for operator inspection.
#[error("delete recovery found an ambiguous legacy residue; operator inspection is required")]
DeleteRecoveryLegacyAmbiguous,
/// Delete recovery exhausted the per-request work budget before it could
/// classify every residue. Retrying later makes progress, so callers treat
/// it like `SlowDown`; it stays distinct so the disk error round-trips.
#[error("delete recovery work limit reached; please retry")]
DeleteRecoveryWorkLimit,
#[error("Decommission not started")]
DecommissionNotStarted,
#[error("Decommission already running")]
@@ -480,6 +485,7 @@ impl From<DiskError> for StorageError {
DiskError::InvalidPath => StorageError::InvalidPath,
DiskError::ConditionalDeleteConflict => StorageError::ConditionalDeleteConflict,
DiskError::GuardedDeleteUnsupported => StorageError::GuardedDeleteUnsupported,
DiskError::DeleteRecoveryWorkLimit => StorageError::DeleteRecoveryWorkLimit,
DiskError::RemoteClientUnavailable(detail) => StorageError::RemoteClientUnavailable(detail),
}
}
@@ -507,6 +513,7 @@ impl StorageError {
StorageError::MethodNotAllowed => DiskError::MethodNotAllowed,
StorageError::StorageFull => DiskError::DiskFull,
StorageError::SlowDown => DiskError::TooManyOpenFiles,
StorageError::DeleteRecoveryWorkLimit => DiskError::DeleteRecoveryWorkLimit,
StorageError::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
StorageError::InconsistentDisk => DiskError::InconsistentDisk,
StorageError::UnsupportedDisk => DiskError::UnsupportedDisk,
@@ -710,6 +717,7 @@ impl Clone for StorageError {
StorageError::StorageFull => StorageError::StorageFull,
StorageError::SlowDown => StorageError::SlowDown,
StorageError::DeleteRecoveryLegacyAmbiguous => StorageError::DeleteRecoveryLegacyAmbiguous,
StorageError::DeleteRecoveryWorkLimit => StorageError::DeleteRecoveryWorkLimit,
StorageError::PrefixAccessDenied(a, b) => StorageError::PrefixAccessDenied(a.clone(), b.clone()),
StorageError::InvalidUploadIDKeyCombination(a, b) => {
StorageError::InvalidUploadIDKeyCombination(a.clone(), b.clone())
@@ -852,6 +860,7 @@ impl StorageError {
StorageError::StorageFull => StorageErrorCode::StorageFull,
StorageError::SlowDown => StorageErrorCode::SlowDown,
StorageError::DeleteRecoveryLegacyAmbiguous => StorageErrorCode::DeleteRecoveryLegacyAmbiguous,
StorageError::DeleteRecoveryWorkLimit => StorageErrorCode::DeleteRecoveryWorkLimit,
StorageError::PrefixAccessDenied(_, _) => StorageErrorCode::PrefixAccessDenied,
StorageError::InvalidUploadIDKeyCombination(_, _) => StorageErrorCode::InvalidUploadIDKeyCombination,
StorageError::MalformedUploadID(_) => StorageErrorCode::MalformedUploadID,
@@ -958,6 +967,7 @@ impl StorageError {
StorageErrorCode::StorageFull => Some(StorageError::StorageFull),
StorageErrorCode::SlowDown => Some(StorageError::SlowDown),
StorageErrorCode::DeleteRecoveryLegacyAmbiguous => Some(StorageError::DeleteRecoveryLegacyAmbiguous),
StorageErrorCode::DeleteRecoveryWorkLimit => Some(StorageError::DeleteRecoveryWorkLimit),
StorageErrorCode::PrefixAccessDenied => {
Some(StorageError::PrefixAccessDenied(Default::default(), Default::default()))
}
@@ -1700,8 +1710,8 @@ mod tests {
// covers every variant and picks up newly appended ones automatically.
let all_variants: Vec<DiskError> = (1u32..).map_while(DiskError::from_u32).collect();
assert!(
all_variants.len() >= 42,
"DiskError variant enumeration shrank: got {}, expected at least 42",
all_variants.len() >= 46,
"DiskError variant enumeration shrank: got {}, expected at least 46",
all_variants.len()
);
+62 -4
View File
@@ -79,6 +79,14 @@ pub fn compression_scheme_to_read_plan(scheme: &str) -> std::io::Result<(Compres
Ok((CompressionAlgorithm::from_str(scheme)?, ReadCompressionBackend::Legacy))
}
/// Validate that replicated compression metadata can be read by this build.
///
/// # Errors
/// Returns an error for an unsupported compression scheme.
pub fn validate_compression_scheme(scheme: &str) -> std::io::Result<()> {
compression_scheme_to_read_plan(scheme).map(|_| ())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadEncryptionBackend {
Legacy,
@@ -444,10 +452,12 @@ impl WritePlan {
if let Some(encryption) = self.encryption {
let etag_nonce = match encryption.mode {
WriteEncryptionMode::Singlepart { base_nonce } | WriteEncryptionMode::MultipartLegacy { base_nonce, .. } => {
base_nonce
}
WriteEncryptionMode::SinglepartObjectKey | WriteEncryptionMode::MultipartObjectKey { .. } => [0u8; 12],
WriteEncryptionMode::Singlepart { base_nonce } => base_nonce,
WriteEncryptionMode::SinglepartObjectKey => [0u8; 12],
// Each part write needs an independent fingerprint, including a
// retry at the same part number. This salt affects only the ETag
// MAC; encryption framing and its data nonce stay unchanged.
WriteEncryptionMode::MultipartLegacy { .. } | WriteEncryptionMode::MultipartObjectKey { .. } => rand::random(),
};
let protected_reader = rustfs_rio::EtagReader::new_encrypted(reader, encryption.key_bytes, etag_nonce)?
.with_inner_etag_delegation(delegate_inner_etag);
@@ -558,6 +568,54 @@ mod tests {
}
}
#[tokio::test]
async fn multipart_etags_are_unique_per_write_without_changing_data_encryption() {
let plaintext = b"same confidential part";
let size = i64::try_from(plaintext.len()).expect("test length");
for object_key_mode in [false, true] {
let mut tags = std::collections::HashSet::new();
for part_number in [1, 2, 1] {
let encryption = if object_key_mode {
WriteEncryption::multipart_object_key([7; 32], part_number)
} else {
WriteEncryption::multipart([7; 32], [9; 12], part_number as usize)
};
let source = HashReader::from_stream(Cursor::new(plaintext), size, size, None, None, false).expect("plaintext");
let mut reader = WritePlan::new()
.with_encryption(encryption)
.apply(source, size)
.expect("part plan");
let mut ciphertext = Vec::new();
reader.read_to_end(&mut ciphertext).await.expect("encrypt part");
let tag = reader.try_resolve_etag().expect("part fingerprint");
assert_eq!(reader.try_resolve_etag().as_ref(), Some(&tag), "reading a stored result stays stable");
assert!(
tags.insert(tag),
"different part numbers and retries must not identify matching plaintext"
);
#[cfg(feature = "rio-v2")]
let mut decrypt = if object_key_mode {
DecryptReader::new_multipart_with_object_key(Cursor::new(ciphertext), [7; 32], vec![part_number as usize])
} else {
DecryptReader::new_multipart(Cursor::new(ciphertext), [7; 32], [9; 12], vec![part_number as usize])
};
#[cfg(not(feature = "rio-v2"))]
let mut decrypt = DecryptReader::new_multipart(
Cursor::new(ciphertext),
[7; 32],
if object_key_mode { [0; 12] } else { [9; 12] },
vec![part_number as usize],
);
let mut decoded = Vec::new();
decrypt
.read_to_end(&mut decoded)
.await
.expect("existing data nonce still decrypts");
assert_eq!(decoded, plaintext);
}
}
}
async fn assert_non_trailing_checksum_survives(plan: WritePlan) {
let plaintext = b"checksum-context-through-write-plan".repeat(256);
let actual_size = plaintext.len() as i64;
+89
View File
@@ -949,8 +949,82 @@ pub enum ShardIntegrityWriteMode {
Protected,
}
/// Shared foreground recovery allowance. Clones of ObjectOptions retain one request budget.
/// Credits cover directory entries (including a minimum charge for an empty probe) and
/// transaction inspections. Each bounded call is admitted while credits remain and is
/// charged for the entries it visited when it completes.
#[derive(Debug)]
pub struct DeleteRecoveryWorkBudget {
remaining: std::sync::atomic::AtomicU32,
}
/// Entries one disk may visit per recovery step (discovery plus inspection).
/// Every disk of every set a request fans out to is admitted up to this much
/// and charged on completion, so a budget must cover that many disks.
const DELETE_RECOVERY_RESERVATION_PER_DISK: u32 = 4096 + 65_536;
impl Default for DeleteRecoveryWorkBudget {
/// Covers a full 16-drive set with headroom; `for_disks` scales it when a
/// request spans more drives than that.
fn default() -> Self {
Self::new(16 * 1024 * 1024)
}
}
impl DeleteRecoveryWorkBudget {
/// A per-request budget sized for `disks` concurrently admitted recovery steps.
pub(crate) fn for_disks(disks: usize) -> Self {
let per_disk = u64::from(DELETE_RECOVERY_RESERVATION_PER_DISK) * 2;
let scaled = u64::try_from(disks).unwrap_or(u64::MAX).saturating_mul(per_disk);
let default = u64::from(Self::default().remaining());
Self::new(u32::try_from(scaled.max(default)).unwrap_or(u32::MAX))
}
pub(crate) fn new(entries: u32) -> Self {
Self {
remaining: std::sync::atomic::AtomicU32::new(entries),
}
}
pub(crate) fn reserve(&self, maximum: u32) -> crate::disk::error::Result<u32> {
use std::sync::atomic::Ordering;
self.remaining
.try_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
(remaining > 0).then(|| remaining.saturating_sub(maximum))
})
.map(|remaining| remaining.min(maximum))
.map_err(|_| crate::disk::error::DiskError::DeleteRecoveryWorkLimit)
}
pub(crate) fn remaining(&self) -> u32 {
self.remaining.load(std::sync::atomic::Ordering::Acquire)
}
/// Admit one bounded disk call: refuse once the budget is spent, otherwise
/// cap the call at `maximum` or what remains. The work actually performed is
/// charged on completion with `charge`, so concurrent calls never exhaust
/// the budget by reservation alone (a bulk delete fans out dozens of
/// inspections per key); each call is still bounded by its own cap.
pub(crate) fn admit(&self, maximum: u32) -> crate::disk::error::Result<u32> {
match self.remaining() {
0 => Err(crate::disk::error::DiskError::DeleteRecoveryWorkLimit),
remaining => Ok(remaining.min(maximum)),
}
}
/// Record the entries a bounded call visited (at least one per call).
pub(crate) fn charge(&self, visited: u32) {
use std::sync::atomic::Ordering;
let _ = self.remaining.try_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
Some(remaining.saturating_sub(visited.max(1)))
});
}
}
#[derive(Default, Clone)]
pub struct ObjectOptions {
#[doc(hidden)]
pub delete_recovery_work_budget: Option<Arc<DeleteRecoveryWorkBudget>>,
// Use the maximum parity (N/2), used when saving server configuration files
pub max_parity: bool,
pub mod_time: Option<OffsetDateTime>,
@@ -1102,6 +1176,12 @@ pub struct ObjectOptions {
pub expiration: ExpirationOptions,
pub lifecycle_audit_event: LcAuditEvent,
/// In-process request gate evaluated on authoritative completion metadata.
/// Not persisted or sent over RPC. The trait deliberately has no Debug bound:
/// implementations may own customer encryption keys.
#[doc(hidden)]
pub multipart_completion_authorization:
Option<Arc<dyn crate::storage_api_contracts::multipart::MultipartCompletionAuthorization<Error = Error>>>,
pub eval_metadata: Option<HashMap<String, String>>,
/// Internal compare-and-set condition for replication workers publishing
/// terminal status after remote I/O. Storage validates it while holding
@@ -1115,6 +1195,10 @@ pub struct ObjectOptions {
/// Authoritative bucket Object Lock snapshot installed inside `ECStore`
/// before a destructive commit reaches the set layer.
pub object_lock_config_snapshot: Option<Arc<ObjectLockConfigSnapshot>>,
/// ECStore single PUTs acquire their destructive-write configuration guards
/// only after staging the client body. External lock owners supply a snapshot.
#[doc(hidden)]
pub defer_put_object_lock_snapshot: bool,
pub want_checksum: Option<Checksum>,
pub skip_verify_bitrot: bool,
@@ -1146,6 +1230,11 @@ pub enum ReplicationStatusWritebackMode {
}
impl ObjectOptions {
pub(crate) fn ensure_delete_recovery_work_budget(&mut self) {
self.delete_recovery_work_budget
.get_or_insert_with(|| Arc::new(DeleteRecoveryWorkBudget::default()));
}
pub(crate) fn shard_integrity_write_enabled(&self) -> bool {
match self.shard_integrity_write_mode {
Some(ShardIntegrityWriteMode::Protected) => true,
@@ -283,6 +283,17 @@ impl ECStore {
arrived.notify_one();
release.notified().await;
}
#[cfg(feature = "e2e-test-hooks")]
tokio::select! {
// Stop must drain this pause before taking the activation writer.
_ = cancel.cancelled() => return Err(Error::OperationCanceled),
result = data_movement::e2e_barrier::wait(
"rebalance",
bucket.as_str(),
entry.name.as_str(),
data_movement::e2e_barrier::STAGE_AFTER_ENTRY_ADMISSION,
) => result.map_err(Error::from)?,
}
let lock_lost_signal = run_guard.lock_lost_signal();
#[cfg(test)]
let _run_signal_test_fence = lock_lost_signal
@@ -786,7 +786,7 @@ impl WarmBackend for MockWarmBackend {
bytes.len()
};
Ok(tokio::io::BufReader::new(Cursor::new(bytes[start.min(bytes.len())..end].to_vec())))
Ok(ReadCloser::new(Cursor::new(bytes[start.min(bytes.len())..end].to_vec())))
}
async fn remove(&self, object: &str, rv: &str) -> Result<(), std::io::Error> {
+3 -4
View File
@@ -37,7 +37,6 @@ use std::{
time::Duration,
};
use time::OffsetDateTime;
use tokio::io::BufReader;
use tokio::{
select,
sync::{Notify, RwLock},
@@ -8183,7 +8182,7 @@ mod tests {
}
async fn get(&self, _object: &str, _rv: &str, _opts: WarmBackendGetOpts) -> io::Result<ReadCloser> {
Ok(BufReader::new(Cursor::new(b"RustFS".to_vec())))
Ok(ReadCloser::new(Cursor::new(b"RustFS".to_vec())))
}
async fn remove(&self, _object: &str, _rv: &str) -> io::Result<()> {
@@ -8248,7 +8247,7 @@ mod tests {
_opts: WarmBackendGetOpts,
) -> std::result::Result<ReadCloser, std::io::Error> {
if self.healthy {
Ok(BufReader::new(Cursor::new(b"RustFS".to_vec())))
Ok(ReadCloser::new(Cursor::new(b"RustFS".to_vec())))
} else {
Err(std::io::Error::other("mock get failed"))
}
@@ -9895,7 +9894,7 @@ mod tests {
}
async fn get(&self, _object: &str, _rv: &str, _opts: WarmBackendGetOpts) -> io::Result<ReadCloser> {
Ok(BufReader::new(Cursor::new(b"RustFS".to_vec())))
Ok(ReadCloser::new(Cursor::new(b"RustFS".to_vec())))
}
async fn remove(&self, _object: &str, _rv: &str) -> io::Result<()> {
+8 -1
View File
@@ -195,7 +195,14 @@ impl SetDisks {
metadata.add_version(selected)?;
}
if metadata.versions.is_empty() {
return Ok(None);
// With no decodable version, the per-version quorum loop cannot
// reject corrupt or unavailable replicas. Only complete, readable
// evidence may establish that this key has no authoritative version.
return if complete {
Ok(None)
} else {
Err(DiskError::ErasureReadQuorum)
};
}
Ok(Some(MetaCacheEntry {
name,
+6 -2
View File
@@ -357,8 +357,7 @@ use crate::io_support::rio::HashReader;
pub const DEFAULT_READ_BUFFER_SIZE: usize = MI_B; // 1 MiB = 1024 * 1024;
pub const MAX_PARTS_COUNT: usize = 10000;
pub(crate) const RUSTFS_MULTIPART_BUCKET_KEY: &str = "x-rustfs-internal-multipart-bucket";
pub(crate) const RUSTFS_MULTIPART_OBJECT_KEY: &str = "x-rustfs-internal-multipart-object";
pub(crate) use crate::disk::multipart_index::{RUSTFS_MULTIPART_BUCKET_KEY, RUSTFS_MULTIPART_OBJECT_KEY};
pub(crate) const DATA_MOVEMENT_MULTIPART_PREFIX: &str = "data-movement";
const ENV_ISSUE3031_DIAG_ENABLE: &str = "RUSTFS_ISSUE3031_DIAG_ENABLE";
@@ -5019,6 +5018,11 @@ impl DiskHealthEntry {
#[error("committed system metadata write requires repair by its owning writer")]
pub(crate) struct SystemMetadataRepairRequired;
/// An inline metadata write was rejected before staging or commit began.
#[derive(Debug, thiserror::Error)]
#[error("system metadata write rejected before staging: {0}")]
pub(crate) struct SystemMetadataWriteNotStarted(#[source] pub(crate) Error);
impl SetDisks {
pub(in crate::set_disk) async fn persist_delete_marker_purge(
&self,
@@ -141,6 +141,7 @@ pub(crate) struct DeleteRecoverySummary {
#[derive(Clone)]
pub(crate) struct DeleteRecoveryContext<'a> {
pub incarnation: Option<Uuid>,
pub work_budget: Option<Arc<crate::object_api::DeleteRecoveryWorkBudget>>,
pub dry_run: bool,
pub explicit_delete: bool,
/// Only the locked heal path may repair a live marker-creation source.
@@ -156,6 +157,7 @@ impl<'a> DeleteRecoveryContext<'a> {
pub(crate) fn new(incarnation: Option<Uuid>, can_commit: &'a (dyn Fn() -> bool + Sync)) -> Self {
Self {
incarnation,
work_budget: None,
dry_run: false,
explicit_delete: false,
repair_marker_source: false,
@@ -230,19 +232,54 @@ async fn inspect(
bucket: &str,
object: &str,
transaction: Uuid,
budget: Option<&crate::object_api::DeleteRecoveryWorkBudget>,
) -> Vec<Result<DeleteRecoveryInspection>> {
join_all(disks.iter().map(|disk| async move {
let state = disk
.as_ref()
.ok_or(DiskError::DiskNotFound)?
.inspect_delete_transaction(bucket, object, transaction)
.await?;
let disk = disk.as_ref().ok_or(DiskError::DiskNotFound)?;
let state = if let Some(budget) = budget {
let admitted = budget.admit(crate::disk::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES)?;
let reply = disk
.inspect_delete_transaction_bounded(bucket, object, transaction, admitted)
.await?;
if reply.visited > admitted {
return Err(DiskError::FileCorrupt);
}
budget.charge(reply.visited);
reply.inspection
} else {
disk.inspect_delete_transaction(bucket, object, transaction).await?
};
validate(&state, object, transaction)?;
Ok(state)
}))
.await
}
async fn discover_bounded(
disk: &DiskStore,
bucket: &str,
object: &str,
budget: &crate::object_api::DeleteRecoveryWorkBudget,
) -> Result<Vec<DeleteTransactionId>> {
let admitted = budget.admit(crate::disk::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES)?;
let page = disk.discover_delete_transactions_bounded(bucket, object, admitted).await?;
if page.visited > admitted
|| page.transactions.len() > 256
|| page
.transactions
.iter()
.any(|id| id.object != object || id.transaction.is_nil())
|| (!page.complete && !page.transactions.is_empty())
{
return Err(DiskError::FileCorrupt);
}
budget.charge(page.visited);
if !page.complete {
return Err(DiskError::DeleteRecoveryWorkLimit);
}
Ok(page.transactions)
}
async fn discover(disk: &DiskStore, bucket: &str, object: Option<&str>) -> Result<Vec<DeleteTransactionId>> {
let mut cursor = None;
let mut ids = BTreeSet::new();
@@ -273,30 +310,92 @@ async fn discover(disk: &DiskStore, bucket: &str, object: Option<&str>) -> Resul
.collect())
}
#[derive(Clone, Copy)]
struct RecoveryWorkScope<'a> {
namespace_owner: Option<&'a Arc<dyn Send + Sync>>,
budget: Option<&'a crate::object_api::DeleteRecoveryWorkBudget>,
}
async fn settle_outcome(
disk: &DiskStore,
bucket: &str,
state: &DeleteRecoveryInspection,
action: DeleteRecoveryAction,
can_commit: &(impl Fn() -> bool + Sync + ?Sized),
scope: RecoveryWorkScope<'_>,
) -> Result<crate::disk::DeleteRecoverySettlementOutcome> {
use crate::disk::DeleteRecoverySettlementOutcome;
if !can_commit() {
return Err(DiskError::other("delete recovery namespace fence was lost"));
}
let outcome = if let Some(budget) = scope.budget {
let admitted = budget.admit(crate::disk::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES)?;
let may_publish = matches!(&action, DeleteRecoveryAction::PublishCommit | DeleteRecoveryAction::AdoptCommit { .. });
let reply = disk
.settle_delete_transaction_bounded(
bucket,
crate::disk::BoundedDeleteRecoverySettlement {
object: state.id.object.clone(),
transaction: state.id.transaction,
expected_fingerprint: state.fingerprint,
action,
max_entries: admitted,
},
scope.namespace_owner.cloned(),
)
.await?;
if reply.visited > admitted
|| (!may_publish && matches!(&reply.outcome, DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred))
{
return Err(DiskError::FileCorrupt);
}
budget.charge(reply.visited);
reply.outcome
} else {
DeleteRecoverySettlementOutcome::Inspected(Box::new(
disk.settle_delete_transaction_with_namespace_owner(
bucket,
&state.id.object,
state.id.transaction,
state.fingerprint,
action,
scope.namespace_owner.cloned(),
)
.await?,
))
};
if let DeleteRecoverySettlementOutcome::Inspected(next) = &outcome {
validate(next, &state.id.object, state.id.transaction)?;
}
Ok(outcome)
}
async fn settle(
disk: &DiskStore,
bucket: &str,
state: &DeleteRecoveryInspection,
action: DeleteRecoveryAction,
can_commit: &(impl Fn() -> bool + Sync + ?Sized),
namespace_owner: Option<&Arc<dyn Send + Sync>>,
scope: RecoveryWorkScope<'_>,
) -> Result<DeleteRecoveryInspection> {
if !can_commit() {
return Err(DiskError::other("delete recovery namespace fence was lost"));
match settle_outcome(disk, bucket, state, action, can_commit, scope).await? {
crate::disk::DeleteRecoverySettlementOutcome::Inspected(inspection) => Ok(*inspection),
crate::disk::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred => {
Err(DiskError::DeleteRecoveryWorkLimit)
}
}
}
fn has_receipt_quorum(outcomes: &[Result<crate::disk::DeleteRecoverySettlementOutcome>], quorum: usize) -> bool {
outcomes.iter().filter(|outcome| published_receipt(outcome)).count() >= quorum
}
fn published_receipt(outcome: &Result<crate::disk::DeleteRecoverySettlementOutcome>) -> bool {
match outcome {
Ok(crate::disk::DeleteRecoverySettlementOutcome::Inspected(state)) => receipt(state.state),
Ok(crate::disk::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred) => true,
Err(_) => false,
}
let result = disk
.settle_delete_transaction_with_namespace_owner(
bucket,
&state.id.object,
state.id.transaction,
state.fingerprint,
action,
namespace_owner.cloned(),
)
.await;
let next = result?;
validate(&next, &state.id.object, state.id.transaction)?;
Ok(next)
}
/// Each phase fans out across independent drives, and awaits all started work
@@ -307,23 +406,16 @@ async fn settle_group(
copies: &mut [DeleteRecoveryInspection],
action: impl Fn(&DeleteRecoveryInspection) -> Option<DeleteRecoveryAction> + Sync,
can_commit: &(impl Fn() -> bool + Sync + ?Sized),
namespace_owner: Option<&Arc<dyn Send + Sync>>,
scope: RecoveryWorkScope<'_>,
removed_versions: &mut HashSet<Uuid>,
) -> Result<bool> {
let results = join_all(disks.iter().zip(copies.iter()).map(|(disk, copy)| {
let action = action(copy);
async move {
match action {
Some(action) => settle(
disk.as_ref().ok_or(DiskError::DiskNotFound)?,
bucket,
copy,
action,
can_commit,
namespace_owner,
)
.await
.map(Some),
Some(action) => settle(disk.as_ref().ok_or(DiskError::DiskNotFound)?, bucket, copy, action, can_commit, scope)
.await
.map(Some),
None => Ok(None),
}
}
@@ -439,16 +531,31 @@ impl SetDisks {
true
}
};
let mut fast_receipt_quorum = false;
if can_try_local_fast_path && can_commit() {
let fast_published = join_all(disks.iter().map(|disk| async {
if !can_commit() {
return Err(DiskError::other("delete recovery namespace fence was lost"));
}
let disk = disk.as_ref().ok_or(DiskError::DiskNotFound)?;
disk.try_publish_prepared_delete_local(bucket, object, transaction, context.namespace_owner.clone())
.await
disk.try_publish_prepared_delete_local(
bucket,
object,
transaction,
context.namespace_owner.clone(),
context.work_budget.clone(),
)
.await
}))
.await;
fast_receipt_quorum = fast_published.iter().filter(|result| matches!(result, Ok(Some(_)))).count() >= write_quorum;
if fast_receipt_quorum
&& fast_published
.iter()
.any(|result| matches!(result, Err(DiskError::DeleteRecoveryWorkLimit)))
{
return Ok(());
}
let all_fast_published = fast_published.len() == self.set_drive_count
&& fast_published.iter().all(|res| matches!(res, Ok(Some((_, true, true)))));
if all_fast_published && can_commit() {
@@ -456,6 +563,7 @@ impl SetDisks {
test_hooks::record_cleanup_attempt(bucket, object);
let finalized = join_all(disks.iter().zip(fast_published.iter()).map(|(disk, pub_res)| {
let ns_owner = context.namespace_owner.clone();
let work_budget = context.work_budget.clone();
async move {
if !can_commit() {
return Ok(false);
@@ -466,11 +574,24 @@ impl SetDisks {
let Ok(Some((journal, _, _))) = pub_res else {
return Ok(false);
};
disk.try_finish_and_finalize_prepared_delete_local(bucket, object, transaction, journal, ns_owner)
.await
disk.try_finish_and_finalize_prepared_delete_local(
bucket,
object,
transaction,
journal,
ns_owner,
work_budget,
)
.await
}
}))
.await;
if finalized
.iter()
.any(|result| matches!(result, Err(DiskError::DeleteRecoveryWorkLimit)))
{
return Ok(());
}
if finalized.iter().all(|res| matches!(res, Ok(true))) {
if let Some(ticket) = context.recovery_ticket.as_ref() {
self.ctx.delete_recovery_hints().ack(ticket);
@@ -479,7 +600,17 @@ impl SetDisks {
}
}
}
let states = inspect(disks, bucket, object, transaction).await;
let states = inspect(disks, bucket, object, transaction, context.work_budget.as_deref()).await;
if states
.iter()
.any(|result| matches!(result, Err(DiskError::DeleteRecoveryWorkLimit)))
{
return if fast_receipt_quorum {
Ok(())
} else {
Err(DiskError::DeleteRecoveryWorkLimit)
};
}
let untouched_absence = |state: &Result<DeleteRecoveryInspection>| matches!(state, Ok(state) if state.state == DeleteRecoveryState::Absent && state.current_digest.is_none());
if states.iter().filter(|state| untouched_absence(state)).count() >= write_quorum
&& states.iter().all(|state| {
@@ -499,25 +630,50 @@ impl SetDisks {
if state.state == DeleteRecoveryState::LegacyAmbiguous {
return Err(DiskError::FileCorrupt);
}
settle(
settle_outcome(
disk,
bucket,
state,
DeleteRecoveryAction::PublishCommit,
can_commit,
context.namespace_owner.as_ref(),
RecoveryWorkScope {
namespace_owner: context.namespace_owner.as_ref(),
budget: context.work_budget.as_deref(),
},
)
.await
}))
.await;
if published
.iter()
.filter(|state| matches!(state, Ok(state) if receipt(state.state)))
.count()
< write_quorum
{
return Err(DiskError::ErasureWriteQuorum);
if !has_receipt_quorum(&published, write_quorum) {
return Err(
if published
.iter()
.any(|result| matches!(result, Err(DiskError::DeleteRecoveryWorkLimit)))
{
DiskError::DeleteRecoveryWorkLimit
} else {
DiskError::ErasureWriteQuorum
},
);
}
if published.iter().any(|outcome| {
matches!(
outcome,
Ok(crate::disk::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred)
)
}) {
return Ok(());
}
let published = published
.into_iter()
.map(|outcome| match outcome {
Ok(crate::disk::DeleteRecoverySettlementOutcome::Inspected(state)) => Ok(*state),
Ok(crate::disk::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred) => {
Err(DiskError::DeleteRecoveryWorkLimit)
}
Err(error) => Err(error),
})
.collect::<Vec<_>>();
// Physical cleanup is retryable after the certified decision. An
// unavailable participant retains the decision for its eventual return.
let ticket = context.recovery_ticket.clone();
@@ -670,6 +826,7 @@ impl SetDisks {
}
let DeleteRecoveryContext {
incarnation,
work_budget,
dry_run,
explicit_delete,
repair_marker_source,
@@ -681,7 +838,13 @@ impl SetDisks {
} = context;
let disks = self.disk_inventory().await;
let complete_inventory = disks.len() == self.set_drive_count && disks.iter().all(Option::is_some);
let pages = join_all(disks.iter().flatten().map(|disk| discover(disk, bucket, Some(object)))).await;
let pages = join_all(disks.iter().flatten().map(|disk| async {
match work_budget.as_deref() {
Some(budget) => discover_bounded(disk, bucket, object, budget).await,
None => discover(disk, bucket, Some(object)).await,
}
}))
.await;
let mut transactions = BTreeSet::new();
let mut inspected = 0;
let mut offline = !complete_inventory;
@@ -712,6 +875,7 @@ impl SetDisks {
..Default::default()
};
let mut all_states = BTreeMap::new();
let mut registered_evidence_hint = false;
for transaction in transactions {
let mut complete = Vec::with_capacity(disks.len());
let inspections = if let Some((published_tx, published_states)) = published_hint
@@ -723,8 +887,14 @@ impl SetDisks {
{
published_states.to_vec()
} else {
inspect(&disks, bucket, object, transaction).await
inspect(&disks, bucket, object, transaction, work_budget.as_deref()).await
};
if inspections
.iter()
.any(|result| matches!(result, Err(DiskError::DeleteRecoveryWorkLimit)))
{
return Err(DiskError::DeleteRecoveryWorkLimit);
}
for state in inspections {
match state {
Ok(state) => {
@@ -736,6 +906,33 @@ impl SetDisks {
summary.blocked += 1;
return Ok(summary);
}
// Candidate UUID directories are not evidence. Only a bound, inspected
// journal can create a foreground hint; empty/partial scans rely on the
// existing raw anti-entropy obligation instead of queuing arbitrary prefixes.
if work_budget.is_some()
&& !registered_evidence_hint
&& journal.protocol_version == DELETE_RECOVERY_PROTOCOL_VERSION
&& journal.volume == bucket
&& journal.object == object
&& journal.transaction == transaction
&& matches!(
state.state,
DeleteRecoveryState::NewPrepared
| DeleteRecoveryState::QuorumCommitted
| DeleteRecoveryState::CommitFinished
)
{
self.ctx.delete_recovery_hints().register(
crate::store::delete_recovery_runtime::DeleteRecoveryKey {
pool: self.pool_index,
set: self.set_index,
bucket: bucket.to_owned(),
object: object.to_owned(),
incarnation,
},
);
registered_evidence_hint = true;
}
}
complete.push(state);
}
@@ -767,7 +964,7 @@ impl SetDisks {
};
if let Some((transaction, source)) = prerequisite {
Box::pin(self.heal_delete_recovery_source(bucket, object, &source, can_commit, namespace_owner.as_ref())).await?;
let refreshed = inspect(&disks, bucket, object, transaction)
let refreshed = inspect(&disks, bucket, object, transaction, work_budget.as_deref())
.await
.into_iter()
.collect::<Result<Vec<_>>>()?;
@@ -853,7 +1050,7 @@ impl SetDisks {
if mutated {
// A sibling may have restored metadata or reclaimed shared
// evidence. Refresh fingerprints before acting on this group.
*copies = inspect(&disks, bucket, object, transaction)
*copies = inspect(&disks, bucket, object, transaction, work_budget.as_deref())
.await
.into_iter()
.collect::<Result<Vec<_>>>()?;
@@ -876,7 +1073,10 @@ impl SetDisks {
(copy.state == DeleteRecoveryState::LegacyAmbiguous).then_some(DeleteRecoveryAction::DiscardLegacy)
},
can_commit,
namespace_owner.as_ref(),
RecoveryWorkScope {
namespace_owner: namespace_owner.as_ref(),
budget: work_budget.as_deref(),
},
&mut summary.removed_versions,
)
.await?
@@ -987,7 +1187,10 @@ impl SetDisks {
)
},
can_commit,
namespace_owner.as_ref(),
RecoveryWorkScope {
namespace_owner: namespace_owner.as_ref(),
budget: work_budget.as_deref(),
},
&mut summary.removed_versions,
)
.await?;
@@ -1001,7 +1204,10 @@ impl SetDisks {
.then_some(DeleteRecoveryAction::FinishCommit)
},
can_commit,
namespace_owner.as_ref(),
RecoveryWorkScope {
namespace_owner: namespace_owner.as_ref(),
budget: work_budget.as_deref(),
},
&mut summary.removed_versions,
)
.await?;
@@ -1048,7 +1254,10 @@ impl SetDisks {
copies,
|copy| (copy.state == DeleteRecoveryState::NewPrepared).then_some(DeleteRecoveryAction::Rollback),
can_commit,
namespace_owner.as_ref(),
RecoveryWorkScope {
namespace_owner: namespace_owner.as_ref(),
budget: work_budget.as_deref(),
},
&mut summary.removed_versions,
)
.await?
@@ -1076,7 +1285,7 @@ impl SetDisks {
settled.push(copies);
} else {
for transaction in all_states.keys() {
let fresh = inspect(&disks, bucket, object, *transaction).await;
let fresh = inspect(&disks, bucket, object, *transaction, work_budget.as_deref()).await;
if fresh.iter().any(|state| !matches!(state, Ok(state) if matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))) {
summary.pending += 1;
return Ok(summary);
@@ -1086,7 +1295,7 @@ impl SetDisks {
}
for (index, mut copies) in settled.into_iter().enumerate() {
if index > 0 {
copies = inspect(&disks, bucket, object, copies[0].id.transaction)
copies = inspect(&disks, bucket, object, copies[0].id.transaction, work_budget.as_deref())
.await
.into_iter()
.collect::<Result<Vec<_>>>()?;
@@ -1106,7 +1315,10 @@ impl SetDisks {
&mut copies,
|copy| (copy.state == DeleteRecoveryState::CommitFinished).then_some(DeleteRecoveryAction::FinalizeCommit),
can_commit,
namespace_owner.as_ref(),
RecoveryWorkScope {
namespace_owner: namespace_owner.as_ref(),
budget: work_budget.as_deref(),
},
&mut summary.removed_versions,
)
.await?
@@ -1358,6 +1570,459 @@ mod tests {
body
}
#[tokio::test]
async fn delete_recovery_mixed_inspected_and_deferred_receipts_count_only_real_quorum() {
let bucket = "recover-bounded-mixed-quorum";
let (_dirs, store, disks) = fixture(bucket).await;
put(&store, bucket, &vec![73; 4096], false).await;
let transaction = applied(&store, &disks, bucket, None, disks.len()).await;
let mut outcomes = Vec::new();
for (index, disk) in disks.iter().enumerate() {
let state = disk
.inspect_delete_transaction_bounded(bucket, "object", transaction, 65536)
.await
.expect("preparation inspection");
let max_entries = match index {
0 => 65536,
1 | 2 => state.visited,
_ => 1,
};
outcomes.push(
disk.settle_delete_transaction_bounded(
bucket,
crate::disk::BoundedDeleteRecoverySettlement {
object: "object".to_owned(),
transaction,
expected_fingerprint: state.inspection.fingerprint,
action: DeleteRecoveryAction::PublishCommit,
max_entries,
},
None,
)
.await
.map(|reply| reply.outcome),
);
}
assert!(
matches!(&outcomes[0], Ok(crate::disk::DeleteRecoverySettlementOutcome::Inspected(state)) if receipt(state.state))
);
assert!(matches!(
&outcomes[1],
Ok(crate::disk::DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred)
));
assert!(matches!(outcomes[3], Err(crate::disk::error::DiskError::DeleteRecoveryWorkLimit)));
assert!(has_receipt_quorum(&outcomes, 3));
assert!(!has_receipt_quorum(&outcomes, 4));
outcomes[2] = Err(DiskError::FileAccessDenied);
assert!(
!has_receipt_quorum(&outcomes, 3),
"a write failure cannot substitute for a durable receipt"
);
}
#[tokio::test]
async fn delete_recovery_single_and_batch_share_bounded_work_across_versions_and_retries() {
use crate::object_api::DeleteRecoveryWorkBudget;
use crate::storage_api_contracts::object::ObjectToDelete;
let bucket = "recover-bounded-request";
let (dirs, store, _) = fixture(bucket).await;
for dir in &dirs {
let prefix = dir.path().join(bucket).join("prefix");
std::fs::create_dir_all(&prefix).expect("physical prefix");
for _ in 0..64 {
std::fs::create_dir(prefix.join(Uuid::new_v4().to_string())).expect("legitimate child");
}
}
let error = store
.delete_object(
bucket,
"prefix",
ObjectOptions {
delete_recovery_work_budget: Some(Arc::new(DeleteRecoveryWorkBudget::new(8))),
..Default::default()
},
)
.await
.expect_err("incomplete discovery is not absence");
assert!(matches!(error, StorageError::DeleteRecoveryWorkLimit), "{error:?}");
for incarnation in [
None,
Some(
store
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("bucket incarnation"),
),
] {
assert!(
!store
.ctx
.delete_recovery_hints()
.is_pending(&crate::store::delete_recovery_runtime::DeleteRecoveryKey {
pool: 0,
set: 0,
bucket: bucket.to_owned(),
object: "prefix".to_owned(),
incarnation,
}),
"an unproven prefix must not move its scan into background recovery"
);
}
for distinct_versions in [false, true] {
let budget = Arc::new(DeleteRecoveryWorkBudget::new(32_768));
let objects = (0..1000)
.map(|_| ObjectToDelete {
object_name: "prefix".to_owned(),
version_id: distinct_versions.then(Uuid::new_v4),
..Default::default()
})
.collect();
let (_, errors) = store
.delete_objects(
bucket,
objects,
ObjectOptions {
delete_recovery_work_budget: Some(Arc::clone(&budget)),
..Default::default()
},
)
.await;
assert!(
errors
.iter()
.any(|error| matches!(error, Some(StorageError::DeleteRecoveryWorkLimit))),
"repeated actions must not each obtain a new allowance: {distinct_versions}"
);
assert!(dirs.iter().all(|dir| {
std::fs::read_dir(dir.path().join(bucket).join("prefix"))
.map(|entries| entries.count() == 64)
.unwrap_or(false)
}));
}
let error = store
.delete_object(bucket, "prefix", ObjectOptions::default())
.await
.expect_err("ordinary missing key");
assert!(
crate::error::is_err_object_not_found(&error),
"small complete inventory retains no-op semantics: {error:?}"
);
}
#[tokio::test]
async fn delete_recovery_batch_budget_survives_pool_and_set_fanout() {
use crate::layout::endpoint::Endpoint;
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
use crate::runtime::instance::InstanceContext;
let mut dirs = Vec::new();
let mut pools = Vec::new();
for pool_index in 0..2 {
let mut endpoints = Vec::new();
for set_index in 0..2 {
for disk_index in 0..4 {
let dir = tempfile::tempdir().expect("temporary disk");
let mut endpoint = Endpoint::try_from(dir.path().to_str().expect("disk path")).expect("endpoint");
endpoint.set_pool_index(pool_index);
endpoint.set_set_index(set_index);
endpoint.set_disk_index(disk_index);
endpoints.push(endpoint);
dirs.push(dir);
}
}
pools.push(PoolEndpoints {
legacy: false,
set_count: 2,
drives_per_set: 4,
endpoints: Endpoints::from(endpoints),
cmd_line: format!("bounded-recovery-{pool_index}"),
platform: "test".to_owned(),
});
}
let endpoints = EndpointServerPools(pools);
let ctx = Arc::new(InstanceContext::new());
crate::store::init_local_disks_with_instance_ctx(&ctx, endpoints.clone())
.await
.expect("local disks");
let cancel = tokio_util::sync::CancellationToken::new();
let store = ECStore::new_with_instance_ctx("127.0.0.1:0".parse().expect("address"), endpoints, cancel.clone(), ctx)
.await
.expect("two pools with two sets");
cancel.cancel();
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = "recover-bounded-fanout";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket");
let candidates = (0..100).map(|index| format!("prefix-{index}")).collect::<Vec<_>>();
let mut keys = BTreeSet::new();
for pool in &store.pools {
for set in &pool.disk_set {
keys.insert(
candidates
.iter()
.find(|key| Arc::ptr_eq(&pool.get_disks_by_key(key), set))
.expect("fixture key for set")
.clone(),
);
}
}
let keys = keys.into_iter().collect::<Vec<_>>();
for pool in &store.pools {
for set in &pool.disk_set {
assert!(
keys.iter().any(|key| Arc::ptr_eq(&pool.get_disks_by_key(key), set)),
"fixture must cover every set"
);
}
}
for dir in &dirs {
for key in &keys {
let prefix = dir.path().join(bucket).join(key);
std::fs::create_dir_all(&prefix).expect("physical prefix");
for _ in 0..8 {
std::fs::create_dir(prefix.join(Uuid::new_v4().to_string())).expect("child");
}
}
}
let budget = Arc::new(crate::object_api::DeleteRecoveryWorkBudget::new(32_768));
let objects = (0..1000)
.map(|index| crate::storage_api_contracts::object::ObjectToDelete {
object_name: keys[index % keys.len()].clone(),
version_id: Some(Uuid::new_v4()),
..Default::default()
})
.collect();
let (_, errors) = store
.delete_objects(
bucket,
objects,
ObjectOptions {
delete_recovery_work_budget: Some(Arc::clone(&budget)),
..Default::default()
},
)
.await;
assert!(
errors
.iter()
.any(|error| matches!(error, Some(StorageError::DeleteRecoveryWorkLimit))),
"all pools and sets must share the finite allowance"
);
assert!(budget.remaining() < 32_768, "fanout must debit the original request allowance");
assert!(dirs.iter().all(|dir| {
std::fs::read_dir(dir.path().join(bucket).join(&keys[0]))
.map(|entries| entries.count() == 8)
.unwrap_or(false)
}));
}
#[tokio::test]
async fn delete_recovery_fast_commit_keeps_success_when_cleanup_budget_is_empty() {
let bucket = "recover-bounded-fast-commit";
let (_dirs, store, disks) = fixture(bucket).await;
put(&store, bucket, &vec![74; 2 * 1024 * 1024], false).await;
// Inline cleanup needs no directory enumeration and may legitimately
// finish with zero scan credits. Require real part files here.
for disk in &disks {
let info = disk
.read_version("", bucket, "object", "", &ReadOptions::default())
.await
.expect("read non-inline source");
assert!(!info.inline_data(), "the fixture must require physical part cleanup");
let data_dir = info.data_dir.expect("physical data directory");
let part = std::path::PathBuf::from(disk.endpoint().to_string())
.join(bucket)
.join("object")
.join(data_dir.to_string())
.join("part.1");
assert!(part.is_file(), "the cleanup scan must encounter a real part");
}
store
.delete_object(
bucket,
"object",
ObjectOptions {
delete_recovery_work_budget: Some(Arc::new(crate::object_api::DeleteRecoveryWorkBudget::new(0))),
..Default::default()
},
)
.await
.expect("a cached prepared receipt commits independently of cleanup budget");
assert!(
store
.ctx
.delete_recovery_hints()
.is_pending(&crate::store::delete_recovery_runtime::DeleteRecoveryKey {
pool: 0,
set: 0,
bucket: bucket.to_owned(),
object: "object".to_owned(),
incarnation: Some(
store
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("bucket incarnation")
),
})
);
assert!(
!disks[0]
.discover_delete_transactions(bucket, Some("object"), None, 128)
.await
.expect("retained recovery proof")
.transactions
.is_empty()
);
store
.recover_delete_transactions_at_incarnation(bucket, Some("object"), None, &HealOpts::default())
.await
.expect("background finishes committed cleanup");
}
#[tokio::test]
async fn delete_recovery_unverified_uuid_candidate_does_not_enqueue_background_work() {
let bucket = "recover-bounded-candidate";
let (dirs, store, _) = fixture(bucket).await;
let transaction = Uuid::new_v4();
for directory in &dirs {
let candidate = directory.path().join(bucket).join("candidate").join(transaction.to_string());
std::fs::create_dir_all(&candidate).expect("candidate path");
std::fs::write(candidate.join("delete-prepared"), b"not a bound journal").expect("unverified candidate");
}
assert!(
store
.delete_object(bucket, "candidate", ObjectOptions::default())
.await
.is_err()
);
for incarnation in [
None,
Some(
store
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("bucket incarnation"),
),
] {
assert!(
!store
.ctx
.delete_recovery_hints()
.is_pending(&crate::store::delete_recovery_runtime::DeleteRecoveryKey {
pool: 0,
set: 0,
bucket: bucket.to_owned(),
object: "candidate".to_owned(),
incarnation,
})
);
}
}
#[tokio::test]
async fn delete_recovery_budget_refusal_preserves_evidence_for_background_replay() {
let bucket = "recover-bounded-evidence";
let (_dirs, store, disks) = fixture(bucket).await;
let payload = vec![71; 4096];
put(&store, bucket, &payload, false).await;
let transaction = applied(&store, &disks, bucket, None, disks.len()).await;
let before = disks[0]
.inspect_delete_transaction(bucket, "object", transaction)
.await
.expect("before refusal");
let error = store
.delete_object(
bucket,
"object",
ObjectOptions {
delete_recovery_work_budget: Some(Arc::new(crate::object_api::DeleteRecoveryWorkBudget::new(1))),
..Default::default()
},
)
.await
.expect_err("no incomplete recovery admission");
assert!(matches!(error, StorageError::DeleteRecoveryWorkLimit), "{error:?}");
let after = disks[0]
.inspect_delete_transaction(bucket, "object", transaction)
.await
.expect("after refusal");
assert_eq!(before.fingerprint, after.fingerprint);
store
.recover_delete_transactions_at_incarnation(bucket, Some("object"), None, &HealOpts::default())
.await
.expect("background-compatible recovery");
assert_eq!(body(&store, bucket, None).await, payload);
}
#[tokio::test]
async fn delete_recovery_committed_delete_defers_exhausted_cleanup_without_rollback() {
let bucket = "recover-bounded-commit";
let (_dirs, store, disks) = fixture(bucket).await;
put(&store, bucket, &vec![72; 4096], false).await;
let transaction = applied(&store, &disks, bucket, None, disks.len()).await;
let set = &store.pools[0].disk_set[0];
let key = crate::store::delete_recovery_runtime::DeleteRecoveryKey {
pool: 0,
set: 0,
bucket: bucket.to_owned(),
object: "object".to_owned(),
incarnation: None,
};
let ticket = set.ctx.delete_recovery_hints().register(key.clone());
let budget = Arc::new(crate::object_api::DeleteRecoveryWorkBudget::default());
let exhausted_after_commit = Arc::new(std::sync::atomic::AtomicBool::new(false));
// This hook disables local fast cleanup and runs only after receipt
// publication and the shared recovery path's key-wide GC inspection.
// Exhausting preparation instead would correctly reject the delete.
test_hooks::before_gc_finalize(bucket, "object", {
let budget = Arc::clone(&budget);
let exhausted_after_commit = Arc::clone(&exhausted_after_commit);
Arc::new(move || {
let budget = Arc::clone(&budget);
let exhausted_after_commit = Arc::clone(&exhausted_after_commit);
Box::pin(async move {
assert!(budget.reserve(u32::MAX).expect("credits remain until committed cleanup") > 0);
exhausted_after_commit.store(true, std::sync::atomic::Ordering::Release);
})
})
});
set.publish_delete_commit(
bucket,
"object",
transaction,
&set.disk_inventory().await,
set.set_drive_count / 2 + 1,
DeleteRecoveryContext {
work_budget: Some(Arc::clone(&budget)),
recovery_ticket: ticket,
..DeleteRecoveryContext::new(None, &|| true)
},
)
.await
.expect("the durable decision still succeeds");
assert!(exhausted_after_commit.load(std::sync::atomic::Ordering::Acquire));
assert_eq!(budget.remaining(), 0);
assert!(set.ctx.delete_recovery_hints().is_pending(&key));
let state = disks[0]
.inspect_delete_transaction(bucket, "object", transaction)
.await
.expect("retained decision");
assert!(receipt(state.state));
store
.recover_delete_transactions_at_incarnation(bucket, Some("object"), None, &HealOpts::default())
.await
.expect("eventual cleanup");
assert!(
disks[0]
.discover_delete_transactions(bucket, Some("object"), None, 128)
.await
.expect("cleaned proofs")
.transactions
.is_empty()
);
}
#[tokio::test]
async fn delete_recovery_set_rolls_back_undecided_inline_bytes() {
let bucket = "recover-undecided";
+23 -13
View File
@@ -458,7 +458,10 @@ impl SetDisks {
return None;
}
let _ = disk.close().await;
// A failed remote reconnect must retain its existing recovery monitor.
if disk.is_local() {
let _ = disk.close().await;
}
}
let endpoint = self.set_endpoints.get(i)?;
@@ -580,19 +583,26 @@ impl SetDisks {
let _ = new_disk.set_disk_id(Some(fm.erasure.this)).await;
if !new_disk.is_local() {
let mut disks = self.disks.write().await;
let slot = &mut disks[disk_idx];
let unchanged = match (slot.as_ref(), previous_disk.as_ref()) {
(None, None) => true,
(Some(current), Some(previous)) => Arc::ptr_eq(current, previous),
_ => false,
let displaced = {
let mut disks = self.disks.write().await;
let slot = &mut disks[disk_idx];
let unchanged = match (slot.as_ref(), previous_disk.as_ref()) {
(None, None) => true,
(Some(current), Some(previous)) => Arc::ptr_eq(current, previous),
_ => false,
};
// A GET probe or another reconnect may have filled this slot while
// format I/O was pending. Only the successful publisher owns the
// displaced monitor; a failed or superseded probe must retain it.
if unchanged {
new_disk.enable_health_check();
slot.replace(new_disk)
} else {
None
}
};
// A GET probe or another reconnect may have filled this slot while
// format I/O was pending. Do not replace its handle or start a
// second health monitor for an unpublished handle.
if unchanged {
new_disk.enable_health_check();
*slot = Some(new_disk);
if let Some(disk) = displaced {
let _ = disk.close().await;
}
return false;
}
File diff suppressed because it is too large Load Diff
+250 -3
View File
@@ -3914,6 +3914,7 @@ impl SetDisks {
bucket,
object,
DeleteRecoveryContext {
work_budget: opts.delete_recovery_work_budget.clone(),
explicit_delete: true,
transaction_guard: transaction_guard.cloned(),
namespace_owner: Some(Arc::new((
@@ -4526,7 +4527,16 @@ impl SetDisks {
"Set disk write quorum unavailable"
);
if let Some(write_err) = reduce_write_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, write_quorum) {
return Err(to_object_err(write_err.into(), vec![bucket, object]));
let error = to_object_err(write_err.into(), vec![bucket, object]);
if direct_inline_commit
&& is_meta_bucketname(bucket)
&& opts.write_completion == crate::object_api::WriteCompletion::TailDrainedWithOwnerRepair
{
// Inline writer setup only checks disk availability;
// no shard or metadata write has been dispatched yet.
return Err(Error::other(crate::set_disk::SystemMetadataWriteNotStarted(error)));
}
return Err(error);
}
return Err(Error::other(format!("not enough disks to write: {errors:?}")));
@@ -4789,6 +4799,27 @@ impl SetDisks {
.await?;
}
// Lock order: lifecycle, metadata transaction, then object namespace.
// No body reads occur below this point. The snapshot must describe the
// configuration at commit, not the pre-body advisory object lookup.
let mut commit_opts;
let opts = if opts.defer_put_object_lock_snapshot {
commit_opts = opts.clone();
let expected = source_bucket_incarnation_id
.ok_or_else(|| Error::other("destructive PUT is missing its bucket incarnation"))?;
let store = metadata_sys::object_store_in(&self.ctx).await?;
let snapshot = store.object_lock_config_snapshot_for_single_put(bucket).await?;
if !snapshot.is_valid_for_destructive_put(store.id, bucket, expected) {
return Err(Error::other("PUT bucket generation or configuration changed while staging"));
}
snapshot.add_lock_fences(&mut commit_opts);
commit_opts.object_lock_config_snapshot = Some(snapshot);
commit_opts.defer_put_object_lock_snapshot = false;
&commit_opts
} else {
opts
};
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
#[cfg(test)]
{
@@ -5282,6 +5313,7 @@ impl SetDisks {
let commit_decommission_object_lock_guard = decommission_object_lock_guard.take();
let commit_publication_guard = publication_commit_guard.take();
let commit_bucket_lifecycle_guard = bucket_lifecycle_guard.take();
let commit_object_lock_config_snapshot = opts.object_lock_config_snapshot.clone();
let commit_decommission_capacity_guard = decommission_capacity_guard.take();
let commit_scanner_publication_scope = opts.scanner_publication_commit_scope.clone();
// A scanner publication scope owns the movement permit until the
@@ -5344,6 +5376,7 @@ impl SetDisks {
let mut _decommission_object_lock_guard = commit_decommission_object_lock_guard;
let mut _publication_guard = commit_publication_guard;
let mut _bucket_lifecycle_guard = commit_bucket_lifecycle_guard;
let mut _object_lock_config_snapshot = commit_object_lock_config_snapshot;
let mut _decommission_capacity_guard = commit_decommission_capacity_guard;
let mut quota_reservation = quota_reservation;
let rename_stage_start = Instant::now();
@@ -5677,6 +5710,7 @@ impl SetDisks {
let object_lock_guard = _object_lock_guard.take();
let publication_guard = _publication_guard.take();
let bucket_lifecycle_guard = _bucket_lifecycle_guard.take();
let object_lock_config_snapshot = _object_lock_config_snapshot.take();
let decommission_object_lock_guard = _decommission_object_lock_guard.take();
let decommission_capacity_guard = _decommission_capacity_guard.take();
let cleanup_bucket = commit_bucket.clone();
@@ -5699,6 +5733,7 @@ impl SetDisks {
write_context,
publication_guard,
bucket_lifecycle_guard,
object_lock_config_snapshot,
decommission_object_lock_guard,
decommission_capacity_guard,
),
@@ -5720,6 +5755,7 @@ impl SetDisks {
write_context,
publication_guard,
bucket_lifecycle_guard,
object_lock_config_snapshot,
decommission_object_lock_guard,
decommission_capacity_guard,
),
@@ -5733,6 +5769,7 @@ impl SetDisks {
drop(write_context);
drop(publication_guard);
drop(bucket_lifecycle_guard);
drop(object_lock_config_snapshot);
cleanup_runtime::run(cleanup_reservation, async move {
let zero_target_cleanup = targets.is_empty();
rustfs_io_metrics::record_put_object_stage_duration(
@@ -9147,6 +9184,9 @@ impl SetDisks {
&disks,
write_quorum,
DeleteRecoveryContext {
work_budget: commit_context
.options
.and_then(|options| options.delete_recovery_work_budget.clone()),
recovery_ticket,
transaction_guard: commit_context.transaction_guard.clone(),
namespace_owner: namespace_owner.clone().map(|owner| owner as Arc<dyn Send + Sync>),
@@ -9710,9 +9750,26 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
if let Some(etag) = &src_info.etag {
replacement_metadata.insert("etag".to_owned(), etag.clone());
}
// ObjectInfo externalizes these fields; copy callers supply the effective
// values, including empty tags/absent expiry for explicit replacement.
if !src_info.user_tags.is_empty() {
replacement_metadata
.entry(rustfs_utils::http::headers::AMZ_OBJECT_TAGGING.to_string())
.or_insert_with(|| (*src_info.user_tags).clone());
}
if let Some(expires) = src_info.expires
&& !replacement_metadata.contains_key("expires")
{
replacement_metadata.insert(
"expires".to_string(),
expires
.format(&time::format_description::well_known::Rfc3339)
.map_err(Error::other)?,
);
}
fi.metadata = replacement_metadata.clone();
let mod_time = OffsetDateTime::now_utc();
let mod_time = dst_opts.mod_time.unwrap_or_else(OffsetDateTime::now_utc);
fi.mod_time = Some(mod_time);
fi.version_id = version_id;
fi.versioned = src_opts.versioned || src_opts.version_suspended;
@@ -9835,6 +9892,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
objects: Vec<ObjectToDelete>,
mut opts: ObjectOptions,
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
opts.ensure_delete_recovery_work_budget();
let mut del_objects = vec![DeletedObject::default(); objects.len()];
let mut accounting = vec![None; objects.len()];
let delete_config_snapshot = opts
@@ -10106,6 +10164,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
bucket,
&dobj.object_name,
DeleteRecoveryContext {
work_budget: opts.delete_recovery_work_budget.clone(),
dry_run: !opts.no_lock,
transaction_guard: transaction_guards.get(lock_names[i].as_ref()).cloned(),
namespace_owner: opts.delete_mutation_owner.clone(),
@@ -10714,6 +10773,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
disks,
self.set_drive_count / 2 + 1,
DeleteRecoveryContext {
work_budget: opts.delete_recovery_work_budget.clone(),
recovery_ticket,
namespace_owner,
transaction_guard,
@@ -10897,6 +10957,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
bucket,
&object.object_name,
DeleteRecoveryContext {
work_budget: opts.delete_recovery_work_budget.clone(),
explicit_delete,
namespace_owner: Some(Arc::new((
self.ctx.begin_namespace_commit(),
@@ -10955,6 +11016,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
#[tracing::instrument(skip(self, opts))]
async fn delete_object(&self, bucket: &str, object: &str, mut opts: ObjectOptions) -> Result<ObjectInfo> {
opts.ensure_delete_recovery_work_budget();
let _scope_outcome_guard = opts
.scanner_publication_commit_scope
.clone()
@@ -11251,6 +11313,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
bucket,
object,
DeleteRecoveryContext {
work_budget: opts.delete_recovery_work_budget.clone(),
transaction_guard: transaction_guard.clone(),
namespace_owner: Some(Arc::new((
self.ctx.begin_namespace_commit(),
@@ -11862,7 +11925,10 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
// as a second object header set: forwarding SSE intent or wrapped DEKs
// would request a second encryption pass and disclose local envelope
// material to the remote provider.
transition_meta.retain(|key, _| !rustfs_utils::http::is_replication_stripped_encryption_key(key));
transition_meta.retain(|key, _| {
!rustfs_utils::http::is_replication_stripped_encryption_key(key)
&& !rustfs_utils::http::is_snowball_transport_key(key)
});
transition_meta.insert("name".to_string(), object.to_string());
rustfs_utils::http::metadata_compat::insert_str(
&mut transition_meta,
@@ -16338,6 +16404,170 @@ mod metadata_mutation_generation_tests {
assert_retired(&set_disks, &metadata_key).await;
}
#[tokio::test]
async fn restore_metadata_copy_preserves_tags_expiry_and_version_order() {
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
use s3s::dto::RestoreRequest;
use time::format_description::well_known::Rfc3339;
let (_temp_dirs, disks, set) = hermetic_set_disks(4).await;
let bucket = "restore-metadata-preservation";
for disk in &disks {
disk.make_volume(bucket).await.expect("create bucket");
}
let old_time = OffsetDateTime::UNIX_EPOCH + time::Duration::days(1);
let latest_time = old_time + time::Duration::days(1);
let expires = latest_time + time::Duration::days(365);
for deleted in [false, true] {
let object = if deleted { "deleted" } else { "live" };
let tags = "classification=secret&owner=alice";
let old = set
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(b"old protected content".to_vec()),
&ObjectOptions {
versioned: true,
mod_time: Some(old_time),
write_completion: crate::object_api::WriteCompletion::TailDrained,
user_defined: HashMap::from([
(AMZ_OBJECT_TAGGING.to_string(), tags.to_string()),
("x-amz-tagging".to_string(), tags.to_string()),
("expires".to_string(), expires.format(&Rfc3339).expect("expiry")),
]),
..Default::default()
},
)
.await
.expect("write historical version");
let latest = if deleted {
let marker = FileInfo {
version_id: Some(Uuid::new_v4()),
mod_time: Some(latest_time),
deleted: true,
versioned: true,
..Default::default()
};
for disk in &disks {
disk.write_metadata(bucket, bucket, object, marker.clone())
.await
.expect("publish current delete marker");
}
marker.version_id
} else {
set.put_object(
bucket,
object,
&mut PutObjReader::from_vec(b"latest content".to_vec()),
&ObjectOptions {
versioned: true,
mod_time: Some(latest_time),
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("write latest version")
.version_id
};
let opts = ObjectOptions {
version_id: old.version_id.map(|id| id.to_string()),
versioned: true,
mod_time: Some(old_time),
..Default::default()
};
let mut source = set.get_object_info(bucket, object, &opts).await.expect("read old metadata");
assert_eq!(source.user_tags.as_str(), tags);
assert_eq!(source.expires, Some(expires));
assert!(!source.user_defined.contains_key(AMZ_OBJECT_TAGGING));
assert!(source.user_defined.contains_key("x-amz-tagging"));
source.metadata_only = true;
Arc::make_mut(&mut source.user_defined)
.insert(metadata_keys::RESTORE.to_string(), "ongoing-request=\"true\"".to_string());
set.copy_object(bucket, object, bucket, object, &mut source, &opts, &opts)
.await
.expect("restore metadata update");
let restored = set
.get_object_info(bucket, object, &opts)
.await
.expect("read restored metadata");
assert_eq!(restored.user_tags.as_str(), tags);
assert_eq!(restored.expires, Some(expires));
assert_eq!(restored.mod_time, Some(old_time));
for disk in &disks {
let bytes = disk
.read_all(bucket, &format!("{object}/xl.meta"))
.await
.expect("persisted metadata");
let meta = FileMeta::load(&bytes).expect("decode versions");
let versions = meta.list_versions(bucket, object, false).expect("list versions");
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].version_id, latest);
assert_eq!(versions[0].mod_time, Some(latest_time));
assert_eq!(versions[0].deleted, deleted);
assert_eq!(versions[1].version_id, old.version_id);
assert_eq!(versions[1].mod_time, Some(old_time));
}
let restore_opts = put_restore_opts(bucket, object, &RestoreRequest::default(), &restored)
.await
.expect("background restore options");
assert_eq!(restore_opts.user_defined.get(AMZ_OBJECT_TAGGING).map(String::as_str), Some(tags));
assert_eq!(restore_opts.user_defined.get("expires"), Some(&expires.format(&Rfc3339).expect("expiry")));
assert_eq!(restore_opts.mod_time, Some(old_time));
}
}
#[tokio::test]
async fn metadata_copy_honors_explicit_tag_and_expiry_replacement() {
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
use time::format_description::well_known::Rfc3339;
let (_temp_dirs, disks, set) = hermetic_set_disks(4).await;
let bucket = "copy-metadata-replacement";
for disk in &disks {
disk.make_volume(bucket).await.expect("create bucket");
}
let old_time = OffsetDateTime::UNIX_EPOCH + time::Duration::days(1);
let expires = old_time + time::Duration::days(365);
set.put_object(
bucket,
"object",
&mut PutObjReader::from_vec(b"content".to_vec()),
&ObjectOptions {
mod_time: Some(old_time),
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("write original");
let opts = ObjectOptions::default();
let mut source = set.get_object_info(bucket, "object", &opts).await.expect("original metadata");
source.metadata_only = true;
source.user_tags = Arc::new("old=value".to_string());
source.expires = Some(expires - time::Duration::days(1));
let metadata = Arc::make_mut(&mut source.user_defined);
metadata.insert(AMZ_OBJECT_TAGGING.to_string(), "new=value".to_string());
metadata.insert("expires".to_string(), expires.format(&Rfc3339).expect("expiry"));
set.copy_object(bucket, "object", bucket, "object", &mut source, &opts, &opts)
.await
.expect("explicit metadata wins over externalized fields");
let mut copied = set.get_object_info(bucket, "object", &opts).await.expect("copied metadata");
assert_eq!(copied.user_tags.as_str(), "new=value");
assert_eq!(copied.expires, Some(expires));
assert!(copied.mod_time.is_some_and(|time| time > old_time));
copied.metadata_only = true;
copied.user_tags = Arc::new(String::new());
copied.expires = None;
set.copy_object(bucket, "object", bucket, "object", &mut copied, &opts, &opts)
.await
.expect("explicit empty replacement");
let cleared = set.get_object_info(bucket, "object", &opts).await.expect("cleared metadata");
assert!(cleared.user_tags.is_empty());
assert!(cleared.expires.is_none());
}
#[tokio::test]
async fn metadata_only_copy_preserves_valid_part_checksums_and_rejects_conflicting_aliases() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
@@ -16790,6 +17020,19 @@ mod transition_commit_failure_tests {
.collect();
user_defined.insert("x-amz-meta-owner".to_string(), "finance".to_string());
let snowball_keys = [
"snowball-auto-extract",
"x-amz-meta-snowball-auto-extract",
"x-amz-snowball-auto-extract",
"snowball-prefix",
"x-amz-meta-rustfs-snowball-prefix",
"x-amz-meta-minio-snowball-ignore-dirs",
"other-vendor-snowball-ignore-errors",
];
for key in snowball_keys {
user_defined.insert(key.to_string(), "true".to_string());
}
let mut reader = PutObjReader::from_vec(b"stored bytes the tier keeps opaque ".repeat(64));
set_disks
.put_object(
@@ -16839,6 +17082,10 @@ mod transition_commit_failure_tests {
.await
.expect("the tier must have received the object");
for key in snowball_keys {
assert!(!remote_metadata.contains_key(key), "tier must receive opaque bytes, not {key}");
assert_eq!(transitioned.user_defined.get(key).map(String::as_str), Some("true"));
}
for (key, _) in encryption_metadata {
assert!(
!remote_metadata.keys().any(|stored| stored.eq_ignore_ascii_case(key)),
@@ -52,8 +52,8 @@ pub(crate) mod list {
pub(crate) mod multipart {
pub(crate) use rustfs_storage_api::{
CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartOperations, MultipartUploadInspection,
MultipartUploadResult, PartInfo,
CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartCompletionAuthorization, MultipartInfo, MultipartOperations,
MultipartUploadInspection, MultipartUploadResult, PartInfo,
};
}
+291 -36
View File
@@ -14,7 +14,7 @@
use super::*;
use crate::bucket::{
metadata::{BUCKET_TABLE_RESERVED_PREFIX, BUCKET_TAGGING_CONFIG, ConfigState, table_bucket_catalog_metadata_prefix},
metadata::{BUCKET_TABLE_RESERVED_PREFIX, BUCKET_TAGGING_CONFIG, ConfigState},
utils::is_meta_bucketname,
};
use crate::error::is_err_bucket_not_found;
@@ -145,11 +145,41 @@ async fn table_bucket_enabled_in(ctx: &crate::runtime::instance::InstanceContext
}
}
fn bucket_delete_metadata_cleanup_prefixes(bucket: &str) -> [String; 2] {
[
table_bucket_catalog_metadata_prefix(bucket),
format!("{BUCKET_META_PREFIX}/{bucket}"),
]
fn bucket_delete_metadata_cleanup_prefixes(bucket: &str) -> Vec<String> {
use crate::bucket::metadata::*;
let mut prefixes = vec![table_bucket_catalog_metadata_prefix(bucket)];
let root = format!("{BUCKET_META_PREFIX}/{bucket}");
if bucket != crate::disk::HEAL_REPLACEMENT_RECOVERY_DIR {
prefixes.push(root);
return prefixes;
}
// This valid user bucket shares its metadata directory with raw drive
// replacement intents, seals and recovery proofs. Delete only bucket-owned
// paths, including legacy standalone configuration, never the shared root.
// S3 objects live in the separate user volume; configuration APIs write the
// aggregate metadata file, so clients cannot populate arbitrary siblings.
prefixes.extend(
[
BUCKET_METADATA_FILE,
BUCKET_INCARNATION_FILE,
BUCKET_POLICY_CONFIG,
BUCKET_QUOTA_CONFIG_FILE,
BUCKET_TARGETS_FILE,
BUCKET_ACL_CONFIG,
BUCKET_TABLE_CONFIG,
BUCKET_DURABILITY_CONFIG,
BUCKET_ON_DEMAND_MIGRATION_CONFIG,
".replication",
"integrity-jobs",
crate::data_usage::DATA_USAGE_CACHE_NAME,
]
.into_iter()
.chain(XML_BUCKET_CONFIG_FILES)
.map(|file| format!("{root}/{file}")),
);
prefixes
}
fn bucket_deleted_marker_prefix(bucket: &str) -> String {
@@ -160,41 +190,48 @@ fn bucket_deleted_marker_volume(bucket: &str) -> String {
format!("{RUSTFS_META_BUCKET}/{}", bucket_deleted_marker_prefix(bucket))
}
pub(crate) async fn await_bucket_namespace_operation<T, F>(
guard: Option<&rustfs_lock::NamespaceLockGuard>,
bucket: &str,
// Box the child before constructing either lease wrapper. Keeping F inline in
// nested select futures multiplies poll-frame temporaries along DELETE -> quota
// cleanup -> metadata GET. This boundary must not inline back into that chain.
#[inline(never)]
pub(crate) fn await_bucket_namespace_operation<'a, T: 'a, F>(
guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
bucket: &'a str,
operation: &'static str,
future: F,
) -> Result<T>
) -> impl Future<Output = Result<T>> + 'a
where
F: Future<Output = Result<T>>,
F: Future<Output = Result<T>> + 'a,
{
let Some(guard) = guard else {
return future.await;
};
if guard.is_lock_lost() {
return Err(StorageError::other(format!(
"bucket namespace lock was lost before {operation}: {bucket}"
)));
}
tokio::select! {
biased;
_ = guard.lock_lost_notified() => Err(StorageError::other(format!(
"bucket namespace lock was lost during {operation}: {bucket}"
))),
result = future => result,
let future = Box::pin(future);
async move {
let Some(guard) = guard else {
return future.await;
};
if guard.is_lock_lost() {
return Err(StorageError::other(format!(
"bucket namespace lock was lost before {operation}: {bucket}"
)));
}
tokio::select! {
biased;
_ = guard.lock_lost_notified() => Err(StorageError::other(format!(
"bucket namespace lock was lost during {operation}: {bucket}"
))),
result = future => result,
}
}
}
async fn await_bucket_lifecycle_operation<T, F>(
lifecycle_guard: Option<&rustfs_lock::NamespaceLockGuard>,
namespace_guard: Option<&rustfs_lock::NamespaceLockGuard>,
bucket: &str,
fn await_bucket_lifecycle_operation<'a, T: 'a, F>(
lifecycle_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
namespace_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
bucket: &'a str,
operation: &'static str,
future: F,
) -> Result<T>
) -> impl Future<Output = Result<T>> + 'a
where
F: Future<Output = Result<T>>,
F: Future<Output = Result<T>> + 'a,
{
await_bucket_namespace_operation(
lifecycle_guard,
@@ -202,7 +239,6 @@ where
operation,
await_bucket_namespace_operation(namespace_guard, bucket, operation, future),
)
.await
}
fn bucket_recovery_lock_unavailable(bucket: &str, object: &str) -> StorageError {
@@ -478,10 +514,18 @@ impl ECStore {
let mut tags = HashMap::with_capacity(tagging.tag_set.len());
for tag in &tagging.tag_set {
let (Some(key), Some(value)) = (&tag.key, &tag.value) else {
return Err(Error::other("stored bucket tag is missing a key or value"));
return Err(crate::bucket::metadata::unreadable_config_error(
bucket,
BUCKET_TAGGING_CONFIG,
metadata.tagging_config_xml.len(),
));
};
if key.is_empty() || tags.insert(key.clone(), value.clone()).is_some() {
return Err(Error::other("stored bucket tags contain empty or duplicate keys"));
return Err(crate::bucket::metadata::unreadable_config_error(
bucket,
BUCKET_TAGGING_CONFIG,
metadata.tagging_config_xml.len(),
));
}
}
Ok(tags)
@@ -1673,7 +1717,17 @@ impl ECStore {
ns_guard.as_ref(),
bucket,
"bucket retirement publication",
crate::bucket::retirement::commit_retirement(store, bucket, incarnation, &record_opts),
crate::bucket::retirement::commit_retirement(store.clone(), bucket, incarnation, &record_opts),
)
.await?;
// The lifecycle fence excludes live reservation owners. A crash-left
// V1 ledger must not block quota admission after same-name recreation.
await_bucket_lifecycle_operation(
bucket_lifecycle_guard.as_ref(),
ns_guard.as_ref(),
bucket,
"retired bucket quota ledger cleanup",
crate::bucket::quota::reservation::cleanup_retired_bucket_ledger(store, bucket, incarnation),
)
.await?;
}
@@ -1824,6 +1878,20 @@ mod tests {
assert!(!xlmeta.diagnostic_truncated);
}
#[tokio::test]
async fn bucket_namespace_operation_keeps_nested_future_state_off_the_poll_stack() {
let payload = std::hint::black_box([7u8; 128 * 1024]);
let operation = super::await_bucket_lifecycle_operation(None, None, "bucket", "large metadata operation", async move {
tokio::task::yield_now().await;
Ok(std::hint::black_box(payload)[0])
});
assert!(
std::mem::size_of_val(&operation) < 1024,
"nested lease wrappers must not retain the concrete storage future inline"
);
assert_eq!(operation.await.expect("the boxed operation must still execute"), 7);
}
#[tokio::test(start_paused = true)]
async fn bucket_namespace_operation_fails_closed_after_lease_expiry() {
let ttl = Duration::from_millis(20);
@@ -2240,7 +2308,13 @@ mod tests {
let result = store.get_bucket_tags_for_policy(bucket).await;
match expected {
Some(tags) => assert_eq!(&result.expect("valid tag configuration"), tags),
None => assert!(result.is_err(), "corrupt tags must not become an untagged bucket: {xml:?}"),
None => assert!(
crate::bucket::metadata::unreadable_config_refusal(
&result.expect_err("corrupt tags must not become an untagged bucket")
)
.is_some_and(|failure| failure.config_file == super::BUCKET_TAGGING_CONFIG),
"corrupt tags must carry a typed tagging refusal: {xml:?}"
),
}
}
}
@@ -2442,6 +2516,187 @@ mod tests {
assert!(prefixes.contains(&"buckets/analytics".to_string()));
}
#[tokio::test]
#[serial]
async fn replacement_named_bucket_delete_preserves_recovery_and_recreates_clean_metadata() {
use crate::bucket::metadata::{BUCKET_QUOTA_CONFIG_FILE, BUCKET_TAGGING_CONFIG, BUCKET_VERSIONING_CONFIG};
let (_temp, store) = setup_bucket_quorum_test_env(&[4], None).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "ahm-replacement";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create legal bucket");
let old_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("old incarnation");
for (config, bytes) in [
(BUCKET_QUOTA_CONFIG_FILE, br#"{"quota":1024,"quota_type":"Hard"}"#.as_slice()),
(
BUCKET_TAGGING_CONFIG,
b"<Tagging><TagSet><Tag><Key>old</Key><Value>value</Value></Tag></TagSet></Tagging>".as_slice(),
),
(
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".as_slice(),
),
] {
metadata_sys::update_in(&store.ctx, bucket, config, bytes.to_vec())
.await
.expect("persist old configuration");
}
let ledger_path = format!("config/quota-ledger/{bucket}.json");
// V1 precharges outstanding growth: four live bytes plus the
// crash-left reservation's 100 bytes must already be accounted.
let ledger = serde_json::to_vec(&serde_json::json!({
"version": 1, "bucket_incarnation": old_incarnation,
"quota_revision_unix_nanos": 0, "accounted_usage": 104,
"reservations": {Uuid::new_v4().to_string(): {
"object": "crashed-put", "old_size": 0, "new_size": 100,
"created_at": 0, "pool_index": 0, "set_index": 0, "commit_started": false
}}, "reconcile_required": true
}))
.expect("encode crash-left ledger");
// The storage API requires the same legacy-quota admission supplied
// by the S3 handler, even for this small fixture object.
let mut live_options = ObjectOptions {
versioned: true,
..Default::default()
};
assert!(live_options.set_quota_admission(0, 1024));
// Failed deletion must leave both the live incarnation and its ledger alone.
store
.put_object(bucket, "live", &mut PutObjReader::from_vec(b"live".to_vec()), &live_options)
.await
.expect("write live object");
crate::config::com::save_config(store.clone(), &ledger_path, ledger.clone())
.await
.expect("persist old ledger");
assert!(matches!(
store.delete_bucket(bucket, &DeleteBucketOptions::default()).await,
Err(StorageError::BucketNotEmpty(_))
));
assert_eq!(
crate::config::com::read_config(store.clone(), &ledger_path)
.await
.expect("live ledger remains"),
ledger
);
// Remove every version; the configured bucket is otherwise still empty.
let versions = store
.clone()
.list_object_versions(bucket, "", None, None, None, 100)
.await
.expect("list live versions");
for object in versions.objects {
store
.delete_object(
bucket,
&object.name,
ObjectOptions {
version_id: object.version_id.map(|id| id.to_string()),
..Default::default()
},
)
.await
.expect("remove live version");
}
let root = format!("{BUCKET_META_PREFIX}/{bucket}");
let task = Uuid::new_v4();
let recovery_names = [
"ahm_replacement_intent.json",
"ahm_replacement_intent_seal",
"ahm_replacement_completion_proof.json",
]
.map(|suffix| format!("{task}_{suffix}"));
let disk_paths = store.pools[0].disk_set[0]
.disks
.read()
.await
.iter()
.flatten()
.map(|disk| PathBuf::from(disk.endpoint().to_string()))
.collect::<Vec<_>>();
for disk in &disk_paths {
let directory = disk.join(RUSTFS_META_BUCKET).join(&root);
for name in &recovery_names {
tokio::fs::write(directory.join(name), b"durable heal record")
.await
.expect("seed raw recovery record");
}
// Legacy standalone settings and bucket-owned subtrees must not survive.
for name in [
"quota.json",
"versioning.xml",
"policy.json",
"bucket-targets.json",
"integrity-jobs/job.json",
".replication/resync.bin",
".usage-cache.bin",
] {
let path = directory.join(name);
tokio::fs::create_dir_all(path.parent().expect("fixture parent"))
.await
.expect("legacy parent");
tokio::fs::write(path, b"old bucket configuration")
.await
.expect("seed legacy configuration");
}
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete legal same-name bucket");
assert!(matches!(
crate::config::com::read_config(store.clone(), &ledger_path).await,
Err(StorageError::ConfigNotFound)
));
for disk in &disk_paths {
let directory = disk.join(RUSTFS_META_BUCKET).join(&root);
for name in &recovery_names {
assert_eq!(
tokio::fs::read(directory.join(name)).await.expect("recovery record survives"),
b"durable heal record"
);
}
for prefix in bucket_delete_metadata_cleanup_prefixes(bucket)
.into_iter()
.filter(|p| p.starts_with(&root))
{
assert!(
!disk.join(RUSTFS_META_BUCKET).join(prefix).exists(),
"bucket-owned metadata must be removed"
);
}
}
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("recreate legal bucket");
let fresh = metadata_sys::get_in(&store.ctx, bucket).await.expect("fresh metadata");
assert_ne!(fresh.bucket_incarnation_id, old_incarnation);
assert!(fresh.quota_config_json.is_empty());
assert!(fresh.tagging_config_xml.is_empty());
assert!(fresh.versioning_config_xml.is_empty());
assert!(fresh.policy_config_json.is_empty());
// The strict purge/recovery cleanup must share the same ownership boundary.
store
.cleanup_deleted_bucket_metadata_strict(bucket, true)
.await
.expect("strict metadata cleanup");
for disk in &disk_paths {
for name in &recovery_names {
assert_eq!(
tokio::fs::read(disk.join(RUSTFS_META_BUCKET).join(&root).join(name))
.await
.expect("strict cleanup preserves recovery"),
b"durable heal record"
);
}
}
}
#[test]
fn bucket_delete_marker_path_uses_internal_deleted_bucket_metadata_prefix() {
assert_eq!(bucket_deleted_marker_prefix("analytics"), "buckets/.deleted/analytics");
+140 -5
View File
@@ -368,7 +368,7 @@ impl ECStore {
Some(expected) => expected,
None => self.bucket_incarnation_id_from_disk(bucket).await?,
};
let object = object.map(str::to_owned);
let object = object.map(encode_dir_object);
let prefix = prefix.map(str::to_owned);
self.run_bucket_heal_at_incarnation(bucket, expected, opts, |store, bucket, opts| async move {
let scope = bucket_heal_scope(&bucket).ok_or(StorageError::PreconditionFailed)?;
@@ -430,7 +430,7 @@ impl ECStore {
HealItemType::Bucket
}
.to_string(),
object: object.or(prefix).unwrap_or_default(),
object: object.map(|object| decode_dir_object(&object)).or(prefix).unwrap_or_default(),
bucket,
detail: format!(
"delete recovery{}: discovered={}, committed={}, rolled_back={}, reclaimed={}, blocked={}, pending={}, failed={}",
@@ -656,12 +656,48 @@ impl ECStore {
Ok(Arc::ptr_eq(&target_set, &pool.get_disks_by_key(POOL_META_NAME)))
}
/// Read the published bucket identity without fabricating or migrating metadata.
/// Absence is distinct from quorum, corruption and transaction-fence failures.
pub async fn replacement_bucket_incarnation_from_disk(self: &Arc<Self>, bucket: &str) -> Result<Option<uuid::Uuid>> {
let transaction =
crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket).await?;
let incarnation = crate::bucket::metadata::load_bucket_incarnation(self.clone(), bucket).await?;
if transaction.is_lock_lost() {
return Err(Error::PreconditionFailed);
}
Ok(incarnation)
}
/// Restore the bucket configuration and incarnation before retiring a replacement marker.
pub async fn heal_replacement_bucket_metadata(
self: &Arc<Self>,
bucket: &str,
opts: &HealOpts,
targets: &[String],
) -> Result<()> {
// Reject an invalid request before touching the drives so callers keep
// the PreconditionFailed contract for bad buckets and options.
if opts.pool.is_none()
|| opts.set.is_none()
|| targets.is_empty()
|| opts.dry_run
|| opts.no_lock
|| check_valid_bucket_name_strict(bucket).is_err()
{
return Err(Error::PreconditionFailed);
}
let expected = self.bucket_incarnation_id_from_disk(bucket).await?;
self.heal_replacement_bucket_metadata_at_incarnation(bucket, expected, opts, targets)
.await
}
/// Keep a replacement pass bound to its admitted bucket through metadata commit.
pub async fn heal_replacement_bucket_metadata_at_incarnation(
self: &Arc<Self>,
bucket: &str,
expected: uuid::Uuid,
opts: &HealOpts,
targets: &[String],
) -> Result<()> {
use crate::bucket::metadata::{BUCKET_INCARNATION_FILE, BUCKET_METADATA_FILE};
use crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_read_lock_in;
@@ -698,9 +734,8 @@ impl ECStore {
// Read the persisted identity without lazy migration: a replacement
// must never turn lost configuration into newly fabricated defaults.
let incarnation = self.bucket_incarnation_id_from_disk(bucket).await?;
let targets = targets.to_vec();
self.run_bucket_heal_at_incarnation(bucket, incarnation, opts, move |store, bucket, opts| async move {
self.run_bucket_heal_at_incarnation(bucket, expected, opts, move |store, bucket, opts| async move {
// Match config writers: lifecycle, metadata transaction, object locks.
// Keep the transaction stable through target readback, including when
// the caller is cancelled and the storage owner finishes its write.
@@ -1022,6 +1057,7 @@ impl ECStore {
set_index: usize,
targets: &[String],
) -> Result<bool> {
let object = encode_dir_object(object);
let pool = self
.pools
.get(pool_index)
@@ -1034,7 +1070,7 @@ impl ECStore {
)
})?;
set.replacement_targets_have_version(bucket, object, version_id, targets)
set.replacement_targets_have_version(bucket, &object, version_id, targets)
.await
.map_err(Into::into)
}
@@ -2859,6 +2895,105 @@ mod tests {
}
}
#[tokio::test]
#[serial_test::serial]
async fn replacement_directory_object_readback_and_exact_recovery_use_encoded_key() {
let (_root, store, shutdown) = heal_store_with_pool_count(1).await;
let bucket = "directory-replacement-readback";
let object = "folder/";
let encoded = encode_dir_object(object);
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
store
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(Vec::new()),
&ObjectOptions {
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("write directory object through the public store entry");
let set = &store.pools[0].disk_set[0];
let targets = vec![set.set_endpoints[0].to_string()];
assert!(
store
.replacement_targets_have_version(bucket, object, "", 0, 0, &targets)
.await
.expect("readback")
);
let disk = set.disks.read().await[0].clone().expect("target disk");
disk.delete(
bucket,
&encoded,
DeleteOptions {
recursive: true,
immediate: true,
..Default::default()
},
)
.await
.expect("remove target directory shard");
assert!(
!store
.replacement_targets_have_version(bucket, object, "", 0, 0, &targets)
.await
.expect("missing target")
);
let opts = HealOpts {
pool: Some(0),
set: Some(0),
..Default::default()
};
let (result, error) = store
.heal_object(bucket, object, "", &opts)
.await
.expect("heal directory object");
assert!(error.is_none(), "heal failed: {error:?}");
assert_eq!(result.object, object);
assert!(
store
.replacement_targets_have_version(bucket, object, "", 0, 0, &targets)
.await
.expect("healed readback")
);
assert!(
store
.replacement_targets_have_version(bucket, &encoded, "", 0, 0, &targets)
.await
.expect("encoded readback")
);
// Exact-object recovery must inspect the same physical namespace and
// reject corrupt proof rather than silently report no transactions.
let metadata = disk
.read_all(bucket, &format!("{encoded}/xl.meta"))
.await
.expect("healed metadata");
disk.write_all(
bucket,
&format!("{encoded}/{}/delete-prepared", Uuid::new_v4()),
b"invalid proof".to_vec().into(),
)
.await
.expect("seed corrupt recovery proof");
store
.recover_delete_transactions_at_incarnation(bucket, Some(object), None, &HealOpts { dry_run: true, ..opts })
.await
.expect_err("directory recovery must discover and reject the corrupt proof");
assert_eq!(
disk.read_all(bucket, &format!("{encoded}/xl.meta"))
.await
.expect("unchanged metadata"),
metadata
);
shutdown.cancel();
}
#[tokio::test]
async fn replacement_bucket_metadata_respects_pool_ownership_and_required_records() {
let (root, store, shutdown) = multi_pool_heal_store().await;
+310 -278
View File
@@ -540,6 +540,7 @@ impl ECStore {
// failures can still spawn recovery probes for peers that come up
// after this node.
let (mut disks, errs) = init_format::init_disks(
&instance_ctx,
&pool_eps.endpoints,
&DiskOption {
cleanup: true,
@@ -5155,6 +5156,11 @@ mod tests {
let offline_disk = force_set_disk_range_offline_for_test(set, 0..1).await;
let _without_proof = crate::services::notification_sys::without_cross_pool_fence_fleet_proof_for_test();
assert!(crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof().is_none());
if !sharded {
crate::bucket::quota::reservation::initialize_quota_ledger_for_test(Arc::clone(&store), &bucket)
.await
.expect("bootstrap quota with one disk offline");
}
let payload = vec![0x51; 4096];
let mut reader = PutObjReader::from_vec(payload.clone());
@@ -21007,6 +21013,310 @@ mod tests {
);
}
#[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:?}");
let removed = store
.get_object_info(
&bucket,
&object,
&ObjectOptions {
version_id: Some(Uuid::nil().to_string()),
..Default::default()
},
)
.await;
match removed {
Ok(marker) => {
assert!(marker.delete_marker, "only a null delete marker may replace the payload");
assert_eq!(marker.size, 0);
assert!(marker.data_dir.is_none());
}
Err(error) => assert!(
matches!(
error,
StorageError::MethodNotAllowed
| StorageError::ObjectNotFound(_, _)
| StorageError::VersionNotFound(_, _, _)
),
"unexpected bypass result: {error:?}"
),
}
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;
}
}
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn exact_version_put_uses_only_its_own_bucket_object_lock_snapshot() {
@@ -27352,282 +27662,4 @@ mod tests {
.expect("recovery must preserve the local source object");
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;
}
}
}
}
+109 -2
View File
@@ -44,12 +44,32 @@ const LEGACY_FORMAT_READ_CONCURRENCY: usize = 16;
const LEGACY_FORMAT_BASE_MAX_BYTES: usize = 16 * 1024;
const LEGACY_FORMAT_MAX_BYTES_PER_DISK: usize = 64;
pub async fn init_disks(eps: &Endpoints, opt: &DiskOption) -> (Vec<Option<DiskStore>>, Vec<Option<DiskError>>) {
pub async fn init_disks(
instance_ctx: &InstanceContext,
eps: &Endpoints,
opt: &DiskOption,
) -> (Vec<Option<DiskStore>>, Vec<Option<DiskError>>) {
let mut futures = Vec::with_capacity(eps.as_ref().len());
let local_disk_map = instance_ctx.local_disk_map();
let local_disks = local_disk_map.read().await;
for ep in eps.as_ref().iter() {
futures.push(new_disk(ep, opt));
// Foundation disks may already serve RPC writes. Opening them again
// with startup cleanup can move live staging out from under a writer.
let registered = if ep.is_local {
local_disks.get(&ep.to_string()).cloned().flatten()
} else {
None
};
futures.push(async move {
match registered {
Some(disk) if disk.endpoint() == *ep => Ok(disk),
Some(_) => Err(DiskError::InconsistentDisk),
None => new_disk(ep, opt).await,
}
});
}
drop(local_disks);
let mut res = Vec::with_capacity(eps.as_ref().len());
let mut errors = Vec::with_capacity(eps.as_ref().len());
@@ -1030,6 +1050,7 @@ mod tests {
}
let (disks, errors) = init_disks(
&InstanceContext::new(),
&Endpoints::from(endpoints),
&DiskOption {
cleanup: false,
@@ -1049,6 +1070,91 @@ mod tests {
(temp_dir, disks)
}
#[tokio::test]
async fn init_disks_preserves_live_staging_on_registered_local_disks() {
let (temp_dir, disks) = local_disks(1).await;
let registered = disks[0].as_ref().expect("registered disk should exist");
let instance_ctx = Arc::new(InstanceContext::new());
record_local_disks(&instance_ctx, vec![registered.clone()]).await;
let staging = temp_dir.path().join("disk-0/.rustfs.sys/tmp/in-flight/xl.meta");
tokio::fs::create_dir_all(staging.parent().expect("staging has a parent"))
.await
.expect("create staging directory");
tokio::fs::write(&staging, b"in-flight pool metadata")
.await
.expect("stage metadata through the registered disk");
let (initialized, errors) = init_disks(
&instance_ctx,
&Endpoints::from(vec![registered.endpoint()]),
&DiskOption {
cleanup: true,
health_check: false,
},
)
.await;
assert!(errors.iter().all(Option::is_none), "disk initialization failed: {errors:?}");
assert_eq!(
tokio::fs::read(&staging)
.await
.expect("startup must preserve live RPC staging"),
b"in-flight pool metadata"
);
assert!(Arc::ptr_eq(registered, initialized[0].as_ref().expect("initialized disk should exist")));
}
#[tokio::test]
async fn init_disks_cleans_stale_staging_without_a_registered_disk() {
let temp_dir = tempfile::tempdir().expect("create disk root");
let endpoint =
Endpoint::try_from(temp_dir.path().to_str().expect("disk root should be UTF-8")).expect("endpoint should parse");
let staging = temp_dir.path().join(".rustfs.sys/tmp/stale/xl.meta");
tokio::fs::create_dir_all(staging.parent().expect("staging has a parent"))
.await
.expect("create stale staging directory");
tokio::fs::write(&staging, b"stale pool metadata")
.await
.expect("write stale staging");
let (disks, errors) = init_disks(
&InstanceContext::new(),
&Endpoints::from(vec![endpoint]),
&DiskOption {
cleanup: true,
health_check: false,
},
)
.await;
assert!(errors.iter().all(Option::is_none), "disk initialization failed: {errors:?}");
assert!(disks[0].is_some());
assert!(!staging.exists(), "a fresh disk open must still clear stale staging");
}
#[tokio::test]
async fn init_disks_rejects_registered_disk_topology_mismatch() {
let (_temp_dir, disks) = local_disks(1).await;
let registered = disks[0].as_ref().expect("registered disk should exist");
let instance_ctx = Arc::new(InstanceContext::new());
record_local_disks(&instance_ctx, vec![registered.clone()]).await;
let mut endpoint = registered.endpoint();
endpoint.set_pool_index(1);
let (initialized, errors) = init_disks(
&instance_ctx,
&Endpoints::from(vec![endpoint]),
&DiskOption {
cleanup: true,
health_check: false,
},
)
.await;
assert!(initialized[0].is_none(), "a registered disk must not be reopened in a different slot");
assert_eq!(errors[0], Some(DiskError::InconsistentDisk));
}
async fn format_bytes(disks: &[Option<DiskStore>]) -> Vec<Option<Vec<u8>>> {
let mut snapshots = Vec::with_capacity(disks.len());
for disk in disks {
@@ -1758,6 +1864,7 @@ mod tests {
disk.set_disk_id(None).await.expect("test disk ID should be reset");
}
let (prewarm_disks, prewarm_errors) = init_disks(
&InstanceContext::new(),
&prewarm_endpoints,
&DiskOption {
cleanup: false,
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -423,8 +423,6 @@ fn should_enqueue_transition_immediately(oi: &ObjectInfo) -> bool {
!is_meta_bucketname(&oi.bucket)
}
const MAX_UPLOADS_LIST: usize = 10000;
mod bucket;
mod bucket_fence;
pub(crate) use bucket::await_bucket_namespace_operation;
@@ -3295,6 +3293,7 @@ mod tests {
init_local_disks(endpoint_pools.clone()).await.expect("init local disks");
let (mut disks, errs) = init_disks(
&crate::runtime::global::current_ctx(),
&endpoint_pools.as_ref().first().expect("pool endpoints").endpoints,
&DiskOption {
cleanup: true,
+395 -15
View File
@@ -273,6 +273,28 @@ async fn pause_data_movement_multipart_before_selected_completion(bucket: &str)
}
}
async fn pool_has_multipart_upload_for_key(
pool: &crate::core::sets::Sets,
bucket: &str,
object: &str,
expected_incarnation_id: Option<Uuid>,
) -> Result<bool> {
let results = stream::iter(pool.disk_set.iter().cloned())
.map(|set| async move {
set.has_multipart_upload_for_key(bucket, object, expected_incarnation_id)
.await
})
.buffer_unordered(MULTIPART_LIST_SET_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut found = false;
for result in results {
// Preserve errors from every set instead of hiding them behind a hit.
found |= result?;
}
Ok(found)
}
async fn list_pool_multipart_uploads_for_incarnation(
pool: &crate::core::sets::Sets,
bucket: &str,
@@ -490,6 +512,8 @@ impl ECStore {
opts: &ObjectOptions,
) -> Result<ListPartsInfo> {
check_list_parts_args(bucket, object, upload_id)?;
let encoded_object = encode_dir_object_ref(object);
let object = encoded_object.as_ref();
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
let opts = &opts;
@@ -620,6 +644,8 @@ impl ECStore {
mutation_fence: Option<&ObjectLockDiagGuard>,
) -> Result<(MultipartUploadResult, usize, Option<Uuid>)> {
check_new_object_args(bucket, object, opts.data_movement)?;
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?;
if self.single_pool() {
@@ -675,21 +701,7 @@ impl ECStore {
if self.is_suspended(idx).await || self.is_pool_rebalancing(idx).await {
continue;
}
let res = list_pool_multipart_uploads_for_incarnation(
pool,
bucket,
&MultipartUploadListRequest {
prefix: object.to_string(),
key_marker: None,
upload_id_marker: None,
delimiter: None,
max_uploads: MAX_UPLOADS_LIST,
expected_incarnation_id: opts.expected_bucket_incarnation_id,
},
)
.await?;
if !res.uploads.is_empty() {
if pool_has_multipart_upload_for_key(pool, bucket, object, opts.expected_bucket_incarnation_id).await? {
self.apply_decommission_target_mutation_fence(idx, object, &mut opts, mutation_fence)
.await;
let expected_bucket_incarnation_id = opts.expected_bucket_incarnation_id;
@@ -772,6 +784,8 @@ impl ECStore {
opts: &ObjectOptions,
) -> Result<PartInfo> {
check_put_object_part_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?;
if let Some(guard) = bucket_lifecycle_guard {
opts.bucket_lifecycle_lock_owner = Some(Arc::new(guard));
@@ -806,6 +820,8 @@ impl ECStore {
.part_number
.ok_or_else(|| Error::other("targeted multipart upload requires a part number"))?;
check_put_object_part_args(bucket, object, upload_id)?;
let encoded_object = encode_dir_object_ref(object);
let object = encoded_object.as_ref();
if !opts.data_movement {
return Err(Error::other("targeted multipart upload requires data_movement options"));
}
@@ -838,6 +854,8 @@ impl ECStore {
opts: &ObjectOptions,
) -> Result<MultipartInfo> {
check_list_parts_args(bucket, object, upload_id)?;
let encoded_object = encode_dir_object_ref(object);
let object = encoded_object.as_ref();
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
let opts = &opts;
if self.single_pool() {
@@ -871,6 +889,8 @@ impl ECStore {
opts: &ObjectOptions,
) -> Result<()> {
check_abort_multipart_args(bucket, object, upload_id)?;
let encoded_object = encode_dir_object_ref(object);
let object = encoded_object.as_ref();
let (opts, bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
for pool_idx in self.existing_multipart_pool_order().await {
@@ -1052,6 +1072,8 @@ impl ECStore {
for upload_id in upload_ids {
check_abort_multipart_args(bucket, object, upload_id)?;
}
let encoded_object = encode_dir_object_ref(object);
let object = encoded_object.as_ref();
if !opts.data_movement {
return Err(Error::other("targeted multipart abort requires data_movement options"));
}
@@ -1346,6 +1368,8 @@ impl ECStore {
let request_opts = opts;
let (target_pool_idx, mutation_fence) = target;
check_complete_multipart_args(bucket, object, upload_id)?;
let encoded_object = encode_dir_object_ref(object);
let object = encoded_object.as_ref();
if !opts.data_movement {
return Err(Error::other("targeted multipart completion requires data_movement options"));
}
@@ -1467,6 +1491,362 @@ mod tests {
};
use std::time::Duration;
#[tokio::test]
async fn create_multipart_pool_selection_uses_only_an_exact_key_upload() {
use crate::bucket::metadata_sys::init_bucket_metadata_sys;
use crate::runtime::instance::InstanceContext;
let mut dirs = Vec::new();
let mut pools = Vec::new();
for pool_index in 0..2 {
let mut endpoints = Vec::new();
for disk_index in 0..4 {
let directory = tempfile::tempdir().expect("temporary disk");
let mut endpoint = Endpoint::try_from(directory.path().to_str().expect("disk path")).expect("local endpoint");
endpoint.set_pool_index(pool_index);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_index);
dirs.push(directory);
endpoints.push(endpoint);
}
pools.push(PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 4,
endpoints: Endpoints::from(endpoints),
cmd_line: format!("exact-multipart-pool-{pool_index}"),
platform: "test".to_owned(),
});
}
let endpoints = EndpointServerPools(pools);
let ctx = Arc::new(InstanceContext::new());
crate::store::init_local_disks_with_instance_ctx(&ctx, endpoints.clone())
.await
.expect("initialize local disks");
let cancel = tokio_util::sync::CancellationToken::new();
let store = ECStore::new_with_instance_ctx("127.0.0.1:0".parse().expect("test address"), endpoints, cancel.clone(), ctx)
.await
.expect("initialize two pools");
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = "exact-pool-selection";
let other_bucket = "other-pool-selection";
for bucket in [bucket, other_bucket] {
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
}
for (upload_bucket, key) in [(bucket, "report-long"), (bucket, "report/"), (other_bucket, "report")] {
let (opts, _guard) = store
.guard_multipart_bucket_incarnation(upload_bucket, &ObjectOptions::default())
.await
.expect("pin upload incarnation");
store.pools[0]
.new_multipart_upload(upload_bucket, &encode_dir_object(key), &opts)
.await
.expect("seed nonmatching upload in first pool");
}
let (opts, _guard) = store
.guard_multipart_bucket_incarnation(bucket, &ObjectOptions::default())
.await
.expect("pin target incarnation");
assert!(
!pool_has_multipart_upload_for_key(&store.pools[0], bucket, "report", opts.expected_bucket_incarnation_id)
.await
.expect("first pool contains only other keys")
);
store.pools[1]
.new_multipart_upload(bucket, "report", &opts)
.await
.expect("seed exact upload in second pool");
let (created, pool_index, incarnation) = store
.handle_new_multipart_upload_with_pool_idx(bucket, "report", &ObjectOptions::default(), None)
.await
.expect("create another upload for the exact key");
assert_eq!(pool_index, 1, "prefix matches in the first pool must not change placement");
assert_eq!(incarnation, opts.expected_bucket_incarnation_id);
assert!(!created.upload_id.is_empty());
assert!(
!pool_has_multipart_upload_for_key(&store.pools[0], bucket, "report", incarnation)
.await
.expect("new upload must not appear in first pool")
);
assert!(
!pool_has_multipart_upload_for_key(&store.pools[1], bucket, "report", Some(Uuid::new_v4()))
.await
.expect("old bucket incarnations must not affect placement")
);
cancel.cancel();
}
#[tokio::test]
async fn multipart_directory_keys_preserve_the_bare_object_and_round_trip() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::object::ObjectIO as _;
use sha2::Digest;
use tokio::io::AsyncReadExt;
let (dirs, store) = isolated_store_over_temp_disks().await;
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
for versioned in [false, true] {
let bucket = if versioned {
"directory-mpu-versioned"
} else {
"directory-mpu-unversioned"
};
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
if versioned {
store
.update_bucket_metadata_config(
bucket,
"versioning.xml",
br#"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"#.to_vec(),
)
.await
.expect("enable versioning");
}
let opts = ObjectOptions {
versioned,
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
};
let original = store
.put_object(
bucket,
"protected",
&mut PutObjReader::from_vec(b"private original".to_vec()),
&ObjectOptions {
user_defined: HashMap::from([(
rustfs_utils::http::headers::AMZ_OBJECT_TAGGING.to_string(),
"classification=secret".to_string(),
)]),
..opts.clone()
},
)
.await
.expect("write protected bare object");
let upload = store
.new_multipart_upload(bucket, "protected/", &opts)
.await
.expect("create directory upload");
let part = store
.put_object_part(
bucket,
"protected/",
&upload.upload_id,
1,
&mut PutObjReader::from_vec(b"directory payload".to_vec()),
&opts,
)
.await
.expect("write directory part");
store
.put_object_part(
bucket,
"protected/",
&upload.upload_id,
2,
&mut PutObjReader::from_vec(b"unselected private bytes".to_vec()),
&opts,
)
.await
.expect("write an unselected part");
let upload_sha = hex_simd::encode_to_string(
sha2::Sha256::digest(format!("{bucket}/protected__XLDIR__").as_bytes()),
hex_simd::AsciiCase::Lower,
);
let upload_uuid = crate::runtime::sources::upload_uuid_suffix(&upload.upload_id);
for dir in &dirs {
let staging = dir
.path()
.join(crate::disk::RUSTFS_META_MULTIPART_BUCKET)
.join(&upload_sha)
.join(&upload_uuid);
let metadata = rustfs_filemeta::FileMeta::load(&std::fs::read(staging.join("xl.meta")).expect("upload metadata"))
.expect("decode upload metadata");
let versions = metadata
.get_file_info_versions(bucket, "protected__XLDIR__", true)
.expect("upload file info");
let data_dir = versions
.versions
.first()
.expect("upload version")
.data_dir
.expect("staging data directory");
assert!(
staging.join(data_dir.to_string()).join("part.2").is_file(),
"unselected part exists before completion"
);
}
assert_eq!(
store
.get_multipart_info(bucket, "protected/", &upload.upload_id, &opts)
.await
.expect("upload info")
.object,
"protected/"
);
assert_eq!(
store
.list_object_parts(bucket, "protected/", &upload.upload_id, None, 10, &opts)
.await
.expect("list parts")
.object,
"protected/"
);
let uploads = store
.list_multipart_uploads(bucket, "protected/", None, None, None, 10)
.await
.expect("list logical prefix");
assert_eq!(uploads.uploads.len(), 1);
assert_eq!(uploads.uploads[0].object, "protected/");
assert_eq!(crate::runtime::sources::upload_uuid_suffix(&uploads.uploads[0].upload_id), upload_uuid,);
let completed = store
.clone()
.complete_multipart_upload(
bucket,
"protected/",
&upload.upload_id,
vec![CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
}],
&opts,
)
.await
.expect("complete directory upload");
assert_eq!(completed.name, "protected/");
for (key, expected) in [
("protected", b"private original".as_slice()),
("protected/", b"directory payload".as_slice()),
] {
let mut reader = store
.get_object_reader(bucket, key, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("read distinct object");
let mut bytes = Vec::new();
reader.stream.read_to_end(&mut bytes).await.expect("read body");
assert_eq!(bytes, expected);
}
let preserved = store
.get_object_info(bucket, "protected", &ObjectOptions::default())
.await
.expect("bare metadata");
assert_eq!(preserved.version_id, original.version_id);
assert_eq!(preserved.etag, original.etag);
assert_eq!(preserved.user_tags.as_str(), "classification=secret");
for dir in &dirs {
assert!(dir.path().join(bucket).join("protected/xl.meta").is_file());
assert!(dir.path().join(bucket).join("protected__XLDIR__/xl.meta").is_file());
let data = dir
.path()
.join(bucket)
.join("protected__XLDIR__")
.join(completed.data_dir.expect("multipart data directory").to_string());
assert!(data.join("part.1").is_file());
assert!(!data.join("part.2").exists(), "unselected part must never be published");
assert!(
!dir.path()
.join(crate::disk::RUSTFS_META_MULTIPART_BUCKET)
.join(&upload_sha)
.join(&upload_uuid)
.exists(),
"original staging must be cleaned"
);
}
let abandoned = store
.new_multipart_upload(bucket, "protected/", &opts)
.await
.expect("create abort control");
store
.abort_multipart_upload(bucket, "protected/", &abandoned.upload_id, &opts)
.await
.expect("abort encoded upload");
assert!(
store
.get_multipart_info(bucket, "protected/", &abandoned.upload_id, &opts)
.await
.is_err()
);
assert!(
store
.get_object_info(bucket, "protected/", &ObjectOptions::default())
.await
.is_ok()
);
}
}
#[cfg(not(windows))]
#[tokio::test]
async fn new_control_character_keys_are_rejected_without_trapping_legacy_objects() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::object::ObjectIO as _;
use tokio::io::AsyncReadExt;
let (dirs, store) = isolated_store_over_temp_disks().await;
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = "xml-key-admission";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
for byte in (1u8..32).filter(|byte| !matches!(byte, 9 | 10 | 13)) {
let key = format!("bad-{}", char::from(byte));
assert!(
store
.put_object(bucket, &key, &mut PutObjReader::from_vec(b"body".to_vec()), &ObjectOptions::default())
.await
.is_err()
);
assert!(
store
.new_multipart_upload(bucket, &key, &ObjectOptions::default())
.await
.is_err()
);
for dir in &dirs {
assert!(!dir.path().join(bucket).join(&key).exists());
}
}
let legacy_key = "legacy-\u{1}";
store.pools[0]
.get_disks_by_key(legacy_key)
.put_object(
bucket,
legacy_key,
&mut PutObjReader::from_vec(b"legacy payload".to_vec()),
&ObjectOptions {
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("seed a pre-upgrade object below new-key admission");
let mut reader = store
.get_object_reader(bucket, legacy_key, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("legacy GET stays usable");
let mut bytes = Vec::new();
reader.stream.read_to_end(&mut bytes).await.expect("legacy bytes");
assert_eq!(bytes, b"legacy payload");
drop(reader);
store
.delete_object(bucket, legacy_key, ObjectOptions::default())
.await
.expect("legacy DELETE stays usable");
assert!(
store
.get_object_info(bucket, legacy_key, &ObjectOptions::default())
.await
.is_err()
);
}
fn mp(object: &str, upload_id: &str) -> MultipartInfo {
MultipartInfo {
bucket: "bucket".to_string(),
+417 -148
View File
@@ -522,16 +522,29 @@ async fn pause_prefix_delete_after_journal_prepare(bucket: &str, prefix: &str) {
}
}
async fn delete_prefix_with_tier_delete_journal(
// Construct only the selected operation off the caller's poll frame. A plain prefix
// delete must not carry the journal state machine into the nested per-disk delete.
#[inline(never)]
fn delete_prefix_with_tier_delete_journal<'a>(
store: &'a ECStore,
bucket: &'a str,
object: &'a str,
opts: &'a ObjectOptions,
tier_journal_api: Option<&'a Arc<ECStore>>,
) -> futures::future::BoxFuture<'a, Result<()>> {
match tier_journal_api {
Some(api) => Box::pin(delete_prefix_with_active_tier_journal(store, bucket, object, opts, api)),
None => Box::pin(store.delete_prefix(bucket, object, opts)),
}
}
async fn delete_prefix_with_active_tier_journal(
store: &ECStore,
bucket: &str,
object: &str,
opts: &ObjectOptions,
tier_journal_api: Option<&Arc<ECStore>>,
api: &Arc<ECStore>,
) -> Result<()> {
let Some(api) = tier_journal_api else {
return store.delete_prefix(bucket, object, opts).await;
};
let PreparedPrefixTierDelete {
dispatch,
chunk_parent_active,
@@ -1385,7 +1398,7 @@ impl Drop for ObjectLockDiagGuard {
}
/// A failure to preserve one object generation for a SelectObjectContent read.
#[derive(Debug, thiserror::Error)]
#[derive(Debug, Clone, Copy, thiserror::Error)]
#[non_exhaustive]
pub enum SnapshotConsistencyError {
#[error("namespace locking is disabled for SelectObjectContent")]
@@ -1499,9 +1512,11 @@ impl SelectObjectSnapshotLease {
}
}
/// Opaque, lock-backed object generation used by SelectObjectContent.
/// Opaque object generation used by SelectObjectContent. Each range reacquires
/// short namespace read fences and rejects changed read semantics before opening
/// its generation-bound reader. An idle snapshot never blocks object mutations.
pub struct SelectObjectSnapshot {
pool: Arc<Sets>,
store: Arc<ECStore>,
bucket: String,
object: String,
headers: HeaderMap,
@@ -1509,7 +1524,7 @@ pub struct SelectObjectSnapshot {
object_info: ObjectInfo,
logical_size: u64,
read_semantics_identity: [u8; 32],
first_metadata: ParkingMutex<Option<crate::set_disk::PreparedGetObjectMetadata>>,
failure: ParkingMutex<Option<SnapshotConsistencyError>>,
lease: Arc<SelectObjectSnapshotLease>,
}
@@ -1542,6 +1557,9 @@ impl SelectObjectSnapshot {
}
pub fn ensure_valid(&self) -> std::result::Result<(), SnapshotConsistencyError> {
if let Some(failure) = *self.failure.lock() {
return Err(failure);
}
self.lease.check()
}
@@ -1555,38 +1573,72 @@ impl SelectObjectSnapshot {
&self,
range: Option<HTTPRangeSpec>,
) -> std::result::Result<GetObjectReader, SelectObjectSnapshotReadError> {
self.lease.check()?;
let first_metadata = self.first_metadata.lock().take();
let metadata = match first_metadata {
Some(metadata) => metadata,
None => {
self.pool
.prepare_get_object_reader_metadata(&self.bucket, &self.object, &self.opts)
.await?
}
};
self.lease.check()?;
let result = self.open_generation_reader(range).await;
if let Err(SelectObjectSnapshotReadError::Consistency(failure)) = &result {
*self.failure.lock() = Some(*failure);
}
result
}
async fn open_generation_reader(
&self,
range: Option<HTTPRangeSpec>,
) -> std::result::Result<GetObjectReader, SelectObjectSnapshotReadError> {
self.ensure_valid()?;
if self.store.ctx.lock_manager().is_disabled() {
return Err(SnapshotConsistencyError::LockingDisabled.into());
}
let mut opts = self.opts.clone();
opts.no_lock = false;
let guards = self
.store
.acquire_all_object_read_locks("select_object_range", &self.bucket, &self.object, &mut opts)
.await?;
let pin_lease = SelectObjectSnapshotLease::new(guards);
pin_lease.check()?;
#[cfg(test)]
tests::pause_select_range_after_lock(&self.bucket, &self.object).await;
// Re-select across all pools, including on the first range. Metadata
// captured during prepare is no longer protected after that call returns.
let (metadata, pool) = self
.store
.prepare_select_object_metadata(&self.bucket, &self.object, &opts)
.await
.map_err(|error| {
if is_err_object_not_found(&error)
|| is_err_version_not_found(&error)
|| matches!(&error, StorageError::BucketNotFound(_) | StorageError::VolumeNotFound)
{
SelectObjectSnapshotReadError::Consistency(SnapshotConsistencyError::ObjectChanged)
} else {
SelectObjectSnapshotReadError::Storage(error)
}
})?;
pin_lease.check()?;
if metadata.read_semantics_identity() != self.read_semantics_identity {
return Err(SnapshotConsistencyError::ObjectChanged.into());
}
let mut reader =
crate::object_api::without_get_object_body_cache_hook(self.pool.get_object_reader_with_prepared_metadata(
&self.bucket,
&self.object,
range,
self.headers.clone(),
&self.opts,
metadata,
))
.await?;
self.lease.check()?;
let mut reader = crate::object_api::without_get_object_body_cache_hook(pool.get_object_reader_with_prepared_metadata(
&self.bucket,
&self.object,
range,
self.headers.clone(),
&opts,
metadata,
))
.await?;
pin_lease.check()?;
self.ensure_valid()?;
reader.body_source = crate::object_api::GetObjectBodySource::HookMissed;
reader.stream = Box::new(SelectObjectSnapshotReader {
inner: reader.stream,
lock_loss_wake: SelectObjectSnapshotLockLossWake::new(self.lease.subscribe_lock_loss()),
lease: Arc::clone(&self.lease),
});
// Streaming/lazy readers retain old FileInfo, inline bytes and data_dir
// paths, never a current-key lookup. Cleanup can fail the read, but may
// not select replacement bytes. This is independent of GET's lock knob.
drop(pin_lease);
Ok(reader)
}
}
@@ -3183,7 +3235,7 @@ impl ECStore {
)))
}
async fn object_lock_config_snapshot_for_single_put(&self, bucket: &str) -> Result<Arc<ObjectLockConfigSnapshot>> {
pub(crate) async fn object_lock_config_snapshot_for_single_put(&self, bucket: &str) -> Result<Arc<ObjectLockConfigSnapshot>> {
check_valid_bucket_name(bucket)?;
let lifecycle_guard = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
let metadata_guard = acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket).await?;
@@ -3232,15 +3284,48 @@ impl ECStore {
)))
}
/// Resolves a GET's object identity without constructing its body reader.
///
/// This is an additive two-stage counterpart to `get_object_reader`. The
/// existing method remains the compatibility path for callers that do not
/// need a pre-reader decision point.
pub async fn prepare_select_object_snapshot(
/// Selects authoritative metadata across all pools under the caller's locks.
async fn prepare_select_object_metadata(
&self,
bucket: &str,
object: &str,
opts: &ObjectOptions,
) -> Result<(crate::set_disk::PreparedGetObjectMetadata, Arc<Sets>)> {
// Acquiring the namespace locks marks ordinary GETs cache-safe. A
// SELECT generation check must instead consult the disks: another node
// (or another SetDisks owner) can commit without invalidating this cache.
let mut opts = opts.clone();
opts.metadata_cache_safe = false;
Ok(if self.single_pool() {
let pool = Arc::clone(&self.pools[0]);
let metadata = pool.prepare_get_object_reader_metadata(bucket, object, &opts).await?;
(metadata, pool)
} else {
// Keep the large multi-pool selection future off the caller stack.
// Debug builds otherwise exceed the common 2 MiB worker stack.
Box::pin(async {
let (metadata, pool_idx) = self.prepare_latest_object_metadata_with_idx(bucket, object, &opts).await?;
let pool = self.pools.get(pool_idx).cloned().ok_or_else(|| {
StorageError::other(format!("resolved SelectObjectContent pool index {pool_idx} is out of bounds"))
})?;
Ok::<_, StorageError>((metadata, pool))
})
.await?
})
}
/// Captures the initial generation for a SelectObjectContent query under short namespace locks.
///
/// Each range, including the first, reacquires these locks, revalidates the
/// initial read semantics, and constructs a generation-bound reader before
/// releasing them. Concurrent mutation may cause a consistency or storage
/// error; callers must propagate it instead of reporting successful query completion.
/// Cancelling preparation or a range open drops its guards. Returned snapshots
/// and readers never retain namespace locks while waiting for client input.
pub async fn prepare_select_object_snapshot(
self: &Arc<Self>,
bucket: &str,
object: &str,
headers: &HeaderMap,
opts: &ObjectOptions,
) -> std::result::Result<SelectObjectSnapshot, PrepareSelectObjectSnapshotError> {
@@ -3260,22 +3345,7 @@ impl ECStore {
return Err(SnapshotConsistencyError::LockLost.into());
}
let (mut metadata, pool) = if self.single_pool() {
let pool = Arc::clone(&self.pools[0]);
let metadata = pool.prepare_get_object_reader_metadata(bucket, &object, &opts).await?;
(metadata, pool)
} else {
// Keep the large multi-pool selection future off the caller stack.
// Debug builds otherwise exceed the common 2 MiB worker stack.
Box::pin(async {
let (metadata, pool_idx) = self.prepare_latest_object_metadata_with_idx(bucket, &object, &opts).await?;
let pool = self.pools.get(pool_idx).cloned().ok_or_else(|| {
StorageError::other(format!("resolved SelectObjectContent pool index {pool_idx} is out of bounds"))
})?;
Ok::<_, StorageError>((metadata, pool))
})
.await?
};
let (mut metadata, _pool) = self.prepare_select_object_metadata(bucket, &object, &opts).await?;
if read_lock_guards.iter().any(ObjectLockDiagGuard::is_lock_lost) {
return Err(SnapshotConsistencyError::LockLost.into());
}
@@ -3292,8 +3362,12 @@ impl ECStore {
return Err(SnapshotConsistencyError::LockLost.into());
}
// Snapshot identity survives, but preparation metadata and all namespace
// fences are released before a caller can wait on client backpressure.
drop(metadata);
drop(read_lock_guards);
Ok(SelectObjectSnapshot {
pool,
store: Arc::clone(self),
bucket: bucket.to_owned(),
object,
headers: rustfs_utils::http::project_ssec_transport_headers(headers),
@@ -3301,8 +3375,8 @@ impl ECStore {
object_info,
logical_size,
read_semantics_identity,
first_metadata: ParkingMutex::new(Some(metadata)),
lease: Arc::new(SelectObjectSnapshotLease::new(read_lock_guards)),
failure: ParkingMutex::new(None),
lease: Arc::new(SelectObjectSnapshotLease::new(Vec::new())),
})
}
@@ -4709,6 +4783,12 @@ impl ECStore {
opts.expected_bucket_incarnation_id = Some(self.bucket_incarnation_id(bucket).await?);
}
if opts.overwrites_existing_version() && !is_meta_bucketname(bucket) {
if opts.object_lock_config_snapshot.is_none() && !opts.no_lock && !opts.data_movement {
// The request body is client-paced. Acquire configuration and
// lifecycle guards in SetDisks only once its staging has finished.
opts.defer_put_object_lock_snapshot = true;
return Ok((object, opts));
}
let expected_incarnation_id = opts
.expected_bucket_incarnation_id
.ok_or_else(|| Error::other("destructive PUT is missing its bucket incarnation"))?;
@@ -5359,6 +5439,12 @@ impl ECStore {
) -> Result<ObjectInfo> {
Box::pin(async move {
let mut opts = opts;
if opts.delete_recovery_work_budget.is_none() {
// Reservations are taken up front per disk, so size the request
// budget for every drive the delete can fan out to.
let disks = self.pools.iter().map(|pool| pool.set_count * pool.set_drive_count).sum();
opts.delete_recovery_work_budget = Some(Arc::new(crate::object_api::DeleteRecoveryWorkBudget::for_disks(disks)));
}
let receipt_sink = install_tier_free_version_receipt_sink(&mut opts);
let result = self
.handle_delete_object_with_journal_inner_boxed(bucket, object, opts, tier_journal_api)
@@ -5918,6 +6004,12 @@ impl ECStore {
return (deleted, errors, accounting);
}
let mut opts = opts;
if opts.delete_recovery_work_budget.is_none() {
// Reservations are taken up front per disk, so size the request
// budget for every drive the delete can fan out to.
let disks = self.pools.iter().map(|pool| pool.set_count * pool.set_drive_count).sum();
opts.delete_recovery_work_budget = Some(Arc::new(crate::object_api::DeleteRecoveryWorkBudget::for_disks(disks)));
}
let receipt_sink = install_tier_free_version_receipt_sink(&mut opts);
let (valid_deleted, valid_errors, valid_accounting) = self
.handle_delete_objects_with_journal_and_accounting_inner(bucket, valid_objects, opts, tier_journal_api)
@@ -11948,6 +12040,63 @@ mod tests {
assert!(other.is_empty());
}
struct SelectRangeLockBarrier {
bucket: String,
object: String,
reached: tokio::sync::Notify,
release: tokio::sync::Notify,
}
type SelectRangeBarriers = ParkingMutex<HashMap<(String, String), Arc<SelectRangeLockBarrier>>>;
fn select_range_barriers() -> &'static SelectRangeBarriers {
static BARRIERS: std::sync::OnceLock<SelectRangeBarriers> = std::sync::OnceLock::new();
BARRIERS.get_or_init(Default::default)
}
struct SelectRangeBarrierGuard(Arc<SelectRangeLockBarrier>);
impl SelectRangeBarrierGuard {
fn install(bucket: &str, object: &str) -> Self {
let barrier = Arc::new(SelectRangeLockBarrier {
bucket: bucket.to_owned(),
object: object.to_owned(),
reached: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(),
});
select_range_barriers()
.lock()
.insert((bucket.to_owned(), object.to_owned()), Arc::clone(&barrier));
Self(barrier)
}
async fn wait(&self) {
tokio::time::timeout(Duration::from_secs(10), self.0.reached.notified())
.await
.expect("range acquired its namespace locks");
}
}
impl Drop for SelectRangeBarrierGuard {
fn drop(&mut self) {
select_range_barriers()
.lock()
.remove(&(self.0.bucket.clone(), self.0.object.clone()));
self.0.release.notify_one();
}
}
pub(super) async fn pause_select_range_after_lock(bucket: &str, object: &str) {
let barrier = select_range_barriers()
.lock()
.get(&(bucket.to_owned(), object.to_owned()))
.cloned();
if let Some(barrier) = barrier {
barrier.reached.notify_one();
barrier.release.notified().await;
}
}
async fn assert_prepared_reader_blocks_writer(store: &ECStore, bucket: &str, object: &str) {
assert_pool_writer_is_blocked(store, 0, bucket, object).await;
}
@@ -12088,76 +12237,175 @@ mod tests {
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn select_snapshot_holds_namespace_lock_independent_of_get_optimization() {
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE, Some("true"))], async {
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
let store = new_prepared_reader_test_store(&[set_disks]).await;
let bucket = "select-snapshot-lock-lifetime";
let object = "object.bin";
let payload = b"select-snapshot-lock-lifetime-payload-".repeat(40_000);
let put_opts = ObjectOptions {
no_lock: true,
..Default::default()
};
async fn select_snapshot_releases_namespace_locks_independent_of_get_optimization() {
for optimization in ["true", "false"] {
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE, Some(optimization))], async {
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
let store = Arc::new(new_prepared_reader_test_store(&[set_disks]).await);
let bucket = "select-snapshot-lock-lifetime";
let object = "object.bin";
let payload = b"select-snapshot-lock-lifetime-payload-".repeat(40_000);
let put_opts = ObjectOptions {
no_lock: true,
..Default::default()
};
store.pools[0]
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut put_reader = PutObjReader::from_vec(payload.clone());
store.pools[0]
.put_object(bucket, object, &mut put_reader, &put_opts)
.await
.expect("object should be written");
store.pools[0]
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut put_reader = PutObjReader::from_vec(payload.clone());
store.pools[0]
.put_object(bucket, object, &mut put_reader, &put_opts)
.await
.expect("object should be written");
use rustfs_utils::http::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
let mut request_headers = HeaderMap::new();
request_headers.insert(SSEC_ALGORITHM_HEADER, "AES256".parse().expect("valid SSE-C algorithm header"));
request_headers.insert(SSEC_KEY_HEADER, "secret-key".parse().expect("valid SSE-C key header"));
request_headers.insert(SSEC_KEY_MD5_HEADER, "key-md5".parse().expect("valid SSE-C key digest header"));
request_headers.insert("authorization", "credential".parse().expect("valid authorization header"));
let snapshot = store
.prepare_select_object_snapshot(bucket, object, &request_headers, &ObjectOptions::default())
.await
.expect("SelectObjectContent snapshot should be prepared");
assert_eq!(snapshot.headers.len(), 3);
assert_eq!(snapshot.headers.get(SSEC_ALGORITHM_HEADER), request_headers.get(SSEC_ALGORITHM_HEADER));
assert_eq!(snapshot.headers.get(SSEC_KEY_HEADER), request_headers.get(SSEC_KEY_HEADER));
assert_eq!(snapshot.headers.get(SSEC_KEY_MD5_HEADER), request_headers.get(SSEC_KEY_MD5_HEADER));
assert!(snapshot.headers.get("authorization").is_none());
assert!(snapshot.headers.values().all(http::HeaderValue::is_sensitive));
assert!(!format!("{:?}", snapshot.headers).contains("secret-key"));
assert_eq!(
snapshot.logical_size(),
u64::try_from(payload.len()).expect("test payload length should fit in u64")
);
assert_eq!(
snapshot.object_info().size,
i64::try_from(payload.len()).expect("test payload length should fit in i64")
);
assert_prepared_reader_blocks_writer(&store, bucket, object).await;
use rustfs_utils::http::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
let mut request_headers = HeaderMap::new();
request_headers.insert(SSEC_ALGORITHM_HEADER, "AES256".parse().expect("valid SSE-C algorithm header"));
request_headers.insert(SSEC_KEY_HEADER, "secret-key".parse().expect("valid SSE-C key header"));
request_headers.insert(SSEC_KEY_MD5_HEADER, "key-md5".parse().expect("valid SSE-C key digest header"));
request_headers.insert("authorization", "credential".parse().expect("valid authorization header"));
let snapshot = store
.prepare_select_object_snapshot(bucket, object, &request_headers, &ObjectOptions::default())
.await
.expect("SelectObjectContent snapshot should be prepared");
assert_eq!(snapshot.headers.len(), 3);
assert_eq!(snapshot.headers.get(SSEC_ALGORITHM_HEADER), request_headers.get(SSEC_ALGORITHM_HEADER));
assert_eq!(snapshot.headers.get(SSEC_KEY_HEADER), request_headers.get(SSEC_KEY_HEADER));
assert_eq!(snapshot.headers.get(SSEC_KEY_MD5_HEADER), request_headers.get(SSEC_KEY_MD5_HEADER));
assert!(snapshot.headers.get("authorization").is_none());
assert!(snapshot.headers.values().all(http::HeaderValue::is_sensitive));
assert!(!format!("{:?}", snapshot.headers).contains("secret-key"));
assert_eq!(
snapshot.logical_size(),
u64::try_from(payload.len()).expect("test payload length should fit in u64")
);
assert_eq!(
snapshot.object_info().size,
i64::try_from(payload.len()).expect("test payload length should fit in i64")
);
drop(acquire_prepared_reader_writer(&store, bucket, object).await);
let mut reader = snapshot.open_reader(None).await.expect("snapshot reader should open");
let mut restored = Vec::new();
reader
.stream
.read_to_end(&mut restored)
.await
.expect("snapshot body should stream");
assert_eq!(restored, payload);
drop(reader);
assert_prepared_reader_blocks_writer(&store, bucket, object).await;
let mut reader = snapshot.open_reader(None).await.expect("snapshot reader should open");
let mut restored = Vec::new();
reader
.stream
.read_to_end(&mut restored)
.await
.expect("snapshot body should stream");
assert_eq!(restored, payload);
drop(reader);
drop(acquire_prepared_reader_writer(&store, bucket, object).await);
drop(snapshot);
drop(acquire_prepared_reader_writer(&store, bucket, object).await);
})
let mut unread = snapshot.open_reader(None).await.expect("unread SELECT range");
let mut replacement = PutObjReader::from_vec(vec![b'z'; payload.len()]);
tokio::time::timeout(
Duration::from_secs(10),
store.pools[0].put_object(bucket, object, &mut replacement, &ObjectOptions::default()),
)
.await
.expect("unread SELECT cannot block an overwrite")
.expect("overwrite");
let mut old_bytes = Vec::new();
match unread.stream.read_to_end(&mut old_bytes).await {
Ok(_) => assert_eq!(old_bytes, payload, "an opened range must never switch generations"),
Err(_) => assert!(payload.starts_with(&old_bytes), "failed reads may emit only original bytes"),
}
assert!(matches!(
snapshot.open_reader(None).await,
Err(SelectObjectSnapshotReadError::Consistency(SnapshotConsistencyError::ObjectChanged))
));
assert!(
snapshot.ensure_valid().is_err(),
"consistency failure prevents successful terminal events"
);
drop(snapshot);
drop(acquire_prepared_reader_writer(&store, bucket, object).await);
})
.await;
}
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn select_snapshot_lazy_multipart_reader_never_switches_to_replacement_parts() {
use crate::storage_api_contracts::multipart::{CompletePart, MultipartOperations};
temp_env::async_with_vars(
[
("RUSTFS_GET_CODEC_STREAMING_ENABLE", Some("true")),
("RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED", Some("true")),
("RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED", Some("true")),
("RUSTFS_GET_CODEC_STREAMING_ROLLOUT", Some("on")),
("RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE", Some("true")),
("RUSTFS_GET_CODEC_STREAMING_MIN_SIZE", Some("1")),
(rustfs_config::ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE, Some("true")),
],
async {
let (_dirs, set) = make_local_set_disks(4, 2).await;
let store = Arc::new(new_prepared_reader_test_store(&[Arc::clone(&set)]).await);
let bucket = "select-lazy-multipart-generation";
let object = "multipart.bin";
set.make_bucket(bucket, &MakeBucketOptions::default()).await.expect("bucket");
let opts = ObjectOptions::default();
let upload = set.new_multipart_upload(bucket, object, &opts).await.expect("upload");
let mut expected = Vec::new();
let mut parts = Vec::new();
for (number, length, byte) in [(1, 5 * 1024 * 1024, b'a'), (2, 1024, b'b')] {
let bytes = vec![byte; length];
expected.extend_from_slice(&bytes);
let mut body = PutObjReader::from_vec(bytes);
let part = set
.put_object_part(bucket, object, &upload.upload_id, number, &mut body, &opts)
.await
.expect("part");
parts.push(CompletePart {
part_num: number,
etag: part.etag,
..Default::default()
});
}
Arc::clone(&set)
.complete_multipart_upload(bucket, object, &upload.upload_id, parts, &opts)
.await
.expect("complete upload");
let snapshot = store
.prepare_select_object_snapshot(bucket, object, &HeaderMap::new(), &opts)
.await
.expect("snapshot");
crate::set_disk::reset_test_get_object_reader_path();
let mut reader = snapshot.open_reader(None).await.expect("lazy multipart reader");
assert_eq!(
crate::set_disk::test_get_object_reader_path_id(),
5,
"exercise codec streaming rather than a materialized read"
);
let mut first = [0u8; 1];
reader.stream.read_exact(&mut first).await.expect("first original byte");
assert_eq!(first, [b'a']);
let mut replacement = PutObjReader::from_vec(vec![b'z'; expected.len()]);
tokio::time::timeout(Duration::from_secs(10), set.put_object(bucket, object, &mut replacement, &opts))
.await
.expect("unread parts cannot block overwrite")
.expect("overwrite");
let mut body = first.to_vec();
match reader.stream.read_to_end(&mut body).await {
Ok(_) => assert_eq!(body, expected, "all successful bytes belong to the original MPU"),
Err(_) => assert!(expected.starts_with(&body), "even a failed stream cannot contain replacement bytes"),
}
assert!(matches!(
snapshot.open_reader(None).await,
Err(SelectObjectSnapshotReadError::Consistency(SnapshotConsistencyError::ObjectChanged))
));
},
)
.await;
}
#[tokio::test]
async fn select_snapshot_identity_compares_encoded_directory_object_key() {
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
let store = new_prepared_reader_test_store(&[set_disks]).await;
let store = Arc::new(new_prepared_reader_test_store(&[set_disks]).await);
let bucket = "select-snapshot-directory-identity";
let source_object = "source.bin";
@@ -12191,9 +12439,9 @@ mod tests {
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn select_snapshot_reuses_initial_metadata_fanout_for_first_reader() {
async fn select_snapshot_revalidates_initial_metadata_before_first_reader() {
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
let store = new_prepared_reader_test_store(&[set_disks]).await;
let store = Arc::new(new_prepared_reader_test_store(&[set_disks]).await);
let bucket = "select-snapshot-initial-metadata";
let object = "initial-metadata.bin";
let payload = b"select snapshot initial metadata".repeat(4_000);
@@ -12226,8 +12474,8 @@ mod tests {
let mut reader = snapshot.open_reader(None).await.expect("first snapshot reader should open");
assert_eq!(
calls.total(disk_call_counters::KIND_READ_VERSION),
4,
"first reader should consume the metadata captured during preparation"
8,
"first reader must revalidate metadata after preparation released its locks"
);
let mut restored = Vec::new();
reader
@@ -12242,7 +12490,7 @@ mod tests {
#[serial_test::serial(body_cache_hook)]
async fn select_snapshot_rejects_identity_change_before_second_reader() {
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
let store = new_prepared_reader_test_store(&[Arc::clone(&set_disks)]).await;
let store = Arc::new(new_prepared_reader_test_store(&[Arc::clone(&set_disks)]).await);
let bucket = "select-snapshot-identity-change";
let object = "identity-change.bin";
let no_lock_opts = ObjectOptions {
@@ -12259,7 +12507,7 @@ mod tests {
.put_object(bucket, object, &mut initial_reader, &no_lock_opts)
.await
.expect("initial generation should be written");
let mut snapshot = store
let snapshot = store
.prepare_select_object_snapshot(bucket, object, &HeaderMap::new(), &ObjectOptions::default())
.await
.expect("snapshot should be prepared");
@@ -12269,13 +12517,12 @@ mod tests {
.await
.expect("first snapshot reader should consume captured metadata"),
);
snapshot.opts.metadata_cache_safe = false;
let mut replacement_reader = PutObjReader::from_vec(b"replacement generation".to_vec());
set_disks
.put_object(bucket, object, &mut replacement_reader, &no_lock_opts)
.put_object(bucket, object, &mut replacement_reader, &ObjectOptions::default())
.await
.expect("test-only no-lock write should replace the object");
.expect("normal writer should replace the object while the snapshot is idle");
let error = match snapshot.open_reader(None).await {
Ok(_) => panic!("a later reader must reject the replacement generation"),
Err(error) => error,
@@ -12292,7 +12539,8 @@ mod tests {
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
let (_first_dirs, first_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
let (_second_dirs, second_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
let store = new_prepared_reader_test_store_with_ctx(&[Arc::clone(&first_set), Arc::clone(&second_set)], ctx).await;
let store =
Arc::new(new_prepared_reader_test_store_with_ctx(&[Arc::clone(&first_set), Arc::clone(&second_set)], ctx).await);
let bucket = "select-snapshot-latest-delete-marker";
let object = "versioned-object.bin";
let versioned_opts = ObjectOptions {
@@ -12345,7 +12593,7 @@ mod tests {
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn select_snapshot_blocks_store_delete_for_object_in_nonzero_set() {
async fn select_snapshot_allows_store_delete_for_object_in_nonzero_set() {
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
let (_dirs, sets) = make_local_two_set_sets_with_ctx(Arc::clone(&ctx)).await;
ctx.update_erasure_type(SetupType::DistErasure).await;
@@ -12385,37 +12633,35 @@ mod tests {
sets.disk_set[1].lockers.clone(),
2,
);
let writer_error = hashed_set_writer
let writer_guard = hashed_set_writer
.get_write_lock(
rustfs_lock::ObjectKey::new(bucket, object.as_str()),
"competing-hashed-set-writer",
Duration::from_millis(50),
)
.await
.expect_err("Select must hold the nonzero hashed-set read lock");
assert!(matches!(writer_error, rustfs_lock::LockError::Timeout { .. }));
.expect("idle Select must release the nonzero hashed-set read lock");
drop(writer_guard);
let barrier = DeleteAfterObjectLockSnapshotBarrier::install(bucket);
let delete_store = Arc::clone(&store);
let delete_object = object.clone();
let mut delete = tokio::spawn(async move {
let delete = tokio::spawn(async move {
delete_store
.delete_object(bucket, &delete_object, ObjectOptions::default())
.await
});
barrier.wait_until_paused().await;
barrier.release();
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut delete).await.is_err(),
"store DELETE must wait for the fixed-domain Select read lock"
);
drop(snapshot);
let deleted = tokio::time::timeout(Duration::from_secs(10), delete)
.await
.expect("DELETE should resume after the snapshot is dropped")
.expect("DELETE should complete while the snapshot remains alive")
.expect("DELETE task should not panic")
.expect("DELETE should complete");
assert_eq!(deleted.name, object);
assert!(matches!(
snapshot.open_reader(None).await,
Err(SelectObjectSnapshotReadError::Consistency(SnapshotConsistencyError::ObjectChanged))
));
}
#[tokio::test]
@@ -12425,7 +12671,7 @@ mod tests {
rustfs_lock::GlobalLockManager::Disabled(rustfs_lock::DisabledLockManager::new()),
)));
let (_dirs, set_disks) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
let store = new_prepared_reader_test_store_with_ctx(&[set_disks], ctx).await;
let store = Arc::new(new_prepared_reader_test_store_with_ctx(&[set_disks], ctx).await);
let bucket = "select-snapshot-lock-disabled";
let object = "object.bin";
let mut put_reader = PutObjReader::from_vec(b"payload".to_vec());
@@ -12537,11 +12783,11 @@ mod tests {
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn select_snapshot_reuses_metadata_across_three_pools() {
async fn select_snapshot_revalidates_metadata_across_three_pools() {
let (_first_dirs, first_set) = make_local_set_disks(4, 2).await;
let (_second_dirs, second_set) = make_local_set_disks(4, 2).await;
let (_third_dirs, third_set) = make_local_set_disks(4, 2).await;
let store = new_prepared_reader_test_store(&[first_set, second_set, third_set]).await;
let store = Arc::new(new_prepared_reader_test_store(&[first_set, second_set, third_set]).await);
let bucket = "select-snapshot-three-pools";
let object = "object.bin";
let payload = b"select-snapshot-three-pool-payload-".repeat(40_000);
@@ -12573,8 +12819,8 @@ mod tests {
let mut reader = snapshot.open_reader(None).await.expect("snapshot body reader should open");
assert_eq!(
calls.total(disk_call_counters::KIND_READ_VERSION),
metadata_calls,
"SelectObjectContent must consume the prepared winner without a second fanout"
metadata_calls * 2,
"each range must select the authoritative winner across all pools"
);
let mut restored = Vec::new();
reader
@@ -12583,6 +12829,18 @@ mod tests {
.await
.expect("snapshot body should stream");
assert_eq!(restored, payload);
// A later authoritative winner can be in a different pool. Reopening
// only the initially selected pool would silently keep stale metadata.
let mut replacement = PutObjReader::from_vec(b"new-pool-winner".to_vec());
store.pools[0]
.put_object(bucket, object, &mut replacement, &ObjectOptions::default())
.await
.expect("normal overwrite in another pool");
assert!(matches!(
snapshot.open_reader(None).await,
Err(SelectObjectSnapshotReadError::Consistency(SnapshotConsistencyError::ObjectChanged))
));
}
#[tokio::test]
@@ -12899,7 +13157,7 @@ mod tests {
async fn select_snapshot_locks_the_hashed_set_in_every_pool() {
let (_first_dirs, first_set) = make_local_set_disks(4, 2).await;
let (_second_dirs, second_set) = make_local_set_disks(4, 2).await;
let store = new_prepared_reader_test_store(&[first_set, second_set]).await;
let store = Arc::new(new_prepared_reader_test_store(&[first_set, second_set]).await);
let bucket = "select-snapshot-all-pools";
let object = "object.bin";
let payload = b"second-pool-snapshot".to_vec();
@@ -12923,10 +13181,21 @@ mod tests {
.prepare_select_object_snapshot(bucket, object, &HeaderMap::new(), &ObjectOptions::default())
.await
.expect("snapshot should resolve the second-pool object");
drop(acquire_pool_writer(&store, 0, bucket, object).await);
drop(acquire_pool_writer(&store, 1, bucket, object).await);
let snapshot = Arc::new(snapshot);
let barrier = SelectRangeBarrierGuard::install(bucket, object);
let opening = tokio::spawn({
let snapshot = Arc::clone(&snapshot);
async move { snapshot.open_reader(None).await }
});
barrier.wait().await;
assert_pool_writer_is_blocked(&store, 0, bucket, object).await;
assert_pool_writer_is_blocked(&store, 1, bucket, object).await;
let mut reader = snapshot.open_reader(None).await.expect("snapshot reader should open");
drop(barrier);
let mut reader = opening.await.expect("range task").expect("range opens");
drop(acquire_pool_writer(&store, 0, bucket, object).await);
drop(acquire_pool_writer(&store, 1, bucket, object).await);
let mut restored = Vec::new();
reader
.stream
@@ -20,14 +20,230 @@ use std::sync::Arc;
use std::time::Duration;
use storage_api::contract_compat::ECStore;
use storage_api::metadata_lock::{
BucketOperations, CompletePart, Error, MakeBucketOptions, MultipartOperations, NamespaceLocking, ObjectIO, ObjectOperations,
ObjectOptions, PutObjReader, PutObjectCommitBarrier, PutObjectCommitPause, init_bucket_metadata_sys,
isolated_store_over_temp_disks,
BucketOperations, CompletePart, DeleteBucketOptions, Error, HashReader, MakeBucketOptions, MultipartOperations,
NamespaceLocking, ObjectIO, ObjectOperations, ObjectOptions, PutObjReader, PutObjectCommitBarrier, PutObjectCommitPause,
init_bucket_metadata_sys, isolated_store_over_temp_disks,
};
use tokio::io::AsyncReadExt;
use tokio::time::timeout;
use uuid::Uuid;
struct ObservedPutBody {
inner: tokio::io::DuplexStream,
polled: Arc<tokio::sync::Notify>,
}
impl tokio::io::AsyncRead for ObservedPutBody {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
self.polled.notify_one();
std::pin::Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
#[tokio::test]
async fn destructive_put_takes_bucket_locks_only_after_its_body_is_staged() {
use tokio::io::AsyncWriteExt;
for (object, version_id) in [("object", None), ("directory/", Some(Uuid::nil().to_string()))] {
let (_dirs, store) = isolated_store_over_temp_disks().await;
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = "deferred-put-locks";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket");
let encoded_object = if object.ends_with('/') { "directory__XLDIR__" } else { object };
let barrier = PutObjectCommitBarrier::install(bucket, encoded_object, PutObjectCommitPause::BeforeQuotaRename);
let (mut sender, inner) = tokio::io::duplex(16);
let polled = Arc::new(tokio::sync::Notify::new());
let body = ObservedPutBody {
inner,
polled: polled.clone(),
};
let put_store = store.clone();
let put = tokio::spawn(async move {
let mut reader = PutObjReader::new(HashReader::from_stream(body, 4, 4, None, None, false).expect("body"));
put_store
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
version_id,
..Default::default()
},
)
.await
});
timeout(Duration::from_secs(5), polled.notified())
.await
.expect("PUT must be waiting for client data");
let lock = store
.new_ns_lock(".rustfs.sys", &format!("bucket-targets/{bucket}/transaction.lock"))
.await
.expect("metadata lock");
drop(
timeout(Duration::from_secs(2), lock.get_write_lock(Duration::from_secs(5)))
.await
.expect("body must not block metadata writers")
.expect("metadata write guard"),
);
drop(
timeout(Duration::from_secs(2), store.lock_bucket_for_recursive_delete(bucket))
.await
.expect("body must not retain lifecycle guard")
.expect("lifecycle writer"),
);
sender.write_all(b"body").await.expect("finish body");
sender.shutdown().await.expect("EOF");
timeout(Duration::from_secs(5), barrier.wait_until_paused())
.await
.expect("staged PUT reaches commit");
let writer = lock.get_write_lock(Duration::from_secs(5));
tokio::pin!(writer);
assert!(futures::poll!(&mut writer).is_pending(), "commit must fence metadata updates");
let lifecycle = store.lock_bucket_for_recursive_delete(bucket);
tokio::pin!(lifecycle);
assert!(futures::poll!(&mut lifecycle).is_pending(), "commit must fence bucket deletion");
barrier.release();
let release_metadata = async {
drop(writer.await.expect("metadata writer"));
};
let release_lifecycle = async {
drop(lifecycle.await.expect("lifecycle writer"));
};
timeout(Duration::from_secs(5), async {
let (put, (), ()) = tokio::join!(put, release_metadata, release_lifecycle);
put.expect("owner task").expect("commit");
})
.await
.expect("PUT and queued writers finish");
}
}
#[tokio::test]
async fn staged_put_cannot_publish_into_a_recreated_bucket() {
use tokio::io::AsyncWriteExt;
let (_dirs, store) = isolated_store_over_temp_disks().await;
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = "deferred-put-generation";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket");
let (mut sender, inner) = tokio::io::duplex(16);
let polled = Arc::new(tokio::sync::Notify::new());
let body = ObservedPutBody {
inner,
polled: polled.clone(),
};
let put_store = store.clone();
let put = tokio::spawn(async move {
let mut reader = PutObjReader::new(HashReader::from_stream(body, 4, 4, None, None, false).expect("body"));
put_store
.put_object(bucket, "object", &mut reader, &ObjectOptions::default())
.await
});
timeout(Duration::from_secs(5), polled.notified()).await.expect("body paused");
store
.delete_bucket(
bucket,
&DeleteBucketOptions {
force: true,
..Default::default()
},
)
.await
.expect("remove old bucket while PUT stalls");
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("recreate bucket");
sender.write_all(b"body").await.expect("finish body");
sender.shutdown().await.expect("EOF");
let error = timeout(Duration::from_secs(5), put)
.await
.expect("commit gate")
.expect("owner")
.expect_err("old request generation must not publish");
assert!(
error.to_string().contains("generation") || matches!(error, Error::BucketNotFound(..)),
"{error:?}"
);
assert!(
store
.get_object_info(bucket, "object", &ObjectOptions::default())
.await
.is_err()
);
}
#[tokio::test]
async fn staged_put_rechecks_current_object_lock_at_commit() {
use tokio::io::AsyncWriteExt;
let (_dirs, store) = isolated_store_over_temp_disks().await;
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = "deferred-put-retention";
store
.make_bucket(
bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await
.expect("locked bucket");
let mut old = PutObjReader::from_vec(b"old".to_vec());
store
.put_object(bucket, "object", &mut old, &ObjectOptions::default())
.await
.expect("unlocked predecessor");
let (mut sender, inner) = tokio::io::duplex(16);
let polled = Arc::new(tokio::sync::Notify::new());
let body = ObservedPutBody {
inner,
polled: polled.clone(),
};
let put_store = store.clone();
let put = tokio::spawn(async move {
let mut reader = PutObjReader::new(HashReader::from_stream(body, 4, 4, None, None, false).expect("body"));
put_store
.put_object(bucket, "object", &mut reader, &ObjectOptions::default())
.await
});
timeout(Duration::from_secs(5), polled.notified()).await.expect("body paused");
let mut protected = PutObjReader::from_vec(b"protected".to_vec());
store
.put_object(
bucket,
"object",
&mut protected,
&ObjectOptions {
user_defined: std::collections::HashMap::from([("x-amz-object-lock-legal-hold".to_string(), "ON".to_string())]),
..Default::default()
},
)
.await
.expect("install a protected predecessor while the client stalls");
sender.write_all(b"body").await.expect("finish stale body");
sender.shutdown().await.expect("EOF");
let error = timeout(Duration::from_secs(5), put)
.await
.expect("commit gate")
.expect("task")
.expect_err("pre-body state cannot admit an overwrite of the newly protected version");
assert!(matches!(error, Error::PrefixAccessDenied(..)), "{error:?}");
let info = store
.get_object_info(bucket, "object", &ObjectOptions::default())
.await
.expect("protected object remains");
assert_eq!(info.size, 9);
}
#[tokio::test]
async fn replica_put_completes_while_metadata_writer_waits_for_its_snapshot() {
replica_write_with_waiting_metadata_writer(false).await;
+3 -1
View File
@@ -9,12 +9,14 @@ use rustfs_storage_api as storage_contracts;
#[cfg(feature = "test-util")]
pub(crate) mod metadata_lock {
pub(crate) use super::storage_contracts::{
BucketOperations, CompletePart, MakeBucketOptions, MultipartOperations, NamespaceLocking, ObjectIO, ObjectOperations,
BucketOperations, CompletePart, DeleteBucketOptions, MakeBucketOptions, MultipartOperations, NamespaceLocking, ObjectIO,
ObjectOperations,
};
pub(crate) use super::{Error, ObjectOptions, PutObjReader};
pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::{
init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks,
};
pub(crate) use rustfs_ecstore::api::rio::HashReader;
pub(crate) use rustfs_ecstore::api::set_disk::test_util::{PutObjectCommitBarrier, PutObjectCommitPause};
}
+28
View File
@@ -3930,6 +3930,34 @@ mod test {
assert!(versions_with_free.versions.iter().all(|version| version.num_versions == 2));
}
#[test]
fn add_version_rejects_mod_times_that_cannot_round_trip() {
for nanos in [0, i128::from(i64::MAX) + 1, i128::from(i64::MIN) - 1, 1_i128 << 64] {
for deleted in [false, true] {
let mut meta = FileMeta::new();
let mut info = crate::fileinfo::FileInfo::new("object", 2, 1);
info.deleted = deleted;
info.mod_time = Some(OffsetDateTime::from_unix_timestamp_nanos(nanos).expect("time within supported date range"));
assert!(
meta.add_version(info).is_err(),
"unrepresentable time {nanos} must be rejected before publication"
);
}
}
for nanos in [-1, 1, i64::MIN, i64::MAX] {
let mut meta = FileMeta::new();
let mut info = crate::fileinfo::FileInfo::new("object", 2, 1);
let time = OffsetDateTime::from_unix_timestamp_nanos(i128::from(nanos)).expect("valid codec time");
info.mod_time = Some(time);
meta.add_version(info).expect("representable times remain supported");
let reopened = FileMeta::load(&meta.marshal_msg().expect("serialize version")).expect("reopen version");
let versions = reopened
.get_file_info_versions("bucket", "object", false)
.expect("read version history");
assert_eq!(versions.versions[0].mod_time, Some(time));
}
}
#[test]
fn test_data_integrity_validation() {
// Test data integrity checks
+13 -2
View File
@@ -858,13 +858,24 @@ impl FileMetaVersion {
VersionType::Object => self
.object
.as_ref()
.map(|v| v.erasure_algorithm.valid() && v.bitrot_checksum_algo.valid() && v.mod_time.is_some())
.map(|v| {
v.erasure_algorithm.valid()
&& v.bitrot_checksum_algo.valid()
&& v.mod_time.is_some_and(|time| {
let nanos = time.unix_timestamp_nanos();
nanos != 0 && i64::try_from(nanos).is_ok()
})
})
.unwrap_or_default(),
VersionType::Legacy => self.legacy_object.as_ref().map(MetaObjectV1::valid).unwrap_or_default(),
VersionType::Delete => self
.delete_marker
.as_ref()
.map(|v| v.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH) > OffsetDateTime::UNIX_EPOCH)
.map(|v| {
v.mod_time.is_some_and(|time| {
time > OffsetDateTime::UNIX_EPOCH && i64::try_from(time.unix_timestamp_nanos()).is_ok()
})
})
.unwrap_or_default(),
_ => false,
}
+12 -4
View File
@@ -77,6 +77,9 @@ pub enum Error {
#[error("Replacement target is not ready: {0}")]
ReplacementTargetNotReady(String),
#[error("Replacement bucket namespace changed; a fresh identity-bound pass is required")]
ReplacementNamespaceChanged,
#[error("replacement recovery retry budget exhausted")]
ReplacementRetryBudgetExhausted,
@@ -132,7 +135,10 @@ impl Error {
/// 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::Storage(error) => matches!(
error,
EcstoreError::SlowDown | EcstoreError::TooManyOpenFiles | EcstoreError::DeleteRecoveryWorkLimit
),
Error::Disk(error) => matches!(error, DiskError::TooManyOpenFiles),
Error::TransientSkip { message } | Error::Other(message) | Error::TaskExecutionFailed { message } => {
message.contains("Please reduce your request rate")
@@ -229,9 +235,10 @@ pub(crate) fn classify_heal_error(error: &Error) -> HealErrorClass {
Error::DanglingDeleteDeferred { retry_not_before } => HealErrorClass::DanglingGrace {
retry_not_before: Some(*retry_not_before),
},
Error::ReplacementTargetNotReady(_) | Error::TransientSkip { .. } | Error::BucketDiscoveryIncomplete => {
HealErrorClass::Transient
}
Error::ReplacementTargetNotReady(_)
| Error::ReplacementNamespaceChanged
| Error::TransientSkip { .. }
| Error::BucketDiscoveryIncomplete => HealErrorClass::Transient,
Error::LegacyMrfUnverified { .. } => HealErrorClass::Permanent,
Error::Storage(error) => classify_storage_error(error),
Error::Disk(error) => classify_disk_error(error),
@@ -285,6 +292,7 @@ pub(crate) fn classify_storage_error(error: &EcstoreError) -> HealErrorClass {
| EcstoreError::FaultyDisk
| EcstoreError::FaultyRemoteDisk
| EcstoreError::SlowDown
| EcstoreError::DeleteRecoveryWorkLimit
| EcstoreError::OperationCanceled
| EcstoreError::RemoteClientUnavailable(_) => HealErrorClass::Transient,
_ if error.is_quorum_error() => HealErrorClass::Transient,
+230 -27
View File
@@ -468,7 +468,16 @@ impl ErasureSetHealer {
let task_id = self.get_or_create_task_id(set_disk_id).await?;
// 2. initialize or resume resume state
let (resume_manager, checkpoint_manager) = self.initialize_resume_state(&task_id, set_disk_id, buckets).await?;
let (resume_manager, mut checkpoint_manager) = self.initialize_resume_state(&task_id, set_disk_id, buckets).await?;
let replacement_buckets = if self.replacement_task_id.is_some() {
let current = resume_manager.prepare_replacement_bucket_pass(self.storage.as_ref()).await?;
// Preparation may have durably rewound the companion checkpoint.
checkpoint_manager = CheckpointManager::load_from_disk(self.disk.clone(), &task_id).await?;
Some(current)
} else {
None
};
let buckets = replacement_buckets.as_deref().unwrap_or(buckets);
let admin_buckets = if self.admin_task.is_some() {
Some(
@@ -489,6 +498,12 @@ impl ErasureSetHealer {
.execute_heal_with_resume(buckets, set_disk_id, &resume_manager, &checkpoint_manager)
.await;
// A cancelled or timed-out pass keeps its control classification: the
// namespace check runs only for completed or storage-failed passes so a
// concurrent bucket deletion cannot reclassify a stop as a failure.
if self.replacement_task_id.is_some() && !matches!(result, Err(Error::TaskCancelled | Error::TaskTimeout)) {
resume_manager.verify_replacement_bucket_pass(self.storage.as_ref()).await?;
}
result?;
self.verify_replacement_identity_fence("completion").await?;
@@ -967,9 +982,16 @@ impl ErasureSetHealer {
// Recheck even resumed buckets: their user-object cursor does not
// prove that the replacement holds the internal bucket records.
for bucket in buckets {
self.storage
.heal_replacement_bucket_metadata(bucket, &self.heal_opts, targets)
.await?;
if self.replacement_task_id.is_some() {
let expected = resume_manager.replacement_bucket_incarnation(bucket).await?;
self.storage
.heal_replacement_bucket_metadata_at_incarnation(bucket, expected, &self.heal_opts, targets)
.await?;
} else {
self.storage
.heal_replacement_bucket_metadata(bucket, &self.heal_opts, targets)
.await?;
}
}
}
self.heal_replacement_pool_metadata(
@@ -1019,6 +1041,7 @@ impl ErasureSetHealer {
// never hot-retried in place here.
if failed_objects > 0 || skipped_objects > 0 || failed_buckets > 0 {
if self.replacement_task_id.is_some() {
resume_manager.verify_replacement_bucket_pass(self.storage.as_ref()).await?;
let state = resume_manager.get_state().await;
let (grace_objects, retry_not_before, counter_unknown) = checkpoint_manager.dangling_delete_grace().await;
if state.can_retry()
@@ -1452,7 +1475,11 @@ impl ErasureSetHealer {
"Erasure set bucket started"
);
let incarnation = self.admin_bucket_incarnation(checkpoint_manager, bucket).await?;
let incarnation = if self.replacement_task_id.is_some() {
Some(resume_manager.replacement_bucket_incarnation(bucket).await?)
} else {
self.admin_bucket_incarnation(checkpoint_manager, bucket).await?
};
let unresolved_delete_recovery = self.heal_admin_bucket_delete_recovery(bucket, incarnation).await?;
if unresolved_delete_recovery && !self.heal_opts.dry_run {
resume_manager.set_delete_recovery_rescan_pending(true).await?;
@@ -1744,15 +1771,20 @@ impl ErasureSetHealer {
(0, Err(Error::TaskCancelled))
} else {
let result = if replacement_object {
storage
.heal_object_with_task_cancel(
&bucket_name,
&object_name,
version_id.as_deref(),
&heal_opts,
&cancel_token,
)
.await
match incarnation {
Some(expected) => storage
.heal_replacement_object_at_incarnation(
&bucket_name,
&object_name,
version_id.as_deref(),
expected,
&heal_opts,
&cancel_token,
)
.await
.map(|result| (result.item, result.error)),
None => Err(Error::ReplacementNamespaceChanged),
}
} else {
storage
.heal_object(&bucket_name, &object_name, version_id.as_deref(), &heal_opts)
@@ -2638,6 +2670,7 @@ mod resume_loop_tests {
struct FakeStorage {
admin_disk: Mutex<Option<DiskStore>>,
bucket_discovery_incomplete: AtomicBool,
bucket_discovery_error: Mutex<Option<EcstoreError>>,
bucket_discovery_scopes: Mutex<Vec<Option<crate::heal::storage_api::storage::DiskSetSelector>>>,
bucket_pages: Mutex<HashMap<String, Page>>,
incarnations: Mutex<HashMap<String, uuid::Uuid>>,
@@ -2729,6 +2762,9 @@ mod resume_loop_tests {
if self.bucket_discovery_incomplete.load(Ordering::SeqCst) {
return Err(Error::BucketDiscoveryIncomplete);
}
if let Some(error) = self.bucket_discovery_error.lock().expect("discovery failure").clone() {
return Err(Error::Storage(error));
}
self.list_buckets().await
}
@@ -2905,6 +2941,16 @@ mod resume_loop_tests {
}
Ok(())
}
async fn heal_replacement_bucket_metadata_at_incarnation(
&self,
bucket: &str,
expected: uuid::Uuid,
opts: &HealOpts,
targets: &[String],
) -> Result<()> {
self.validate_bucket_incarnation(bucket, Some(expected)).await?;
self.heal_replacement_bucket_metadata(bucket, opts, targets).await
}
async fn replacement_targets_have_version(
&self,
_bucket: &str,
@@ -3021,6 +3067,16 @@ mod resume_loop_tests {
let temp = TempDir::new().unwrap();
let disk = make_disk(&temp).await;
let storage = Arc::new(FakeStorage::default());
if !target_endpoints.is_empty() {
storage.bucket_pages.lock().expect("replacement inventory").insert(
"b".to_string(),
Page {
items: Vec::new(),
next: None,
truncated: false,
},
);
}
let task_id = ResumeUtils::generate_task_id();
let healer = ErasureSetHealer::new(
storage.clone(),
@@ -3040,7 +3096,17 @@ mod resume_loop_tests {
)
.await
.unwrap();
let checkpoint = CheckpointManager::new(disk, task_id.clone()).await.unwrap();
if !healer.target_endpoints.is_empty() {
resume
.prepare_replacement_bucket_pass(storage.as_ref())
.await
.expect("replacement fixture identity admission");
}
let checkpoint = if CheckpointManager::has_checkpoint(&disk, &task_id).await {
CheckpointManager::load_from_disk(disk, &task_id).await.unwrap()
} else {
CheckpointManager::new(disk, task_id.clone()).await.unwrap()
};
Env {
healer,
storage,
@@ -3080,6 +3146,60 @@ mod resume_loop_tests {
(processed, successful, failed, skipped, result.map(|_| ()))
}
#[tokio::test]
async fn replacement_bucket_pass_rejects_incomplete_inventory_without_reusing_or_clearing_progress() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.resume
.set_resume_cursor(Some("held-cursor".to_string()))
.await
.expect("cursor before discovery failure");
env.checkpoint
.add_processed_object("held-key".to_string())
.await
.expect("dedup before discovery failure");
// Model a page whose dedup identities must survive until cursor
// publication. complete_page deliberately prunes that identity set.
env.checkpoint
.advance_page(0, 7)
.await
.expect("persist the retained page identities");
let before = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
.await
.expect("durable checkpoint before discovery failure")
.get_checkpoint()
.await;
assert_eq!(before.current_object_index, 7);
assert!(before.processed_objects.contains("held-key"));
let state = env.resume.get_state().await;
env.storage.bucket_discovery_incomplete.store(true, Ordering::SeqCst);
assert!(matches!(
env.resume.prepare_replacement_bucket_pass(env.storage.as_ref()).await,
Err(Error::BucketDiscoveryIncomplete)
));
assert_eq!(env.resume.get_state().await, state, "partial discovery must never publish a fresh plan");
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
.await
.expect("durable checkpoint")
.get_checkpoint()
.await;
assert_eq!(checkpoint.current_object_index, 7);
assert!(checkpoint.processed_objects.contains("held-key"));
env.storage.bucket_discovery_incomplete.store(false, Ordering::SeqCst);
*env.storage.bucket_discovery_error.lock().expect("inject quorum failure") =
Some(EcstoreError::InsufficientReadQuorum("bucket".to_string(), "inventory".to_string()));
assert!(matches!(
env.resume.prepare_replacement_bucket_pass(env.storage.as_ref()).await,
Err(Error::Storage(EcstoreError::InsufficientReadQuorum(..)))
));
assert_eq!(env.resume.get_state().await, state, "quorum failure is not an empty namespace");
*env.storage.bucket_discovery_error.lock().expect("restore inventory") = None;
env.resume
.prepare_replacement_bucket_pass(env.storage.as_ref())
.await
.expect("unchanged complete inventory resumes existing pass");
assert_eq!(env.resume.get_state().await.resume_cursor.as_deref(), Some("held-cursor"));
}
#[tokio::test]
async fn test_empty_bucket_no_panic() {
let env = make_env().await;
@@ -3482,6 +3602,85 @@ mod resume_loop_tests {
drop(checkpoint);
}
#[tokio::test]
async fn replacement_cancellation_keeps_its_classification_when_the_namespace_changes() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let replacement_task_id = ResumeUtils::generate_task_id();
ResumeManager::new_replacement_intent(
env.healer.disk.clone(),
replacement_task_id.clone(),
"pool_0_set_0".to_string(),
vec!["b".to_string()],
vec!["replacement-a".to_string()],
vec![crate::heal::resume::ReplacementTargetIdentity {
endpoint: "replacement-a".to_string(),
canonical_path: "/mnt/replacement-a".to_string(),
physical_device_ids: vec!["device-a".to_string()],
filesystem_identity: "1:2:3".to_string(),
}],
)
.await
.expect("replacement intent should persist");
env.storage.set_page(
None,
Page {
items: vec![item("retry", None, false)],
next: None,
truncated: false,
},
);
env.storage.set_outcome("retry", None, HealOutcome::Transient);
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts::default(),
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
let cancel = healer.cancel_token.clone();
let observe = async {
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let notified = env.storage.heal_started.notified();
if !env.storage.calls().is_empty() {
break;
}
notified.await;
}
})
.await
.expect("the replacement object repair must start");
// The only bucket is recreated while the operator cancels the pass.
env.storage
.incarnations
.lock()
.expect("incarnations")
.insert("b".to_string(), uuid::Uuid::from_u128(7));
cancel.cancel();
};
let buckets = ["b".to_string()];
let (result, ()) = tokio::join!(healer.heal_erasure_set(&buckets, "pool_0_set_0"), observe);
assert!(
matches!(result, Err(Error::TaskCancelled)),
"a cancelled pass keeps its control classification: {result:?}"
);
let manager = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
.await
.expect("cancelled replacement intent must remain");
assert!(
matches!(
manager.verify_replacement_bucket_pass(env.storage.as_ref()).await,
Err(Error::ReplacementNamespaceChanged)
),
"the fixture must have changed the recorded bucket identity"
);
assert!(!manager.get_state().await.completed);
}
#[tokio::test]
async fn replacement_pool_metadata_readback_failure_schedules_retry() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
@@ -4055,7 +4254,7 @@ mod resume_loop_tests {
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
],
async {
let mut env = make_env().await;
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
@@ -4110,7 +4309,7 @@ mod resume_loop_tests {
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
],
async {
let mut env = make_env().await;
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer.heal_opts.no_lock = true;
env.healer = env
@@ -4167,7 +4366,7 @@ mod resume_loop_tests {
#[tokio::test]
async fn auto_replacement_retries_a_transient_object_before_rewinding_the_scan() {
let mut env = make_env().await;
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
@@ -4199,7 +4398,7 @@ mod resume_loop_tests {
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("1")),
],
async {
let mut env = make_env().await;
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
@@ -4231,7 +4430,7 @@ mod resume_loop_tests {
#[tokio::test]
async fn auto_replacement_cancellation_interrupts_object_retry() {
let mut env = make_env().await;
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
@@ -4247,14 +4446,18 @@ mod resume_loop_tests {
env.storage.set_outcome("retry", None, HealOutcome::Transient);
let cancel = env.healer.cancel_token.clone();
let observe = async {
loop {
let notified = env.storage.heal_started.notified();
if !env.storage.calls().is_empty() {
break;
let started = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let notified = env.storage.heal_started.notified();
if !env.storage.calls().is_empty() {
break;
}
notified.await;
}
notified.await;
}
})
.await;
cancel.cancel();
started.expect("replacement object retry must start");
};
let ((_, successful, _, _, result), ()) = tokio::join!(run(&env), observe);
@@ -4272,7 +4475,7 @@ mod resume_loop_tests {
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
],
async {
let mut env = make_env().await;
let mut env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.healer.source = HealRequestSource::AutoHeal;
let execution = crate::heal::storage::ReplacementExecution::for_test(Vec::new(), Vec::new());
env.healer = env
@@ -51,6 +51,25 @@ async fn replacement_env() -> Env {
)
.await
.expect("replacement intent should persist before scanning");
env.resume
.prepare_replacement_bucket_pass(env.storage.as_ref())
.await
.expect("the new replacement generation must admit its bucket identities before direct execution");
// Admission durably resets the companion checkpoint. Reload its current
// revision instead of retaining the previous generation's CAS instance.
env.checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
.await
.expect("reload the admitted replacement pass checkpoint");
assert_eq!(
env.resume
.replacement_bucket_incarnation("b")
.await
.expect("the fixture bucket must belong to the replacement pass"),
env.storage
.admit_bucket_incarnation("b")
.await
.expect("fixture bucket identity")
);
env.healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
@@ -881,6 +881,13 @@ async fn auto_format_fixture(
// Preserve the constructor's explicit open/closed state before any fixture await.
let node_limit = node.as_ref().map(|node| node.snapshot().limit);
let fixture = resume_admission_fixture(node.clone()).await;
// The endpoint monitor independently probes format.json after five seconds.
// Drain it before aging the cache so the path-only pause observes this scanner.
for pool in &fixture._store.ecstore.pools {
tokio::time::timeout(Duration::from_secs(30), pool.stop_endpoint_monitor_for_test())
.await
.expect("endpoint reconnection must stop before the scanner owns format probes");
}
let disks = crate::heal::local_disk_map_read()
.await
.values()
+59 -2
View File
@@ -27,6 +27,7 @@ use super::{
BUCKET_META_PREFIX, DiskError, DiskStore, HealDiskExt as _, RUSTFS_META_BUCKET, storage_api::owner::EcstoreDiskBytes,
};
mod bucket_pass;
mod checkpoint;
mod gc;
mod handoff;
@@ -69,7 +70,9 @@ const RESUME_PROGRESS_FILE: &str = "ahm_progress.json";
pub(super) const RESUME_CHECKPOINT_FILE: &str = "ahm_checkpoint.json";
pub(super) const RESUME_CHECKPOINT_BLOCKED_FILE: &str = "ahm_checkpoint.blocked";
const REPLACEMENT_COMPLETION_PROOF_FILE: &str = "ahm_replacement_completion_proof.json";
const REPLACEMENT_RECOVERY_DIR: &str = "ahm-replacement";
// Owned by ecstore: bucket deletion must keep this directory when a user
// bucket carries the same name, so both sides read one definition.
const REPLACEMENT_RECOVERY_DIR: &str = super::storage_api::owner::ECSTORE_HEAL_REPLACEMENT_RECOVERY_DIR;
const REPLACEMENT_INTENT_SEAL_FILE: &str = "ahm_replacement_intent_seal";
const LEGACY_REPLACEMENT_RECOVERY_MARKER_FILE: &str = "ahm_replacement_recovery.json";
const REPLACEMENT_RECOVERY_CONFLICT_PREFIX: &str = "replacement recovery conflict:";
@@ -345,6 +348,22 @@ pub struct ResumeState {
/// input after restart.
#[serde(default)]
pub replacement_buckets: Vec<String>,
/// Current namespace pass, separate from the immutable generation plan.
/// Legacy snapshots have no binding and must discard every old cursor.
#[serde(default)]
pub replacement_bucket_incarnations: Option<std::collections::BTreeMap<String, Uuid>>,
/// Last proven retired identity per name, retained for returning stale volumes.
#[serde(default)]
pub replacement_retired_bucket_incarnations: std::collections::BTreeMap<String, Uuid>,
/// Positional bucket list of the current pass; the companion checkpoint's
/// bucket index refers to it. Buckets created mid-pass are appended so the
/// index keeps its meaning. Empty in snapshots written before additions
/// were admitted, where the order is the sorted identity map.
#[serde(default)]
pub replacement_bucket_pass_order: Vec<String>,
/// Reapply the checkpoint rewind after any interruption between the two writes.
#[serde(default)]
pub replacement_bucket_pass_reset_pending: bool,
/// A task-scoped generation assigned before formatting an automatic
/// replacement. A new unformatted replacement receives a new generation
/// and therefore cannot reuse an older disk's cursor or checkpoint.
@@ -454,6 +473,10 @@ impl ResumeState {
replacement_targets: Vec::new(),
replacement_target_identities: Vec::new(),
replacement_buckets: Vec::new(),
replacement_bucket_incarnations: None,
replacement_retired_bucket_incarnations: std::collections::BTreeMap::new(),
replacement_bucket_pass_order: Vec::new(),
replacement_bucket_pass_reset_pending: false,
replacement_generation: None,
replacement_phase: ReplacementPhase::None,
replacement_execution_protocol: 0,
@@ -573,6 +596,18 @@ impl ResumeState {
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
}
/// Buckets of the current replacement pass in checkpoint order.
pub fn replacement_bucket_pass_buckets(&self) -> Vec<String> {
if self.replacement_bucket_pass_order.is_empty() {
self.replacement_bucket_incarnations
.as_ref()
.map(|identities| identities.keys().cloned().collect())
.unwrap_or_default()
} else {
self.replacement_bucket_pass_order.clone()
}
}
pub fn complete_bucket(&mut self, bucket: &str) {
if !self.completed_buckets.contains(&bucket.to_string()) {
self.completed_buckets.push(bucket.to_string());
@@ -985,7 +1020,29 @@ impl ResumeManager {
return Err(replacement_recovery_conflict("invalid retired legacy generation"));
}
}
if state.replacement_execution_protocol != 1
let valid_execution_protocol = match state.replacement_execution_protocol {
1 => state.replacement_bucket_incarnations.is_none(),
2 => state
.replacement_bucket_incarnations
.as_ref()
.is_some_and(|identities| identities.values().all(|incarnation| !incarnation.is_nil())),
_ => false,
};
// A persisted pass order must name exactly the recorded identities;
// otherwise the checkpoint's bucket index would address the wrong bucket.
let valid_pass_order = state.replacement_bucket_pass_order.is_empty()
|| state.replacement_bucket_incarnations.as_ref().is_some_and(|identities| {
let ordered = state
.replacement_bucket_pass_order
.iter()
.collect::<std::collections::BTreeSet<_>>();
ordered.len() == state.replacement_bucket_pass_order.len()
&& ordered.len() == identities.len()
&& ordered.iter().all(|bucket| identities.contains_key(*bucket))
});
if !valid_execution_protocol
|| !valid_pass_order
|| state.replacement_retired_bucket_incarnations.values().any(Uuid::is_nil)
|| state.replacement_predecessor.as_deref()
!= state.replacement_lineage.last().map(|link| link.predecessor.as_str())
{
+575
View File
@@ -0,0 +1,575 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use crate::heal::storage::HealStorageAPI;
use crate::heal::storage_api::storage::DiskSetSelector;
use std::collections::BTreeMap;
async fn namespace_snapshot(storage: &dyn HealStorageAPI, state: &ResumeState) -> Result<BTreeMap<String, Uuid>> {
let (pool, set) = crate::heal::utils::parse_set_disk_id(&state.set_disk_id)?;
// This API rejects partial topology and propagates read-quorum failures.
let buckets = storage
.list_buckets_for_admin_heal(Some(DiskSetSelector::new(pool, set)))
.await?;
let mut identities = BTreeMap::new();
for bucket in buckets {
let Some(incarnation) = storage.replacement_bucket_incarnation(&bucket.name).await? else {
// A returning disk may still list a retired volume. Only the admitted
// old UUID plus its durable receipt can discharge that obligation.
if let Some(expected) = state
.replacement_bucket_incarnations
.as_ref()
.and_then(|map| map.get(&bucket.name))
.or_else(|| state.replacement_retired_bucket_incarnations.get(&bucket.name))
&& storage.mrf_bucket_incarnation_retired(&bucket.name, *expected).await?
{
continue;
}
return Err(Error::ReplacementNamespaceChanged);
};
if incarnation.is_nil() {
return Err(Error::InvalidBucketIncarnation);
}
identities.insert(bucket.name, incarnation);
}
Ok(identities)
}
impl ResumeManager {
pub(crate) async fn replacement_bucket_incarnation(&self, bucket: &str) -> Result<Uuid> {
self.state
.read()
.await
.replacement_bucket_incarnations
.as_ref()
.and_then(|identities| identities.get(bucket).copied())
.ok_or(Error::ReplacementNamespaceChanged)
}
pub(crate) async fn prepare_replacement_bucket_pass(&self, storage: &dyn HealStorageAPI) -> Result<Vec<String>> {
let previous = self.get_state().await;
let current = namespace_snapshot(storage, &previous).await?;
// Recorded identities that are gone or replaced. Only a durable deletion
// receipt discharges one; absence or a different identity alone does not.
let mut retired = BTreeMap::new();
if let Some(identities) = previous.replacement_bucket_incarnations.as_ref() {
for (bucket, expected) in identities {
if current.get(bucket) != Some(expected) {
if !storage.mrf_bucket_incarnation_retired(bucket, *expected).await? {
return Err(Error::ReplacementNamespaceChanged);
}
retired.insert(bucket.clone(), *expected);
}
}
}
match previous.replacement_bucket_incarnations.as_ref() {
// The first pass of this generation, or a recorded bucket was deleted
// (and possibly recreated): the pass restarts over the current namespace.
None => self.restart_replacement_bucket_pass(current, retired).await?,
Some(_) if !retired.is_empty() => self.restart_replacement_bucket_pass(current, retired).await?,
// Buckets created since the snapshot join the pass so their identity
// is fenced like every other bucket, without discarding progress.
Some(identities) => {
let added = current
.iter()
.filter(|(bucket, _)| !identities.contains_key(*bucket))
.map(|(bucket, incarnation)| (bucket.clone(), *incarnation))
.collect::<Vec<_>>();
if !added.is_empty() {
let mut state = self.state.write().await;
if state.replacement_bucket_pass_order.is_empty() {
state.replacement_bucket_pass_order = identities.keys().cloned().collect();
}
for (bucket, incarnation) in added {
state
.replacement_bucket_incarnations
.get_or_insert_with(BTreeMap::new)
.insert(bucket.clone(), incarnation);
state.replacement_bucket_pass_order.push(bucket.clone());
state.pending_buckets.push(bucket);
}
drop(state);
self.save_state_strict().await?;
}
}
}
if self.get_state().await.replacement_bucket_pass_reset_pending {
let checkpoint = if CheckpointManager::has_checkpoint(&self.disk, &previous.task_id).await {
CheckpointManager::load_from_disk(self.disk.clone(), &previous.task_id).await?
} else {
CheckpointManager::new(self.disk.clone(), previous.task_id.clone()).await?
};
// Unlike the throttled retry writer this is durable before clearing the intent.
checkpoint.reset_after_delete_recovery().await?;
self.state.write().await.replacement_bucket_pass_reset_pending = false;
self.save_state_strict().await?;
}
Ok(self.get_state().await.replacement_bucket_pass_buckets())
}
/// Bind a fresh pass to `current`, publishing the checkpoint rewind first.
async fn restart_replacement_bucket_pass(
&self,
current: BTreeMap<String, Uuid>,
retired: BTreeMap<String, Uuid>,
) -> Result<()> {
let mut state = self.state.write().await;
state.replacement_retired_bucket_incarnations.extend(retired);
state.replacement_bucket_pass_order = current.keys().cloned().collect();
state.pending_buckets = current.keys().cloned().collect();
state.replacement_bucket_incarnations = Some(current);
if state.replacement_generation.is_some() {
// Protocol-1 readers reject this before interpreting a new pass cursor.
state.replacement_execution_protocol = 2;
}
state.replacement_bucket_pass_reset_pending = true;
state.reset_for_retry();
state.total_objects = 0;
state.total_bytes = 0;
state.baseline_generation = None;
state.baseline_known = false;
drop(state);
self.save_state_strict().await
}
pub(crate) async fn verify_replacement_bucket_pass(&self, storage: &dyn HealStorageAPI) -> Result<()> {
let state = self.get_state().await;
let current = namespace_snapshot(storage, &state).await?;
let Some(identities) = state.replacement_bucket_incarnations.as_ref() else {
return Err(Error::ReplacementNamespaceChanged);
};
// Buckets created during the pass have nothing the pass could have
// missed; only a recorded identity that is gone or replaced fails it.
if identities
.iter()
.any(|(bucket, expected)| current.get(bucket) != Some(expected))
{
return Err(Error::ReplacementNamespaceChanged);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::heal::storage::{ECStoreHealStorage, HealStorageAPI};
use crate::heal::storage_api::{
storage::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions, ObjectIO as _},
test_support::{
ObjectOptions, PutObjReader, delete_config, init_bucket_metadata_sys, isolated_store_over_temp_disks, read_config,
save_config,
},
};
use rustfs_heal_contracts::heal_channel::HealOpts;
use tokio_util::sync::CancellationToken;
#[tokio::test]
#[serial_test::serial]
async fn replacement_bucket_pass_retires_deleted_identity_and_restarts_recreated_bucket() {
// Keep the concrete storage futures off the test executor stack.
let (directories, store) = Box::pin(isolated_store_over_temp_disks()).await;
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "replacement-pass-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create original generation");
let old = store
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("original identity");
let storage = ECStoreHealStorage::new(store.clone());
let (_anchor_dir, disk) = super::super::tests::schema_test_disk().await;
let task_id = ResumeUtils::generate_task_id();
let manager = ResumeManager::new_replacement_intent(
disk.clone(),
task_id.clone(),
"pool_0_set_0".to_string(),
vec![bucket.to_owned()],
vec!["replacement-a".to_string()],
vec![ReplacementTargetIdentity {
endpoint: "replacement-a".to_string(),
canonical_path: "/mnt/replacement-a".to_string(),
physical_device_ids: vec!["device-a".to_string()],
filesystem_identity: "1:2:3".to_string(),
}],
)
.await
.expect("persist generation");
// A legacy name-only cursor cannot be attributed to the currently live bucket.
manager
.set_resume_cursor(Some("unbound-legacy-cursor".to_string()))
.await
.expect("legacy cursor");
let legacy_checkpoint = CheckpointManager::new(disk.clone(), task_id.clone())
.await
.expect("legacy checkpoint");
legacy_checkpoint
.add_processed_object("legacy-key".to_string())
.await
.expect("legacy dedup");
legacy_checkpoint.complete_page(0, 99).await.expect("persist legacy cursor");
assert_eq!(
manager
.prepare_replacement_bucket_pass(&storage)
.await
.expect("admit original pass"),
[bucket]
);
assert_eq!(
manager
.get_state()
.await
.replacement_bucket_incarnations
.as_ref()
.and_then(|m| m.get(bucket))
.copied(),
Some(old)
);
assert!(
manager.get_state().await.resume_cursor.is_none(),
"legacy cursor must not enter the fresh pass"
);
let fresh_checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
.await
.expect("fresh checkpoint")
.get_checkpoint()
.await;
assert_eq!(fresh_checkpoint.current_object_index, 0);
assert!(fresh_checkpoint.processed_objects.is_empty());
manager
.set_resume_cursor(Some("old-generation-cursor".to_string()))
.await
.expect("persist old cursor");
let checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
.await
.expect("checkpoint");
checkpoint
.add_processed_object("same-key".to_string())
.await
.expect("old dedup entry");
checkpoint.complete_page(0, 7).await.expect("persist old position and dedup");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete original generation");
assert!(matches!(
manager.verify_replacement_bucket_pass(&storage).await,
Err(Error::ReplacementNamespaceChanged)
));
manager
.record_replacement_failure(&Error::ReplacementNamespaceChanged, 99)
.await
.expect("defer without exhausting replacement");
assert_eq!(manager.get_state().await.retry_count, 0);
let resumed = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
.await
.expect("restart replacement manager");
let retirement_path = format!("bucket-retirements/{bucket}/{old}.json");
let retirement = Box::pin(read_config(store.clone(), &retirement_path))
.await
.expect("authoritative retirement");
Box::pin(delete_config(store.clone(), &retirement_path))
.await
.expect("simulate missing retirement evidence");
assert!(
matches!(
resumed.prepare_replacement_bucket_pass(&storage).await,
Err(Error::ReplacementNamespaceChanged)
),
"physical absence alone cannot retire an admitted identity"
);
Box::pin(save_config(store.clone(), &retirement_path, b"corrupt retirement".to_vec()))
.await
.expect("inject unreadable proof");
assert!(
matches!(resumed.prepare_replacement_bucket_pass(&storage).await, Err(Error::Storage(_))),
"a corrupt proof cannot be treated as an absent bucket"
);
assert_eq!(resumed.get_state().await.resume_cursor.as_deref(), Some("old-generation-cursor"));
Box::pin(save_config(store.clone(), &retirement_path, retirement))
.await
.expect("restore proof");
assert!(
resumed
.prepare_replacement_bucket_pass(&storage)
.await
.expect("retirement proves deleted plan item")
.is_empty()
);
// A disk returning after DELETE may expose the retired physical volume
// again; the persisted receipt identity still discharges that old name.
tokio::fs::create_dir(directories[0].path().join(bucket))
.await
.expect("return stale retired volume");
let returned = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
.await
.expect("restart after retirement");
assert!(
returned
.prepare_replacement_bucket_pass(&storage)
.await
.expect("retired stale volume is not a new bucket")
.is_empty()
);
tokio::fs::remove_dir(directories[0].path().join(bucket))
.await
.expect("remove stale test volume");
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("recreate same name");
let new = store.bucket_incarnation_id_from_disk(bucket).await.expect("new identity");
assert_ne!(new, old);
Box::pin(store.put_object(
bucket,
"same-key",
&mut PutObjReader::from_vec(b"new payload".to_vec()),
&ObjectOptions::default(),
))
.await
.expect("new generation payload");
assert_eq!(
resumed
.prepare_replacement_bucket_pass(&storage)
.await
.expect("new generation gets independent pass"),
[bucket]
);
let state = resumed.get_state().await;
assert_eq!(
state.replacement_execution_protocol, 2,
"older binaries must reject identity-bound cursors"
);
assert_eq!(
state
.replacement_bucket_incarnations
.as_ref()
.and_then(|m| m.get(bucket))
.copied(),
Some(new)
);
assert!(state.resume_cursor.is_none());
let checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
.await
.expect("restarted checkpoint")
.get_checkpoint()
.await;
assert_eq!((checkpoint.current_bucket_index, checkpoint.current_object_index), (0, 0));
assert!(checkpoint.processed_objects.is_empty());
let opts = HealOpts {
pool: Some(0),
set: Some(0),
..Default::default()
};
assert!(
storage
.heal_replacement_object_at_incarnation(bucket, "same-key", None, old, &opts, &CancellationToken::new())
.await
.is_err(),
"a stale worker cannot mutate a recreated bucket"
);
let targets = directories
.iter()
.map(|dir| dir.path().to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(
storage
.heal_replacement_bucket_metadata_at_incarnation(bucket, old, &opts, &targets)
.await
.is_err(),
"a stale metadata pass cannot certify the new incarnation"
);
storage
.heal_replacement_bucket_metadata_at_incarnation(bucket, new, &opts, &targets)
.await
.expect("current incarnation can repair metadata");
let mut reader =
Box::pin(store.get_object_reader(bucket, "same-key", None, Default::default(), &ObjectOptions::default()))
.await
.expect("open new generation payload");
assert_eq!(reader.read_all().await.expect("read new payload"), b"new payload");
drop(reader);
assert_eq!(
store
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("new identity remains"),
new
);
// Simulate interruption after publishing the reset obligation, before
// replacing the old checkpoint.
let stale_checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
.await
.expect("checkpoint before interruption");
stale_checkpoint
.add_processed_object("stale-before-reset".to_string())
.await
.expect("stale dedup");
stale_checkpoint
.complete_page(0, 42)
.await
.expect("persist interrupted checkpoint");
resumed.state.write().await.replacement_bucket_pass_reset_pending = true;
resumed.save_state_strict().await.expect("persist reset obligation");
let interrupted = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
.await
.expect("restart interrupted pass");
interrupted
.prepare_replacement_bucket_pass(&storage)
.await
.expect("finish interrupted checkpoint rewind");
let repaired_checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
.await
.expect("repaired checkpoint")
.get_checkpoint()
.await;
assert_eq!(repaired_checkpoint.current_object_index, 0);
assert!(repaired_checkpoint.processed_objects.is_empty());
assert!(!interrupted.get_state().await.replacement_bucket_pass_reset_pending);
let loaded = ResumeManager::load_replacement_intent(disk, &task_id)
.await
.expect("new pass survives restart");
loaded
.verify_replacement_bucket_pass(&storage)
.await
.expect("new pass matches current namespace");
}
#[tokio::test]
#[serial_test::serial]
async fn replacement_bucket_pass_keeps_progress_when_a_bucket_is_created_mid_pass() {
let (_directories, store) = Box::pin(isolated_store_over_temp_disks()).await;
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let recorded = "pass-recorded";
store
.make_bucket(recorded, &MakeBucketOptions::default())
.await
.expect("create the recorded bucket");
let storage = ECStoreHealStorage::new(store.clone());
let (_anchor_dir, disk) = super::super::tests::schema_test_disk().await;
let task_id = ResumeUtils::generate_task_id();
let manager = ResumeManager::new_replacement_intent(
disk.clone(),
task_id.clone(),
"pool_0_set_0".to_string(),
vec![recorded.to_owned()],
vec!["replacement-a".to_string()],
vec![ReplacementTargetIdentity {
endpoint: "replacement-a".to_string(),
canonical_path: "/mnt/replacement-a".to_string(),
physical_device_ids: vec!["device-a".to_string()],
filesystem_identity: "1:2:3".to_string(),
}],
)
.await
.expect("persist generation");
assert_eq!(
manager
.prepare_replacement_bucket_pass(&storage)
.await
.expect("admit the recorded pass"),
[recorded]
);
// Progress into the recorded bucket.
manager
.set_resume_cursor(Some("mid-pass-cursor".to_string()))
.await
.expect("persist mid-pass cursor");
let checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
.await
.expect("admitted checkpoint");
checkpoint.complete_page(0, 7).await.expect("persist mid-pass position");
// The new name sorts before the recorded one, so a sorted rebuild would
// move the recorded bucket away from the checkpoint's index.
let created = "pass-created";
store
.make_bucket(created, &MakeBucketOptions::default())
.await
.expect("create a bucket during the pass");
let created_identity = store
.bucket_incarnation_id_from_disk(created)
.await
.expect("created identity");
manager
.verify_replacement_bucket_pass(&storage)
.await
.expect("a bucket created mid-pass does not fail verification");
assert_eq!(
manager
.prepare_replacement_bucket_pass(&storage)
.await
.expect("additions extend the pass"),
[recorded, created],
"additions are appended so the checkpoint's bucket index keeps its meaning"
);
let state = manager.get_state().await;
assert_eq!(
state
.replacement_bucket_incarnations
.as_ref()
.and_then(|identities| identities.get(created))
.copied(),
Some(created_identity)
);
assert!(state.pending_buckets.contains(&created.to_string()));
assert_eq!(state.resume_cursor.as_deref(), Some("mid-pass-cursor"));
assert!(!state.replacement_bucket_pass_reset_pending);
assert_eq!(state.replacement_execution_protocol, 2);
let checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
.await
.expect("checkpoint after the addition")
.get_checkpoint()
.await;
assert_eq!(
(checkpoint.current_bucket_index, checkpoint.current_object_index),
(0, 7),
"the checkpoint must not rewind"
);
// A restart reads the persisted order instead of rebuilding it sorted.
let restarted = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
.await
.expect("restart the pass");
assert_eq!(
restarted
.prepare_replacement_bucket_pass(&storage)
.await
.expect("an unchanged namespace keeps the pass"),
[recorded, created]
);
restarted
.verify_replacement_bucket_pass(&storage)
.await
.expect("the extended pass matches the namespace");
assert_eq!(restarted.get_state().await.resume_cursor.as_deref(), Some("mid-pass-cursor"));
// Deleting the created bucket is still a namespace change for the pass.
store
.delete_bucket(created, &DeleteBucketOptions::default())
.await
.expect("delete the created bucket");
assert!(matches!(
restarted.verify_replacement_bucket_pass(&storage).await,
Err(Error::ReplacementNamespaceChanged)
));
}
}
+9 -2
View File
@@ -277,7 +277,7 @@ impl ResumeManager {
}
return self.ensure_handoff_successor().await;
}
if state.replacement_execution_protocol != 1
if !matches!(state.replacement_execution_protocol, 1 | 2)
|| !matches!(
state.replacement_phase,
ReplacementPhase::Intent
@@ -458,6 +458,13 @@ impl ResumeManager {
predecessor.replacement_targets.clone(),
link.targets.clone(),
);
if predecessor.replacement_execution_protocol == 2 {
state.replacement_execution_protocol = 2;
state.replacement_bucket_incarnations = predecessor.replacement_bucket_incarnations.clone();
state.replacement_bucket_pass_order = predecessor.replacement_bucket_pass_order.clone();
state.replacement_retired_bucket_incarnations = predecessor.replacement_retired_bucket_incarnations.clone();
state.replacement_bucket_pass_reset_pending = true;
}
state.replacement_phase = ReplacementPhase::OwnershipPending;
// The constructor marks this compatibility grant consumed for every
// successor, including descendants of a recovered older generation.
@@ -580,7 +587,7 @@ fn preformat_handoff_retry_candidate(state: &ResumeState) -> bool {
&& state.replacement_generation.as_deref() == Some(state.task_id.as_str())
&& !state.replacement_preformat_handoff_retry_done
&& state.replacement_phase == ReplacementPhase::Intent
&& state.replacement_execution_protocol == 1
&& matches!(state.replacement_execution_protocol, 1 | 2)
&& state.max_retries >= 2
&& state.replacement_predecessor.is_none()
&& state.replacement_handoff.is_none()
@@ -918,3 +918,33 @@ async fn committed_predecessor_is_collectible_once_its_successor_is_verified() {
"the verified successor keeps its own record until cleanup"
);
}
#[tokio::test]
async fn handoff_preserves_bucket_pass_protocol_and_rewinds_successor_progress() {
let (_dirs, parent, execution) = fixture(1).await;
let identities = [("bucket-a".to_string(), Uuid::new_v4())].into_iter().collect();
{
let mut state = parent.state.write().await;
state.replacement_execution_protocol = 2;
state.replacement_bucket_incarnations = Some(identities);
state.resume_cursor = Some("old-pass-cursor".to_string());
}
parent.save_state_strict().await.expect("persist identity-bound predecessor");
let child = parent
.prepare_replacement_handoff(&execution, Vec::new())
.await
.expect("handoff protocol two");
let state = child.get_state().await;
assert_eq!(state.replacement_execution_protocol, 2);
assert_eq!(
state.replacement_bucket_incarnations,
parent.get_state().await.replacement_bucket_incarnations
);
assert!(state.replacement_bucket_pass_reset_pending);
assert!(state.resume_cursor.is_none());
assert!(state.completed_buckets.is_empty());
let recovered = ResumeManager::load_replacement_intent(child.disk.clone(), &state.task_id)
.await
.expect("reload protocol-two successor");
assert_eq!(recovered.get_state().await, state);
}
+20 -1
View File
@@ -345,6 +345,16 @@ pub(crate) fn merge_replacement_recovery_candidate(
let candidate_revision = candidate.state.replacement_revision;
let current_revision = current.state.replacement_revision;
let candidate_protocol = candidate.state.replacement_execution_protocol;
let current_protocol = current.state.replacement_execution_protocol;
if (candidate_protocol > current_protocol && candidate_revision <= current_revision)
|| (candidate_protocol < current_protocol && candidate_revision >= current_revision)
{
return Err(Error::ReplacementGenerationConflict {
task_id: candidate.state.task_id,
reason: "replacement execution protocol regresses with durable revision".to_string(),
});
}
if candidate_revision == current_revision && candidate.state != current.state {
return Err(Error::ReplacementGenerationConflict {
task_id: candidate.state.task_id,
@@ -374,7 +384,10 @@ fn same_replacement_generation_binding(left: &ResumeState, right: &ResumeState)
&& left.replacement_target_identities == right.replacement_target_identities
&& left.replacement_buckets == right.replacement_buckets
&& left.replacement_generation == right.replacement_generation
&& left.replacement_execution_protocol == right.replacement_execution_protocol
// A revision-ordered protocol-1 -> protocol-2 pass admission preserves
// the generation's target ownership and immutable lineage.
&& matches!(left.replacement_execution_protocol, 1 | 2)
&& matches!(right.replacement_execution_protocol, 1 | 2)
&& left.replacement_predecessor == right.replacement_predecessor
&& left.replacement_lineage == right.replacement_lineage
&& left.replacement_legacy_import == right.replacement_legacy_import
@@ -592,6 +605,12 @@ impl ResumeManager {
if state.replacement_generation.as_deref() != Some(state.task_id.as_str()) {
return Err(replacement_recovery_conflict("replacement failure has no matching generation"));
}
if matches!(error, Error::ReplacementNamespaceChanged | Error::BucketDiscoveryIncomplete) {
// Namespace churn is not evidence that the replacement is irreparable.
state.error_message = Some(error.to_string());
drop(state);
return self.save_state_strict().await;
}
if let Error::DanglingDeleteDeferred { retry_not_before } = error {
if !super::dangling_delete_deadline_is_representable(*retry_not_before) {
return Err(Error::TaskExecutionFailed {
+126
View File
@@ -3629,3 +3629,129 @@ async fn blocked_checkpoint_is_not_revalidated_before_its_schedule() {
assert_eq!(serde_json::from_slice::<serde_json::Value>(&retained).expect("marker JSON"), marker);
temp_dir.close().expect("remove scheduled revalidation fixture");
}
#[test]
fn replacement_candidate_merge_rejects_protocol_downgrade_in_either_read_order() {
for downgrade in [false, true] {
for reverse in [false, true] {
let task_id = ResumeUtils::generate_task_id();
let mut protocol_one = replacement_candidate_state(&task_id);
protocol_one.replacement_revision = if downgrade { 11 } else { 9 };
let mut protocol_two = replacement_candidate_state(&task_id);
protocol_two.replacement_execution_protocol = 2;
protocol_two.replacement_bucket_incarnations = Some(Default::default());
protocol_two.replacement_revision = 10;
let (first, second) = if reverse {
(protocol_two, protocol_one)
} else {
(protocol_one, protocol_two)
};
let mut selected = None;
merge_replacement_recovery_candidate(
&mut selected,
ReplacementRecoveryCandidate::new(first, "anchor-a", false).expect("first durable copy"),
)
.expect("initial observation");
let result = merge_replacement_recovery_candidate(
&mut selected,
ReplacementRecoveryCandidate::new(second, "anchor-b", true).expect("second durable copy"),
);
if downgrade {
assert!(
matches!(result, Err(Error::ReplacementGenerationConflict { .. })),
"protocol 1 at a newer revision must not erase protocol 2, reverse={reverse}"
);
} else {
result.expect("protocol 1 at an older revision may advance to protocol 2");
let state = selected.expect("selected upgraded generation").state;
assert_eq!(state.replacement_execution_protocol, 2);
assert_eq!(state.replacement_revision, 10);
}
}
}
}
#[tokio::test]
#[serial_test::serial]
async fn replacement_recovery_survives_same_named_bucket_deletion_and_recreation() {
use crate::heal::storage_api::{
storage::{BucketOperations as _, DeleteBucketOptions, DiskSetSelector, MakeBucketOptions, StorageAdminApi as _},
test_support::{init_bucket_metadata_sys, isolated_store_over_temp_disks},
};
let (_directories, store) = Box::pin(isolated_store_over_temp_disks()).await;
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "ahm-replacement";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create legal same-name bucket");
let disk = store
.disk_set_inventory(DiskSetSelector::new(0, 0))
.await
.expect("survivor inventory")
.into_iter()
.flatten()
.next()
.expect("survivor disk");
let task_id = ResumeUtils::generate_task_id();
let manager = ResumeManager::new_replacement_intent(
disk.clone(),
task_id.clone(),
"pool_0_set_0".to_string(),
vec!["other-bucket".to_string()],
vec!["replacement-a".to_string()],
vec![ReplacementTargetIdentity {
endpoint: "replacement-a".to_string(),
canonical_path: "/mnt/replacement-a".to_string(),
physical_device_ids: vec!["device-a".to_string()],
filesystem_identity: "1:2:3".to_string(),
}],
)
.await
.expect("persist sealed replacement intent");
let expected = manager.get_state().await;
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete same-name user bucket");
let resumed = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
.await
.expect("sealed intent remains recoverable");
assert_eq!(resumed.get_state().await, expected);
assert_eq!(
ResumeUtils::get_replacement_intent_tasks(&disk)
.await
.expect("scanner recovery enumeration"),
std::slice::from_ref(&task_id)
);
resumed
.mark_replacement_completed_and_verified()
.await
.expect("replacement can finish after deletion");
resumed
.ensure_replacement_completion_proof()
.await
.expect("persist completion proof");
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("recreate same-name user bucket");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete recreated bucket");
let proof = ResumeManager::load_replacement_completion_proof(disk.clone(), &task_id)
.await
.expect("completion proof survives recreation");
assert_eq!(proof.task_id, task_id);
assert_eq!(
ResumeManager::load_replacement_intent(disk, &task_id)
.await
.expect("verified intent survives")
.get_state()
.await
.replacement_phase,
ReplacementPhase::Verified
);
}
+86
View File
@@ -476,6 +476,15 @@ pub trait HealStorageAPI: Send + Sync {
Ok(None)
}
/// Published identity for a replacement pass; None proves missing metadata only.
async fn replacement_bucket_incarnation(&self, bucket: &str) -> Result<Option<Uuid>> {
match self.admit_bucket_incarnation(bucket).await {
Ok(id) => Ok(Some(id)),
Err(Error::StaleBucketIncarnation { .. }) => Ok(None),
Err(error) => Err(error),
}
}
/// Whether the exact old bucket generation has a durable deletion record.
/// Alternate backends must fail closed unless they can validate the same
/// deployment, bucket and incarnation identity.
@@ -598,6 +607,23 @@ pub trait HealStorageAPI: Send + Sync {
self.heal_object(bucket, object, version_id, opts).await
}
/// Replacement writes retain both cancellation ownership and the admitted bucket fence.
async fn heal_replacement_object_at_incarnation(
&self,
bucket: &str,
object: &str,
version_id: Option<&str>,
expected: Uuid,
opts: &HealOpts,
task_cancel: &CancellationToken,
) -> Result<HealStorageObjectResult> {
if task_cancel.is_cancelled() {
return Err(Error::TaskCancelled);
}
self.heal_object_at_incarnation(bucket, object, version_id, expected, opts)
.await
}
async fn heal_object_with_receipt(
&self,
bucket: &str,
@@ -663,6 +689,17 @@ pub trait HealStorageAPI: Send + Sync {
Err(Error::Storage(StorageError::PreconditionFailed))
}
async fn heal_replacement_bucket_metadata_at_incarnation(
&self,
_bucket: &str,
_expected: Uuid,
_opts: &HealOpts,
_targets: &[String],
) -> Result<()> {
// An unfenced fallback could certify a different same-name bucket.
Err(Error::Storage(StorageError::PreconditionFailed))
}
/// Read target-specific physical evidence for one replacement version.
///
/// This is only used by automatic replacement healing after the normal
@@ -1316,6 +1353,35 @@ impl HealStorageAPI for ECStoreHealStorage {
.await
}
async fn heal_replacement_object_at_incarnation(
&self,
bucket: &str,
object: &str,
version_id: Option<&str>,
expected: Uuid,
opts: &HealOpts,
task_cancel: &CancellationToken,
) -> Result<HealStorageObjectResult> {
run_background_object_inner(
self.admission.as_ref(),
|| self.ecstore.physical_heal_plan(object, opts).map_err(Error::Storage),
opts,
Some(task_cancel),
|result: &HealStorageObjectResult| result.error.is_none(),
boxed_object_operation(|| async {
let result = self
.ecstore
.heal_object_at_incarnation(bucket, object, version_id.unwrap_or_default(), expected, opts)
.await
.map_err(|error| incarnation_storage_error(bucket, expected, error))?;
Ok(self
.object_result_with_receipt(bucket, object, version_id, opts, result, Some(expected))
.await)
}),
)
.await
}
async fn heal_mrf_object_at_incarnation(
&self,
bucket: &str,
@@ -1520,6 +1586,13 @@ impl HealStorageAPI for ECStoreHealStorage {
}
}
async fn replacement_bucket_incarnation(&self, bucket: &str) -> Result<Option<Uuid>> {
self.ecstore
.replacement_bucket_incarnation_from_disk(bucket)
.await
.map_err(Error::Storage)
}
async fn mrf_bucket_incarnation_retired(&self, bucket: &str, expected: Uuid) -> Result<bool> {
self.ecstore
.is_bucket_incarnation_retired(bucket, expected)
@@ -1959,6 +2032,19 @@ impl HealStorageAPI for ECStoreHealStorage {
.map_err(Error::Storage)
}
async fn heal_replacement_bucket_metadata_at_incarnation(
&self,
bucket: &str,
expected: Uuid,
opts: &HealOpts,
targets: &[String],
) -> Result<()> {
self.ecstore
.heal_replacement_bucket_metadata_at_incarnation(bucket, expected, opts, targets)
.await
.map_err(|error| incarnation_storage_error(bucket, expected, error))
}
async fn replacement_targets_have_version(
&self,
bucket: &str,
+18 -8
View File
@@ -22,8 +22,9 @@ pub(crate) use rustfs_ecstore::api::disk::error::{DiskError as EcstoreDiskError,
pub(crate) use rustfs_ecstore::api::disk::{
BUCKET_META_PREFIX as ECSTORE_BUCKET_META_PREFIX, Bytes as EcstoreDiskBytes,
ConditionalFileUpdate as EcstoreConditionalFileUpdate, DeleteOptions as EcstoreDeleteOptions, DiskAPI as EcstoreDiskAPI,
DiskStore as EcstoreDiskStore, HEALING_MARKER_PATH as ECSTORE_HEALING_MARKER_PATH,
RUSTFS_META_BUCKET as ECSTORE_RUSTFS_META_BUCKET, ReplacementExecutionLease as EcstoreReplacementExecutionLease,
DiskStore as EcstoreDiskStore, HEAL_REPLACEMENT_RECOVERY_DIR as ECSTORE_HEAL_REPLACEMENT_RECOVERY_DIR,
HEALING_MARKER_PATH as ECSTORE_HEALING_MARKER_PATH, RUSTFS_META_BUCKET as ECSTORE_RUSTFS_META_BUCKET,
ReplacementExecutionLease as EcstoreReplacementExecutionLease,
};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{
@@ -80,11 +81,11 @@ pub(crate) mod owner {
pub(crate) use super::storage_contracts::{ObjectIO, ObjectOperations};
pub(crate) use super::{
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEALING_MARKER_PATH, ECSTORE_POOL_META_NAME,
ECSTORE_RUSTFS_META_BUCKET, EcstoreConditionalFileUpdate, EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes,
EcstoreDiskError, EcstoreDiskResult, EcstoreDiskStore, EcstoreEndpoint, EcstoreErrorType,
EcstoreHealLifecycleExpiryContext, EcstoreStorageError, EcstoreStore, ecstore_is_reserved_or_invalid_bucket,
ecstore_load_admin_data_usage_from_backend_cached, ecstore_local_disk_map_read,
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEAL_REPLACEMENT_RECOVERY_DIR,
ECSTORE_HEALING_MARKER_PATH, ECSTORE_POOL_META_NAME, ECSTORE_RUSTFS_META_BUCKET, EcstoreConditionalFileUpdate,
EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes, EcstoreDiskError, EcstoreDiskResult, EcstoreDiskStore,
EcstoreEndpoint, EcstoreErrorType, EcstoreHealLifecycleExpiryContext, EcstoreStorageError, EcstoreStore,
ecstore_is_reserved_or_invalid_bucket, ecstore_load_admin_data_usage_from_backend_cached, ecstore_local_disk_map_read,
};
pub(crate) use super::{EcstoreDiskOption, ecstore_new_disk};
@@ -98,10 +99,19 @@ pub(crate) mod storage {
};
#[cfg(test)]
pub(crate) use super::storage_contracts::DeleteBucketOptions;
pub(crate) use super::storage_contracts::{DeleteBucketOptions, MakeBucketOptions};
}
#[cfg(test)]
pub(crate) mod status {
pub(crate) use super::storage_contracts::BucketInfo;
}
#[cfg(test)]
pub(crate) mod test_support {
pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::{
init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks,
};
pub(crate) use rustfs_ecstore::api::config::com::{delete_config, read_config, save_config};
pub(crate) use rustfs_ecstore::api::object::{ObjectOptions, PutObjReader};
}
+16 -1
View File
@@ -1310,7 +1310,22 @@ impl HealTask {
.load(Ordering::Acquire)
.max(self.retry_attempts)
.saturating_add(1);
manager.record_replacement_failure(&failure, attempt).await
// A deletion can race any stage, including hidden-transaction recovery.
// Only a successful complete inventory may reclassify that failure;
// lookup/quorum failures remain the original storage failure.
if manager.get_state().await.replacement_bucket_incarnations.is_some()
&& !matches!(failure, Error::TaskCancelled | Error::TaskTimeout)
&& matches!(
manager.verify_replacement_bucket_pass(self.storage.as_ref()).await,
Err(Error::ReplacementNamespaceChanged)
)
{
manager
.record_replacement_failure(&Error::ReplacementNamespaceChanged, attempt)
.await
} else {
manager.record_replacement_failure(&failure, attempt).await
}
}
.await;
match persisted {

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