Compare commits

..

39 Commits

Author SHA1 Message Date
houseme a118d7e4fd perf(ecstore): enable inline data read early-stop by default (#6140)
* perf(ecstore): enable inline data read early-stop by default

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

* test(scanner): box large ILM transition flow future

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

* test(ecstore): align internal meta early-stop miss

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-16 06:38:51 +00:00
Zhengchao An ed1bedf1fb fix(storage): skip table guards for multipart parts (#6143) 2026-08-16 14:34:46 +08:00
houseme 4392f94e1a chore(deps): update flake.lock (#6142)
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/70ce234' (2026-08-06)
  → 'github:NixOS/nixpkgs/8be7bd0' (2026-08-14)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/6df7076' (2026-08-09)
  → 'github:oxalica/rust-overlay/b211ead' (2026-08-16)
2026-08-16 13:35:41 +08:00
Zhengchao An 81d7b7d07a chore(ecstore): drop the client dead_code blanket (#6138) 2026-08-16 03:20:52 +00:00
唐小鸭 e26668e62c fix(ecstore): mint bucket-target ARNs in the madmin arn:minio partition (#6128)
* test(ecstore): pin madmin-compatible ARN partition contract

Red-light evidence for backlog#1675 P1-7: madmin-go's ParseARN
hard-rejects any ARN that does not start with 'arn:minio:', while RustFS
generates and only accepts 'arn:rustfs:'. mc/madmin tooling therefore
cannot decode RustFS remote-target listings, and MinIO-era replication
configs are rejected as StaleTarget when re-registered. The new tests
pin the target contract (generate arn:minio:, parse both partitions,
reject unknown partitions) and fail against the current single-partition
gate.

* fix(ecstore): mint bucket-target ARNs in the madmin arn:minio partition

madmin-go's ParseARN hard-rejects any partition other than 'arn:minio:',
so native mc/madmin tooling could not decode RustFS remote-target
listings, and re-registering a MinIO-era replication config failed its
StaleTarget check against freshly minted arn:rustfs: targets
(backlog#1675 P1-7, route A).

- ARN Display now emits 'arn:minio:'; FromStr accepts a {minio, rustfs}
  partition whitelist (the legacy partition stays readable forever for
  persisted bucket-targets.json / replication configs). The whitelist is
  the only structural gate — BucketTargetType::from_str never fails —
  so it deliberately rejects foreign partitions such as arn:aws:.
- No data migration: every runtime match between targets, rules and
  stats keys is full-string equality, so existing arn:rustfs: targets
  keep matching their persisted rules; site replication already
  preserves MinIO-era ARNs on reconcile (pinned by existing tests).
- Rolling upgrade note: upgrade all cluster nodes before creating new
  remote targets — a not-yet-upgraded node rejects remove-remote-target
  for a freshly minted arn:minio: ARN with BucketRemoteArnInvalid.
- Out of scope: notification/SQS ARNs (crates/targets) keep the
  arn:rustfs:sqs: partition; they have their own compatibility story.
2026-08-16 10:28:45 +08:00
Henry Guo 8d3511c1b3 fix(heal): preserve timeout budget across retries (#6101)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-08-16 10:28:11 +08:00
Henry Guo d172d05e86 fix(ecstore): overlap metacache reader deadlines (#6098)
* fix(ecstore): overlap metacache reader deadlines

* fix(admin): avoid span guards across awaits

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-08-16 10:27:59 +08:00
Zhengchao An 0d86c50760 fix(ecstore): classify remote inline early-stop misses (#6136) 2026-08-16 09:50:16 +08:00
houseme 526d6f667e perf(ecstore): defer pending inline data shards (#6137) 2026-08-16 09:44:00 +08:00
唐小鸭 dcf3e4b9e8 fix(replication): transport and persist LWW timestamps for tag, retention, and legal hold (#6129)
* test(replication): pin missing LWW timestamp header transport

Red-light tests for the replication timestamp three-header contract:

- put_object_headers_carry_replication_timestamp_headers pins that
  PutObjectOptions::header() must emit the
  x-{rustfs,minio}-source-replication-{tagging,retention,legalhold}-timestamp
  headers when the internal timestamps are set (currently missing).
- test_put_opts_from_headers_gates_replication_timestamp_persistence_on_authorization
  and test_complete_multipart_opts_persist_replication_timestamps_when_authorized
  pin that an authorized replication PUT / multipart complete must persist
  the inbound timestamps into the internal metadata keys while unauthorized
  requests must not (currently never persisted).
- fake_s3_target journals the three timestamp headers per request
  (ReplicationTimestampHeaders on RequestRecord) so sender-side e2e
  assertions can observe what a real target receives; self-test included.

* fix(replication): transport and persist LWW timestamps for tag, retention, and legal hold

Active-active conflict resolution for concurrent tag/retention/legal-hold
edits needs the source's per-category modification times on both sides of
the wire; the three AdvancedPutOptions timestamp fields were dead and the
headers were neither sent nor parsed.

- Emit x-{rustfs,minio}-source-replication-{tagging,retention,legalhold}-
  timestamp from PutObjectOptions::header(); names and RFC3339 values
  interoperate with MinIO (minio-go constants.go, object-api-options.go),
  pinned by a header_compat wire-name test.
- Default the three AdvancedPutOptions timestamps to UNIX_EPOCH and skip
  epoch values in header(), so "never modified" is not sent as a
  modification made now.
- Parse the headers only on authorized replication PUTs and multipart
  completes, expose them as Option<OffsetDateTime> on ObjectOptions, and
  persist them into the dual-prefix internal metadata keys so the
  outbound pass (replication_target_boundary) reads the source's
  timestamps instead of the mod_time fallback.
- Record the local tagging timestamp in the PutObjectTagging and
  DeleteObjectTagging eval metadata, mirroring the object-lock handlers;
  without it the sender only ever had the mod_time fallback to offer.

Receiver-side LWW comparison (keep newer stored category metadata over a
stale inbound copy) is left as a TODO at the parse site.

* fix(replication): load the stored tagging timestamp independently of remaining tags

Review: DeleteObjectTagging persists the tagging-timestamp internal key
but leaves the object tagless, and the outbound mapper only loaded the
key inside the user_tags-nonempty branch — the deletion's LWW timestamp
stayed at the epoch and the header was omitted, so the deletion could
never win conflict resolution on the replica. The stored key is now
loaded unconditionally; the mod_time fallback still applies only while
tags exist (MinIO parity), and a tagless object without the key keeps
the epoch default (no header). Deletion-path regression test added.

* fix(storage): reserve replication transport names at metadata ingest

Second review round: a client PUT of
x-amz-meta-x-rustfs-source-replication-tagging-timestamp materialized
the bare transport key as stored user metadata. The outbound
replication header builder forwards user metadata verbatim on a
server-authorized request, so the receiver would persist the
attacker-chosen value as trusted internal LWW state — and for a
tagless object nothing later overwrites it.

The ingest namespacing guard now reserves the whole
x-rustfs-source- / x-minio-source- families (the new timestamps and
their siblings: source-mtime/-etag/-version-id/-replication-request),
folding forged keys back under x-amz-meta-. Forged-ingress regression
covers both prefixes and a sibling.

* fix(replication): harden timestamp replay

* fix(app): route retention helper through facade

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-08-16 05:56:04 +08:00
唐小鸭 04b9c8fd36 fix(admin): stream madmin ReplicationMRF documents from /v3/replication/mrf (#6126)
* test(admin): pin madmin ReplicationMRF stream contract for /v3/replication/mrf

Red-light evidence for backlog#1675 P1-13 (mrf half): madmin's
BucketReplicationMRF decodes the response one ReplicationMRF document at
a time, so the current aggregate envelope decodes as a single phantom
row with an empty object in 'mc replicate backlog'. The new contract
tests assert the desired bare-document stream (exact madmin json tags,
empty body for an empty backlog) and fail against the current
render_mrf_backlog extraction, which preserves the envelope-only
behavior:

- mrf_stream_renders_bare_madmin_documents: envelope keys leak, no
  per-entry documents
- mrf_stream_renders_empty_body_for_no_entries: empty backlog still
  renders the envelope (phantom row)
- mrf_aggregate_envelope_retains_counters: PerObjectEntriesAvailable
  never advertises the enumerable stream

* fix(admin): stream madmin ReplicationMRF documents from /v3/replication/mrf

The mrf endpoint returned a single aggregate envelope, which madmin's
json.Decoder loop decoded as one phantom row (empty object) in
'mc replicate backlog' (backlog#1675 P1-13, mrf half; the diff half was
fixed in #5799 and this mirrors its pattern).

- Default response is now a bare stream of ReplicationMRF documents
  (exact madmin json tags; Size/TargetARNs as ignored extension keys)
  built from the durable backlog ledger; an empty backlog renders an
  empty body, so mc shows zero rows instead of a phantom row.
- The aggregate counter envelope moves behind ?aggregate=true (RustFS
  extension) and now advertises PerObjectEntriesAvailable whenever the
  durable backlog is readable.
- An unreadable backlog is signalled out-of-band via
  x-rustfs-replication-mrf-backlog-unavailable (mirrors the diff
  truncation header) plus a warn event, since the bare stream cannot
  carry source health.
- The madmin node parameter is accepted but documented as a no-op: the
  durable ledger is cluster-shared with no per-node attribution.
- Delete-marker purge entries fall back to the marker version id so
  those rows keep a version identity.

* fix(admin): fail the mrf stream request when the durable ledger is unreadable

Review: madmin only decodes the body of a 200, so the out-of-band
unavailability header was invisible to it and an unreadable ledger read
as a clean zero-row backlog. Stream mode now returns 503; aggregate
mode keeps the availability fields.

* fix(admin): gate, bound, and null-map the mrf stream

Second review round:

- Authorization: the default stream enumerates object names and version
  ids, which a metrics-only principal must not see — it now requires
  admin:ReplicationDiff (MinIO parity, route policy updated);
  ?aggregate=true carries no object identities and keeps
  admin:GetReplicationMetrics.
- The nil UUID is RustFS's in-memory null-version sentinel and now
  leaves as the S3 wire token 'null' instead of a zero UUID (a
  pre-versioning object scanned after versioning + existing-object
  replication can persist it into the ledger).
- The durable ledger is not bounded by the in-memory pending cap and
  the body is buffered before send; the stream now stops at 10,000
  documents and signals truncation via
  x-rustfs-replication-mrf-truncated (mirroring the diff endpoint)
  plus a warn event, instead of staging an unbounded body.

* fix(admin): reject truncated MRF streams

---------

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-08-16 05:55:15 +08:00
唐小鸭 c1f66969d7 fix(site-replication): merge incoming ILM expiry documents instead of overwriting (#6130)
* test(site-replication): pin ILM expiry merge contract for incoming lc-config

Red-light evidence for backlog#1675 P1-1: the lc-config receiver
overwrites the whole local lifecycle config with whatever the peer
sends (and deletes it wholesale on peer delete), so an expiry-only
document erases the receiver's local tier/transition rules, and peer
transition rules get installed across sites. The new tests pin the
MinIO mergeWithCurrentLCConfig semantics plus RustFS hardening:

- incoming expiry documents merge with (never replace) local rules
- local transition sides are authoritative for same-id rules
- incoming transition fields are discarded at the trust boundary
- dropped expiry rules strip the expiry side but keep transitions;
  pure-expiry rules are removed
- delete merges with the empty set instead of dropping the config
- disabled rules survive; abort-mpu-only rules stay site-local
- deterministic order (idempotent re-delivery) and expiry_updated_at
  stamping for the staleness axis

All fail against the current overwrite implementation (identity
extraction of merge_incoming_lifecycle_config).

* fix(site-replication): merge incoming ILM expiry documents instead of overwriting

The lc-config receiver replaced the whole local lifecycle config with
the peer's document (and deleted it wholesale on peer delete), so an
expiry-only update erased the receiver's local tier/transition rules,
and a peer's transition rules were installed across sites
(backlog#1675 P1-1).

Receiver (apply_bucket_meta_item):
- lc-config now merges via merge_incoming_lifecycle_config, mirroring
  MinIO's mergeWithCurrentLCConfig with a trust-boundary hardening:
  incoming transition fields are discarded outright; the local
  transition side of a same-id rule is authoritative. A peer delete
  merges with the empty set — pure-expiry rules go away, transition
  rules survive with their expiry side cleared, and only an empty
  result deletes the config file.
- Staleness moves to the expiry axis (config.expiry_updated_at):
  lifecycle_config_updated_at also moves on local transition-only
  edits, which shadowed newer peer expiry updates.
- Receiver-side replicateILMExpiry gate, symmetric with the sender
  hook (previously any peer could install expiry rules while the
  option was off).
- Rule order is deterministic (local order, incoming-new appended), so
  re-delivering the same document is byte-stable and does not rewrite
  bucket metadata per broadcast.

Sender:
- Both admin choke points — the bucket-meta hook and the SRInfo bucket
  entry feeding bootstrap/repair and consistency views — now emit only
  the expiry subset (transition fields stripped, non-expiry rules
  dropped). MinIO receivers install incoming rules verbatim, so
  transition rules must never leave the site. An unparseable local
  config is forwarded unfiltered rather than degraded to a delete.

Not covered here (follow-up): a two-site e2e with a real tier backend
to exercise transition-rule preservation end to end; receiver-side
validate_transition_tier for merged configs.

* fix(site-replication): close ILM merge review findings

Adversarial review of the lc-config merge surfaced four real defects,
all fixed here:

- Deletion tombstone regression: with the staleness axis moved to the
  in-config expiry_updated_at, a deleted lifecycle config fell back to
  UNIX_EPOCH and any delayed stale broadcast could resurrect deleted
  expiry rules. The axis now falls back to the whole-config write time
  (which survives deletion in bucket metadata as the deletion's lower
  bound), also covering legacy configs that predate the axis field.
- MinIO zero-rule documents: MinIO's delete tombstone / transition-only
  state marshals a lifecycle document with no <Rule>, which the strict
  s3s deserializer rejects — the receiver now recognizes it as the 'no
  expiry rules here' statement (delete semantics) instead of erroring
  on every MinIO heal pass.
- Inflated expiry axis at the sender: PutBucketLifecycle stamped
  expiry_updated_at unconditionally, so a transition-only edit advanced
  the axis and let this site's stale expiry subset shadow and roll back
  newer peer expiry edits fleet-wide. The stamp is now conditional
  (expiry subset present before or after the edit, MinIO parity), the
  hook item travels with the config's expiry axis (UNIX_EPOCH when the
  site has none), and the SRInfo bucket entry feeds bootstrap/repair
  the same axis instead of the whole-config write time.
- Del-marker parity: MinIO's CloneNonTransition never emits del-marker
  or abort-mpu fields, so treating del_marker_expiration as traveling
  expiry let a MinIO broadcast delete this site's del-marker-only
  rules. Both fields are now site-local on every edge: stripped from
  outbound subsets and inbound rules, restored from the local side on
  same-id merges, and never a deletion criterion.

Receiver-side validation of merged configs (object-lock / tier
constraints, MinIO runs finalLcCfg.Validate) remains a follow-up.

* fix(site-replication): close the second ILM review round

- Missed-delete repair: a deleted expiry state now travels through
  bootstrap/repair as an explicit timestamped lc-config delete item
  (lifecycle_expiry_statement distinguishes deletion — whole-config
  write time advanced past the created backfill — from never-configured
  buckets and from transition-only configs without an expiry axis,
  which say nothing). A peer that missed the live delete converges on
  repair; the receiver's staleness guard protects newer peer state.
- Strict tombstone recognition: only a well-delimited zero-rule
  <LifecycleConfiguration> document maps to delete semantics; truncated
  or foreign payloads that fail the strict deserializer are rejected
  instead of being treated as a delete that erases local expiry rules.
- Staleness fallback axis narrowed: the whole-config write time is used
  only for deleted or legacy-with-expiry state. A present
  transition-only config without an expiry axis compares at epoch — its
  whole-config time moves on transition edits and must not shadow or
  block independent peer expiry updates and same-timestamp repairs.

* fix(site-replication): validate tombstone children structurally

Second review round: a well-delimited root could still smuggle
malformed content — e.g. <LifecycleConfiguration><ExpiryUpdatedAt>
</LifecycleConfiguration> passed the no-<Rule check and was applied as
a delete. The tombstone body must now be a sequence of well-formed
simple children (matching open/close or self-closing, no nested markup,
no stray text, none named Rule); anything else surfaces InvalidRequest.
Malformed-child cases pinned in the recognition test.

* fix(site-replication): serialize lifecycle merges

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-08-16 05:34:17 +08:00
唐小鸭 cfa9276fad fix(admin): serialize replication metrics in minio-go wire shapes (#6127)
* test(admin): pin minio-go Metrics/MetricsV2 wire contract for replication metrics

Red-light evidence for backlog#1675 P1-11: ?replication-metrics[=2]
serializes the internal snake_case BucketStats family straight onto the
wire, while minio-go's replication.Metrics/MetricsV2 expect camelCase
tags (currStats/queueStats/replicaCount/queued/...). Go's decoder is
case-insensitive but does not ignore underscores, so 'mc replicate
status' shows all zeros without any error. The rewritten snapshot tests
assert the minio-go tags (plus a synthesized queueStats node — the
aggregation path leaves queue_stats.nodes empty today) and fail against
the current pass-through serialization.

* fix(admin): serialize replication metrics in minio-go wire shapes

?replication-metrics[=2] and the admin replicationmetrics endpoint
serialized the internal snake_case BucketStats family straight onto the
wire, so 'mc replicate status' decoded all zeros without any error
(backlog#1675 P1-11). The internal structs cannot be renamed: they are
the intra-cluster peer-RPC wire format (rmp_serde to_vec_named in
node_service.rs), pinned by a new regression test.

- New admin/replication_metrics_wire.rs: Serialize-only projections onto
  minio-go replication.Metrics (v1 body, currStats) and MetricsV2
  (uptime/currStats/queueStats/downtimeInfo) with the exact json tags;
  per-target failed becomes the TimedErrStats envelope fed from the
  FailStats rolling window; the queue peak is dual-emitted as max
  (MinIO server tag) and peak (minio-go tag).
- queueStats synthesizes one node from the bucket queue snapshot — the
  aggregation path leaves queue_stats.nodes empty, and mc treats an
  empty node list as 'no data' — and carries transfer summaries
  (Large/Small/Total) derived from the per-target xfer rates.
- Both endpoints share the DTOs; source-health extension keys
  (provider_available/cluster_complete/...) ride along and are ignored
  by Go decoders.
- Widen the ecstore replication_stats_boundary re-exports
  (BucketReplicationStat/InQueueMetric/XferStats) so the admin facade
  chain can name the projected types.

* fix(replication): carry failure rolling windows through cluster aggregation

Review: both metrics endpoints aggregate first, and FailStats::merge
dropped the process-local samples (which also never cross the peer-RPC
wire — serde-skipped), so lastMinute/lastHour serialized as zero right
after a failure while totals was nonzero.

- FailStats gains serializable last_minute/last_hour window snapshots
  (serde default: old nodes read zeros, new fields are ignored by old
  decoders), recomputed on every add_size and re-stamped at the
  per-node collection point (get_latest_replication_stats), and summed
  by merge.
- The wire DTO takes the component-wise max of the live samples and the
  snapshot, so both the single-node and the aggregated path report the
  window.
- Regression test drives a stat through rmp round trip + merge before
  serialization, as requested.

Also restore the #[allow(dead_code)] attribute to route_policy — the
new module declaration had been inserted between the attribute and its
item, which broke the -D warnings CI lanes.

* fix(replication): bin transfer summaries at 128 MiB and keep window refresh off the hot path

Second review round:

- update_xfer_rate split at 1 MiB while the minio-go transferSummary
  labels (and RustFS's own worker-pool split) mean >= 128 MiB for
  Large, so a 2 MiB replication reported under Large with Small stuck
  at zero. The producer now bins on MIN_LARGE_OBJ_SIZE; a MetricsV2
  assertion covers 2 MiB / 127 MiB / exactly 128 MiB.
- add_size no longer recomputes the rolling windows: two full
  one-hour-deque scans per failure under the bucket-stats write lock
  made failure bursts quadratic (30k events ~2.1s). The windows are
  stamped only at the collection point (get_latest_replication_stats,
  which serves both the local leg and the peer RPC); the aggregation
  regression now drives that path explicitly before the RPC round trip
  and merge.

* fix(replication): average transfer summaries

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-08-16 05:27:23 +08:00
Henry Guo db8f55cb97 feat(table-catalog): finalize Iceberg REST behavior (#6072)
* feat(table-catalog): finalize Iceberg REST behavior

* fix(table-catalog): address REST finalization regressions

* test(table-catalog): expect REST commit conflicts

* test(table-catalog): avoid serialized view test deadlocks

* fix(table-catalog): adapt shared test backend

* fix(table-catalog): enforce Iceberg metadata invariants

* fix(table-catalog): preserve manifest length in test

* test(table-catalog): use valid metadata fixtures

* test(table-catalog): seed manifests before manifest lists

* fix(table-catalog): restore validation gates

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-08-16 03:05:09 +08:00
houseme 7f23a1ba91 feat(ecstore): report inline early-stop miss reasons (#6134)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-15 17:18:26 +00:00
Henry Guo 1619c4be60 fix(scanner): add context to corrupt metadata logs (#6099)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-08-15 21:32:05 +08:00
Zhengchao An 72fd7339c9 test(utils): allow ephemeral port reuse (#6122)
* test(utils): allow ephemeral port reuse

* test(kms): allow any ciphertext prefix
2026-08-15 08:32:10 +08:00
Zhengchao An 71e83aeec4 fix(ci): pin Docker images to release source (#6121) 2026-08-15 07:13:37 +08:00
唐小鸭 9138c24571 fix(site-replication): lift a rejoined site's restarted edit counter over stale marks (#6119)
fix(site-replication): lift a rejoined site's restarted edit counter over stale fence marks

A site removed while unreachable (unilateral removal: the receiver never
dropped it from its peer map, so parse_site_replication_state's load-time
mark pruning never fired) that later rejoins recreates its state object
and restarts edit_generation at zero. The receiver's surviving high-water
mark then silently fences out every stamped delivery from that origin —
peer edits and the add finalize fan-out alike are acked without applying
— until the restarted counter catches up.

Allocate the generation as a hybrid logical clock instead:
max(wall clock in unix nanoseconds, previous + 1), still inside the state
transaction under the distributed state-object lock. Every value a
lifetime hands out is capped by the wall clock at its own allocation, so
a recreated lifetime's first allocation exceeds them all and clears the
stale mark, while a pre-removal delivery still in flight stays below the
new floor and remains correctly fenced. previous+1 keeps allocations
strictly increasing across same-tick allocations and mid-lifetime clock
regressions.

Nothing changes on the wire or in the persisted schema: editGeneration
stays the single fence param and edit_generation the single counter
field, so pre-hybrid receivers get the fix as soon as the sender
upgrades, old binaries preserve the field across rolling up/downgrades,
and marks recorded by plain-counter receivers (small values) are cleared
by any wall-clock allocation. A clock that regresses across a
delete/recreate degrades to a fence that self-heals once real time
passes the previous lifetime's last allocation, and introduces no
rollback window beyond what the plain counter already had.

An epoch-based design (editEpoch wire param + per-origin epoch marks)
was built first and rejected under adversarial review: old binaries
rewriting the state object drop the unknown epoch fields, which both
disarms the fix mid-rolling-upgrade and — because epoch adoption lowers
the generation mark — reopens the pre-restart rollback the fence exists
to prevent; a backwards clock also fences an origin permanently instead
of self-healing. The hybrid clock has none of these modes.
2026-08-15 01:50:35 +08:00
Zhengchao An e9f5318027 chore(release): prepare 1.0.0-rc.2 2026-08-15 01:29:08 +08:00
Zhengchao An 69e8ef9af5 test(sse): align KMS context error assertion (#6118) 2026-08-15 00:44:36 +08:00
Henry Guo 0b2a46b36f fix(log-analyzer): remove stale heal manager anchor (#6100) 2026-08-14 23:49:31 +08:00
Zhengchao An 56509ead1f fix(ci): remove unused ecstore error conversion (#6117) 2026-08-14 23:15:08 +08:00
Zhengchao An ffe889ad59 fix(storage): restore multipart disk compression and make the legacy decompressor resumable (#6044)
* fix(storage): restore multipart disk compression and make the legacy decompressor resumable

Multipart uploads have bypassed disk compression since #5169 removed the session marker as a stopgap for mid-stream GET failures. The actual root cause was never the multipart layout: the legacy DecompressReader reset its payload consumption state on every poll re-entry, so a Poll::Pending in the middle of a block payload (routine under the erasure duplex) desynchronized the block framing and surfaced as LZ4 frameType errors. This rewrites the decoder as a resumable state machine, restores the multipart session compression marker, reports logical part sizes in ListParts, and makes the rebalance migration read raw stored bytes so compressed and encrypted objects survive migration verbatim.

Fixes #5957. Internal tracking: backlog#1848, backlog#1850.

* feat(storage): stage multipart compression behind RUSTFS_COMPRESSION_MULTIPART_ENABLED

Review follow-up: a rolling-upgrade window must not create new compressed multipart objects while pre-fix nodes (whose decompressor is not resumable) may still serve reads. The session marker is now additionally gated on RUSTFS_COMPRESSION_MULTIPART_ENABLED, default off, so the restored capability stays dark until the operator confirms fleet convergence. The default flips per the multipart-compression-default-off-window entry in docs/architecture/compat-cleanup-register.md once the minimum supported direct-upgrade release ships the resumable decoder.

* chore(compat): satisfy the cleanup-register guard for the multipart compression switch

The architecture guard requires every backticked identifier in a register entry to carry a RUSTFS_COMPAT_TODO source marker: keep only the entry slug in backticks, and add the marker (with its literal Remove-after condition) at the switch definition.

* chore(rio): drop a dead store in the poison guard and note the end-block branch

Review follow-up: the poison gate re-assigned an already-true flag, and the COMPRESS_TYPE_END branch reads as dead without stating that the writer never emits an end block — that absence is exactly what lets concatenated per-part streams decode as one.

* fix(s3): report empty compressed multipart part size

* fix(s3): report empty encrypted multipart part size
2026-08-14 22:14:26 +08:00
唐小鸭 e11ce2f132 fix(site-replication): route every state RMW through the locked transaction (#6097)
* fix(site-replication): route every state RMW through the locked transaction

P1-15 PR2 (rustfs/backlog#1796, batch B2 of rustfs/backlog#1675), the
follow-up promised by rustfs/rustfs#5882.

PR1 left ~26 read-modify-write call sites on
config/site-replication/state.json in the pre-transaction shape: a
process-local mutex around load / mutate / save, each IO taking its own
object lock. Nothing held a distributed lock across the whole sequence, so
two nodes of one site still lost each other's updates, and the transitional
mutex kept the old shape available to copy.

Every remaining RMW now runs inside update_site_replication_state;
read-only sites use load_site_replication_state, whose object read comes
with the object-level read lock. SITE_REPLICATION_STATE_LOCK and its owner
helper are gone, together with their architecture-guard allowlist entry and
inventory row.

The multi-stage flows (add / edit / peer join / peer edit / remove / rotate)
keep their updated_at and pending-id CAS, but the CAS now runs inside the
transaction that writes, against the state that transaction loaded. Peer
probes, IAM work and fan-outs run between transactions and hold no lock at
all — the add no longer blocks every writer of the site across its peer join
round trips, and it re-checks the precondition right after the capability
probes so the common race is rejected before any IAM write or remote join.
When the add's commit CAS still fails, the error says the peers may already
be joined and that re-running the add reconverges. The add adopts only the
fields it computed (exhaustive destructure — adding a state field is a
compile error until classified); fields owned by writers that do not bump
updated_at keep their freshly loaded values.

Ordering of peer-edit deliveries now rests on the generation fence landed in
PR1 rather than on a guard that could never order two nodes: the add's
finalize fan-out carries the generation allocated in its commit. An accepted
peer join PRESERVES the applied-generation high-water marks — join fan-outs
are routine (adds and rotations both deliver SRPeerJoin to existing peers),
so wiping them would let stalled older edits land after any join; the
unilateral-removal rejoin misfence that a wipe would have patched is
pre-existing since the fence landed and needs an epoch in the fence instead.

The rotation handler now takes the lifecycle guard: the background
service-account reconciler runs its repair under a lifecycle try-acquire,
and its pending-rotation precheck is only sound if a rotation cannot start
mid-repair — an exclusion the removed process mutex used to provide as a
side effect.

update_site_replication_state_when_changed adds persist-or-skip so ack
markers and pending-clearing paths stop rewriting the object on a miss —
load-bearing, because the shared persist helper clears the whole object for
a ≤1-peer pending-free state — and save_site_replication_state is now
cfg(test): the pre-P1-15 shape can no longer be written in production code.

No on-disk format change.

Verification: cargo nextest run -p rustfs -E
'test(/admin::handlers::site_replication::/)' (181 passed); site-replication
dual/three-node e2e (13 passed); cargo clippy -p rustfs --all-targets -D
warnings; make pre-commit. Mutation checks: dropping the state-object lock
from the boundary reds the separate-node concurrency tests; flipping a
persist-or-skip miss to a persist reds
test_missed_pending_clear_must_not_rewrite_the_state_object. Reviewed by
three independent adversarial passes (correctness/concurrency,
security/compatibility, simplicity/test-coverage); their confirmed findings
are folded in.

* fix(site-replication): serialize peer-join admission around its IAM write

Review follow-up (overtrue): two joins accepted by the same node could
interleave as "A checks a stale snapshot and pauses reading its body, B
applies secret B and commits, A resumes, overwrites IAM with secret A, and
A's commit is refused as superseded" — the persisted state advertised B's
contract while IAM only accepted A's secret, failing every peer
control-plane call. The pre-P1-15 process mutex serialized same-node joins
end to end; removing it dropped that exclusion.

admit_peer_join now runs the staleness check, the IAM upsert and the state
commit under the lifecycle guard, with the authoritative pre-check taken
against a load under that guard BEFORE IAM changes anything. The closing
transaction still re-checks staleness: the guard is process-local (exactly
as far as the old mutex reached) and the state-object lock arbitrates joins
accepted by different nodes. The body is fully read before the guard so a
stalling sender cannot block add/remove/rotate/reconciler.

The IAM step is injected, and the gated-body regression test reproduces the
review's ordering: join A is held mid-IAM while a newer join B arrives; B
must wait at the guard, and both IAM order and the final persisted state end
on B. Mutation-verified: removing the lifecycle guard from admit_peer_join
turns the test red.

Verification: cargo nextest run -p rustfs -E
'test(/admin::handlers::site_replication::/)' (182 passed);
site-replication dual/three-node e2e (13 passed); cargo clippy -p rustfs
--all-targets -D warnings; make pre-commit.

* fix(site-replication): fence peer-join admission across nodes

Review follow-up (overtrue, round 2): the lifecycle guard only serializes
joins within one process. Node A could pass the staleness check for an
older T1, node B write secret B to IAM and commit a newer T2, and node A
then overwrite IAM with secret A while its own state commit is refused as
superseded — state advertising T2's contract while IAM only accepts A's
secret.

The admission (staleness check -> IAM upsert -> state commit) now also runs
under a distributed join-admission lock, a namespace-lock key with no
backing object, following the repair execution lock's pattern — including
its nesting of config-object locks (admission -> state), and delegating
crash safety to the lock subsystem's lease expiry instead of a hand-rolled
TTL. The staleness check runs against a load taken inside the lock, before
IAM changes anything, so a superseded join exits without touching IAM. The
closing transaction keeps its re-check for defence in depth and for
old-version nodes that do not take the admission lock during a rolling
upgrade (that mixed-version window keeps today's behavior and closes when
the upgrade completes).

admit_peer_join_across_nodes is the admission minus the process-local
lifecycle guard — exactly what a second node runs — and the new
separate-nodes regression test drives it directly with join A gated
mid-IAM: join B must wait at the distributed lock, and both the IAM write
order and the final persisted state end on B. Mutation-verified: removing
the admission lock turns the test red while the same-node test (which
drives the full admit_peer_join) stays green.

Verification: cargo nextest run -p rustfs -E
'test(/admin::handlers::site_replication::/)' (183 passed);
site-replication dual/three-node e2e (13 passed); cargo clippy -p rustfs
--all-targets -D warnings; make pre-commit.
2026-08-14 22:13:37 +08:00
cxymds eca6bc1600 fix(ecstore): preserve CopyObject producer errors (#6090)
* fix(ecstore): preserve CopyObject producer errors

* fix(app): resume preserved relocation I/O errors

* fix(copy): preserve transformed source errors
2026-08-14 14:12:42 +00:00
cxymds 85be26b3c1 test(ecstore): cover cancelled PUT tmp cleanup (#6105) 2026-08-14 14:07:44 +00:00
Zhengchao An ebbcfa3ac2 fix(tier): decrypt transitioned objects instead of serving their ciphertext (#6107)
* fix(tier): decrypt transitioned objects instead of serving their ciphertext

A GET on a managed-SSE object that lifecycle had transitioned to a remote tier returned the ciphertext with the plaintext's Content-Length and no error: silent corruption on read-through, and worse than a failed request because nothing signals it. Restore of the same object failed server-side with IncompleteBody while POST ?restore still answered 200, so the object simply never came back and HEAD never showed an x-amz-restore marker.

Both symptoms are one cause. The transitioned read path built its fetch through new_getobjectreader, which decides nothing about encryption: it derived the range from the parts table — whose sizes are PLAINTEXT sizes — then used that range to fetch the object's STORED bytes from the tier, and handed the stream to the caller without any decrypt transform. The GET therefore served the first plaintext-length bytes of ciphertext; the restore copy-back, which validates against the stored size, came up short by exactly the encryption overhead.

The path now builds the same ReadPlan the local read path uses, so a single place decides how stored bytes map to requested bytes. ReadPlan gains a two-phase API — build_for_request to learn the storage coordinates before issuing the tier fetch, into_object_reader to wrap the returned stream — because the tier fetch has to be positioned before a stream exists. The encryption resolver reaches the path from InstanceContext, the same source the local read uses.

A restore read additionally stops synthesizing a range from the part number. A restore serves the stored representation (restore_request_active already forces the Plain branch), so a plaintext-coordinate range would be reinterpreted as a storage range and truncate the payload by its encoding overhead. An explicit caller range is already in storage coordinates on that path and is still honored, which two existing tests pin.

crates/e2e_test/src/kms/kms_ilm_sse_kms_test.rs drops its #[ignore]: the transition test now runs and asserts the plaintext round-trips byte-identically through transition, read-through and restore. The same file had its enforcement switch stuck at false from a control experiment; it is back to true, so the test again exercises what its name and module docs claim.

Fixes #6025. Refs rustfs/backlog#1582, rustfs/backlog#1637.

* test(tier): pass resolver to transitioned reader tests
2026-08-14 21:59:38 +08:00
Zhengchao An ebd0531124 chore(ecstore): drop the data_usage dead_code blanket (#6089)
Removing the blanket exposes eighteen items. Six are deleted; the rest belong to one feature that was never wired up.

crates/ecstore/src/data_usage/local_snapshot.rs and its aggregation entry point, aggregate_local_snapshots, form a complete per-disk usage-snapshot feature: read/write of snapshot files under the metadata bucket, cross-disk aggregation, and tests. Nothing calls it. It landed in #5307 on 2026-07-27 and git log -S over the whole history shows the entry point has never had a caller; the live data-usage path is load_data_usage_from_backend / store_data_usage_in_backend. Rather than delete a recent, tested feature on cleanup grounds, the module gets a header explaining the situation and its items carry individual allows, so the gap stays greppable without reintroducing a blanket. One commit flips this to deletion if that is preferred.

Deleted:

- DATA_USAGE_BLOOM_NAME together with DATA_USAGE_BLOOM_NAME_PATH. Both are a dead duplicate of the pair in crates/scanner/src/data_usage_define.rs, which has roughly fifty consumers across scanner.rs and remote_scanner.rs; the ecstore copies have none.
- increment_bucket_usage_memory and decrement_bucket_usage_memory, thin wrappers over the live record_bucket_object_write_memory and record_bucket_object_delete_memory.
- sync_memory_cache_with_backend, whose doc comment says it is "called by scanner" — nothing calls it.
- create_cache_entry_from_summary and cache_to_data_usage_info, neither with a consumer in any lane.

resolve_loaded_snapshot is kept with an allow: nine tests in the same file assert its primary/backup fallback.

Verification, four lanes warning-free: default, --tests, --features rio-v2 --tests, --features test-util --tests. rustfs-scanner still compiles clean. cargo nextest run -p rustfs-ecstore 4041 passed; clippy --lib --tests -D warnings clean; make pre-commit exit 0.

Ref rustfs/backlog#1823 (step 2).
2026-08-14 21:57:29 +08:00
Zhengchao An 4421d4829f test(table-catalog): share the store test doubles and fold three commit-rejection cases (#6076)
Completes PR3 of the issue.

Store doubles: NoopTableCatalogStore (193 lines, a pure stub answering "nothing here") and TestTableCatalogStore (416 lines, a stateful fake with commit pauses and failure injection) move into test_support.rs, along with TestCatalogPublishPause which the latter needs. Per the issue's ruling both shapes are kept — they are different tools, not duplicates of each other. Being honest about the benefit: this does not reduce the number of TableCatalogStore implementations, it puts both in one file so a trait change is one file to edit instead of two.

row_level_conflict fold: rejects_stale_new_manifest_sequence, rejects_stale_added_entry_sequence, and rejects_historical_change_in_new_manifest were identical apart from four literals (manifest-list sequence, data-file name, manifest-entry snapshot id, failure message). They become one table-driven test with three rows, each row keeping its original values, and every assertion carries the case name.

Verification: cargo test -p rustfs --lib table_catalog 479 passed and --lib admin::handlers::table_catalog 165 passed (both down exactly 2 from the 3->1 fold; the store filter is a substring match that also covers the admin tests); clippy --lib --tests -D warnings clean; make pre-commit green.

Ref rustfs/backlog#1837 (PR3).
2026-08-14 21:56:20 +08:00
Zhengchao An d6c62b9601 chore(ecstore): drop the services dead_code blanket (#6103)
Removing the blanket exposes twenty-five items across tier, notification and rebalance. Only eight are deleted — the lowest ratio of this burn-down so far, and the reason is that these subsystems carry heavy test coverage, so the blanket was mostly hiding test-only seams rather than dead weight.

Deleted:

- crates/ecstore/src/services/tier/warm_backend_s3sdk.rs entirely (200 lines). Its WarmBackendS3 is never constructed; the type of the same name in warm_backend_s3.rs is the live one, wrapped by the Azure backend. Two implementations of one S3 warm backend, one of them never wired.
- TierConfigMgr::begin_publish_transition and publish_candidate_inner, thin wrappers whose _with_allowed_mutation_blocks siblings carry every real caller, plus retire_driver.
- The GCS backend's MAX_MULTIPART_PUT_OBJECT_SIZE, MAX_PARTS_COUNT and MIN_PART_SIZE, and its write-only storage_class field.
- mark_started_rebalance_pools_stopped and the RStats alias.

Two deletions were withdrawn after a per-name grep, both because of an inference rather than a check:

AsyncBatchProcessor::new was deleted on the strength of grepping only BATCH_PROCESSOR_OPERATION_CUSTOM, whose two hits are its definition and its use inside new. That looked like a self-contained dead pair; new in fact has seven test callers. The warning listed both items, and only one of them was actually checked.

Deleting the two dead publish wrappers then revealed a second layer — publish_candidate_owned, remove_and_save_with, clear_and_save_with, save_tiering_config_if_current. These are not dead: publish_candidate, their caller, is #[cfg(test)], so a callee that lives in the main body has no caller in the lib build and a live one in the test build. rustc reports the roots of a dead subgraph, and the next layer down can have a different character, so each layer needs its own grep.

Kept with allows: the tier mutation-intent record helpers (asserted by store::init tests), affected_targets, tier_object_blocks_target_rebind, the rebalance snapshot and retry-wait helpers, notification_sys's tier_config_reload_worker_active and call_peer_with_timeout, and active_operation_lease_count, whose only caller sits behind #[cfg(feature = "test-util")].

Also kept, with a module note rather than removal: the ecstore-side EventNotifier. All four of its methods are unreachable and init_bucket_targets logs that it is a no-op in this build; the working stack is rustfs-notify, whose own EventNotifier drives bucket configuration. Removing it means also retiring the InstanceContext slot that holds it (backlog#939 Phase 5), which belongs in its own PR.

Worth a separate issue: MAX_MULTIPART_PUT_OBJECT_SIZE, MAX_PARTS_COUNT and MIN_PART_SIZE are declared independently in eight warm-backend files plus client/constants.rs. Only the GCS copies were dead; the other seven backends each use their own.

Verification, four lanes warning-free: default, --tests, --features rio-v2 --tests, --features test-util --tests. cargo nextest run -p rustfs-ecstore 4041 passed; clippy --lib --tests -D warnings clean; make pre-commit exit 0.

Ref rustfs/backlog#1823 (step 2).
2026-08-14 21:56:11 +08:00
cxymds d91086d094 test(scanner): refresh metadata after fixture mutation (#6113) 2026-08-14 13:37:51 +00:00
Zhengchao An 69719c257e chore(ecstore): remove the pool-level ListObjects pagination copy (#6078)
* chore(ecstore): remove the pool-level ListObjects pagination copy

The ListObjects pagination pipeline existed in three near-copies in one file; production listing never reaches the Sets copy, which ECStore bypasses by expanding straight to per-set disks. This removes it: impl ListOperations for Sets (61 lines of pure forwarding in core/sets.rs) and the impl Sets pagination block (826 lines of inner_list_objects_v2 / list_objects_generic / inner_list_object_versions / list_path / list_merged / walk_internal in store/list_objects.rs).

Two preconditions verified before deleting rather than taken on faith: the architecture guard pins only set_disks_implements_storage_list_operations_contract, so nothing requires the Sets trait impl; and the four Sets pagination methods had no cross-file caller besides that trait impl.

The single test consumer moves to the surviving pipeline instead of being deleted: writes still go through the pool, and the listing assertion now targets the set-level implementation. It is renamed accordingly so the name still describes what it covers.

The logging guardrail's TRACE-only requirement for Sets::list_objects_v2 retires in the same diff — the wrapper it pinned no longer exists. The ECStore and SetDisks entries are untouched.

The SetDisks copy stays for now: its trait impl is guard-pinned, so replacing the duplicate pipeline behind it needs the generic helper the issue schedules for post-1.0.

Verification: cargo nextest run -p rustfs-ecstore 4020 passed; check_architecture_migration_rules.sh and check_logging_guardrails.sh pass; clippy --lib --tests -D warnings clean; make pre-commit green.

Ref rustfs/backlog#1821 (PR1).

* chore(ecstore): fold the ListObjects forwarders into the ECStore impl

store/list.rs held two thin forwarders, handle_list_objects_v2 and handle_list_object_versions, that only re-entered the inner_* implementations. The ListOperations impl now calls those directly and the file goes away.

The logging guardrail's trace_hot_spans list pinned handle_list_objects_v2 as TRACE-only; that entry is retired in the same diff, adjacent to the sets.rs entry retired by the preceding commit.

Ref rustfs/backlog#1821.

* chore(ecstore): drop the type aliases orphaned by the pagination removal

core/sets.rs declared four local type aliases — ListObjectsV2Info, ListObjectVersionsInfo, ObjectInfoOrErr and WalkOptions — used only by the pool-level pagination pipeline removed earlier in this branch. store/list_objects.rs keeps its own live copies of the same aliases.

They only surface now that #6087 removed the core module's dead_code blanket: on that older base each PR was warning-free on its own, and the combination is what exposes them. Their storage_api_contracts imports go with them.

Ref rustfs/backlog#1823, rustfs/backlog#1821.

* fix(ecstore): preserve Sets listing compatibility
2026-08-14 13:19:06 +00:00
cxymds 67a19021b5 fix(ecstore): allow migrated unknown part sizes (#6112) 2026-08-14 21:00:01 +08:00
houseme 0ff3d4cbf4 perf(ecstore): borrow rename metadata during commit fanout (#6104)
* perf(ecstore): borrow rename metadata during commit fanout

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

* fix(ecstore): preserve rename_data API compatibility

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-08-14 11:56:36 +00:00
cxymds 6f29431a65 test(ecstore): isolate rename publication hooks (#6106) 2026-08-14 11:07:34 +00:00
houseme 5a4c063d16 perf(get): avoid materialized body clone (#6109)
Stream materialized GET bodies by moving the buffered Bytes once instead of wrapping the stream in an extra bytes_stream layer.

Add an operations runbook for object I/O tuning A/B sweeps.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-14 17:25:10 +08:00
houseme e2be34cade test(rpc): align snapshot lease missing-disk expectation (#6108)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-14 16:59:21 +08:00
cxymds d60a77b750 fix(quota): enforce durable hard quota reservations (#6058)
* fix(quota): enforce durable hard quota reservations

* fix(quota): close reservation bypasses

* fix(quota): isolate tests and box object futures

* fix(quota): close legacy and deferred settlement bypasses

* fix(app): keep object futures off caller stacks

* fix(metrics): preserve object operation labels

* fix(logging): retain GET trace guard contract
2026-08-14 06:26:00 +00:00
154 changed files with 21873 additions and 5340 deletions
+7 -1
View File
@@ -252,10 +252,16 @@ test-group = 'ecstore-serial-flaky'
# cluster, so it keeps the lane's parallel-safe / no-external-dependency # cluster, so it keeps the lane's parallel-safe / no-external-dependency
# properties. The RustFS warm backend has no loopback guard (that guard is # properties. The RustFS warm backend has no loopback guard (that guard is
# replication-only), so it needs no opt-in env for its 127.0.0.1 tier target. # replication-only), so it needs no opt-in env for its 127.0.0.1 tier target.
#
# Disk compression (backlog#1848): the `compression` module joins the smoke
# lane so the multipart disk-compression roundtrips (restored after
# rustfs/rustfs#5169 disabled them) have PR-lane signal, not just merge-gate.
# Single-node servers on random ports with isolated temp dirs — meets the
# admission criteria unchanged.
[profile.e2e-smoke] [profile.e2e-smoke]
default-filter = """ default-filter = """
package(e2e_test) & ( package(e2e_test) & (
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|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)_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|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)_test::|^fake_s3_target::/)
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/) | test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/) | test(/^reliant::lifecycle::/)
| test(/^reliant::tiering::/) | test(/^reliant::tiering::/)
+25 -1
View File
@@ -94,6 +94,7 @@ jobs:
short_sha: ${{ steps.check.outputs.short_sha }} short_sha: ${{ steps.check.outputs.short_sha }}
is_prerelease: ${{ steps.check.outputs.is_prerelease }} is_prerelease: ${{ steps.check.outputs.is_prerelease }}
create_latest: ${{ steps.check.outputs.create_latest }} create_latest: ${{ steps.check.outputs.create_latest }}
source_ref: ${{ steps.check.outputs.source_ref }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -118,6 +119,7 @@ jobs:
short_sha="" short_sha=""
is_prerelease=false is_prerelease=false
create_latest=false create_latest=false
source_ref="$GITHUB_SHA"
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
# Triggered by build workflow completion # Triggered by build workflow completion
@@ -137,6 +139,7 @@ jobs:
# Extract version info from commit message or use commit SHA # Extract version info from commit message or use commit SHA
# Use Git to generate consistent short SHA (ensures uniqueness like build.yml) # Use Git to generate consistent short SHA (ensures uniqueness like build.yml)
short_sha=$(git rev-parse --short "$HEAD_SHA") short_sha=$(git rev-parse --short "$HEAD_SHA")
source_ref="$HEAD_SHA"
# Determine build type based on triggering workflow event and ref # Determine build type based on triggering workflow event and ref
triggering_event="$TRIGGERING_EVENT" triggering_event="$TRIGGERING_EVENT"
@@ -261,6 +264,23 @@ jobs:
echo "⚠️ Only release versions (latest, v1.0.0, 1.0.0) and prereleases (v1.0.0-alpha1, 1.0.0-beta2) are supported" echo "⚠️ Only release versions (latest, v1.0.0, 1.0.0) and prereleases (v1.0.0-alpha1, 1.0.0-beta2) are supported"
;; ;;
esac esac
if [[ "$should_build" == true && "$input_version" != "latest" ]]; then
tag_ref="refs/tags/$input_version"
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
if [[ "$input_version" == v* ]]; then
tag_ref="refs/tags/${input_version#v}"
else
tag_ref="refs/tags/v$input_version"
fi
fi
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
echo "❌ Release tag not found for Docker build: $input_version"
exit 1
fi
source_ref="$tag_ref"
fi
fi fi
{ {
@@ -271,6 +291,7 @@ jobs:
echo "short_sha=$short_sha" echo "short_sha=$short_sha"
echo "is_prerelease=$is_prerelease" echo "is_prerelease=$is_prerelease"
echo "create_latest=$create_latest" echo "create_latest=$create_latest"
echo "source_ref=$source_ref"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
echo "🐳 Docker Build Summary:" echo "🐳 Docker Build Summary:"
@@ -281,6 +302,7 @@ jobs:
echo " - Short SHA: $short_sha" echo " - Short SHA: $short_sha"
echo " - Is prerelease: $is_prerelease" echo " - Is prerelease: $is_prerelease"
echo " - Create latest: $create_latest" echo " - Create latest: $create_latest"
echo " - Source ref: $source_ref"
# Build multi-arch Docker images # Build multi-arch Docker images
# Strategy: Build images using pre-built binaries from dl.rustfs.com # Strategy: Build images using pre-built binaries from dl.rustfs.com
@@ -308,6 +330,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
ref: ${{ needs.build-check.outputs.source_ref }}
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
@@ -397,7 +420,8 @@ jobs:
LABELS="org.opencontainers.image.title=RustFS" LABELS="org.opencontainers.image.title=RustFS"
LABELS="$LABELS,org.opencontainers.image.description=RustFS distributed object storage system" LABELS="$LABELS,org.opencontainers.image.description=RustFS distributed object storage system"
LABELS="$LABELS,org.opencontainers.image.version=$VERSION" LABELS="$LABELS,org.opencontainers.image.version=$VERSION"
LABELS="$LABELS,org.opencontainers.image.revision=${{ github.sha }}" SOURCE_REVISION="$(git rev-parse HEAD)"
LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"
LABELS="$LABELS,org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}" LABELS="$LABELS,org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}"
LABELS="$LABELS,org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" LABELS="$LABELS,org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
LABELS="$LABELS,org.opencontainers.image.build-type=$BUILD_TYPE" LABELS="$LABELS,org.opencontainers.image.build-type=$BUILD_TYPE"
Generated
+51 -47
View File
@@ -278,6 +278,7 @@ checksum = "312c1ea69e5fe9966e0029fb95aca8790100b85aff4f0d3b00a9337c74069a9c"
dependencies = [ dependencies = [
"bigdecimal", "bigdecimal",
"bon", "bon",
"crc32fast",
"digest 0.11.3", "digest 0.11.3",
"log", "log",
"miniz_oxide 0.9.1", "miniz_oxide 0.9.1",
@@ -289,9 +290,11 @@ dependencies = [
"serde", "serde",
"serde_bytes", "serde_bytes",
"serde_json", "serde_json",
"snap",
"strum", "strum",
"thiserror 2.0.20", "thiserror 2.0.20",
"uuid", "uuid",
"zstd",
] ]
[[package]] [[package]]
@@ -3761,7 +3764,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]] [[package]]
name = "e2e_test" name = "e2e_test"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"astral-tokio-tar", "astral-tokio-tar",
@@ -9090,7 +9093,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs" name = "rustfs"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@@ -9200,6 +9203,7 @@ dependencies = [
"serial_test", "serial_test",
"sha2 0.11.0", "sha2 0.11.0",
"shadow-rs", "shadow-rs",
"snap",
"socket2", "socket2",
"subtle", "subtle",
"sysinfo", "sysinfo",
@@ -9227,7 +9231,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-audit" name = "rustfs-audit"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"const-str", "const-str",
@@ -9250,7 +9254,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-checksums" name = "rustfs-checksums"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"base64-simd", "base64-simd",
"bytes", "bytes",
@@ -9266,7 +9270,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-common" name = "rustfs-common"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"hotpath", "hotpath",
@@ -9284,7 +9288,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-concurrency" name = "rustfs-concurrency"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"hotpath", "hotpath",
"insta", "insta",
@@ -9297,7 +9301,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-config" name = "rustfs-config"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"const-str", "const-str",
"hotpath", "hotpath",
@@ -9307,7 +9311,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-credentials" name = "rustfs-credentials"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"base64-simd", "base64-simd",
"hmac 0.13.0", "hmac 0.13.0",
@@ -9321,7 +9325,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-crypto" name = "rustfs-crypto"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"argon2", "argon2",
@@ -9342,7 +9346,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-data-usage" name = "rustfs-data-usage"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"hotpath", "hotpath",
"rmp-serde", "rmp-serde",
@@ -9352,7 +9356,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-ecstore" name = "rustfs-ecstore"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"async-channel", "async-channel",
@@ -9491,7 +9495,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-extension-schema" name = "rustfs-extension-schema"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"hotpath", "hotpath",
"serde", "serde",
@@ -9501,7 +9505,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-filemeta" name = "rustfs-filemeta"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"byteorder", "byteorder",
@@ -9528,7 +9532,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-heal" name = "rustfs-heal"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -9559,7 +9563,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-iam" name = "rustfs-iam"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"async-trait", "async-trait",
@@ -9600,7 +9604,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-io-core" name = "rustfs-io-core"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"bytes", "bytes",
"hotpath", "hotpath",
@@ -9613,7 +9617,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-io-metrics" name = "rustfs-io-metrics"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"criterion", "criterion",
"hotpath", "hotpath",
@@ -9677,7 +9681,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-keystone" name = "rustfs-keystone"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures", "futures",
@@ -9704,7 +9708,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-kms" name = "rustfs-kms"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@@ -9753,7 +9757,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-lifecycle" name = "rustfs-lifecycle"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"hotpath", "hotpath",
@@ -9776,7 +9780,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-lock" name = "rustfs-lock"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"compact_str", "compact_str",
@@ -9799,7 +9803,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-log-analyzer" name = "rustfs-log-analyzer"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"flate2", "flate2",
@@ -9818,7 +9822,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-madmin" name = "rustfs-madmin"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"hotpath", "hotpath",
"humantime", "humantime",
@@ -9833,7 +9837,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-notify" name = "rustfs-notify"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"async-trait", "async-trait",
@@ -9868,7 +9872,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-object-capacity" name = "rustfs-object-capacity"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"criterion", "criterion",
"futures", "futures",
@@ -9888,7 +9892,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-object-data-cache" name = "rustfs-object-data-cache"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"bytes", "bytes",
"criterion", "criterion",
@@ -9905,7 +9909,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-obs" name = "rustfs-obs"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"crossbeam-channel", "crossbeam-channel",
@@ -9960,7 +9964,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-policy" name = "rustfs-policy"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64-simd", "base64-simd",
@@ -9991,7 +9995,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-protocols" name = "rustfs-protocols"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"astral-tokio-tar", "astral-tokio-tar",
"async-compression", "async-compression",
@@ -10053,7 +10057,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-protos" name = "rustfs-protos"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"flatbuffers", "flatbuffers",
"hotpath", "hotpath",
@@ -10077,7 +10081,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-replication" name = "rustfs-replication"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"bytes", "bytes",
@@ -10095,7 +10099,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-rio" name = "rustfs-rio"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"arc-swap", "arc-swap",
@@ -10133,7 +10137,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-rio-v2" name = "rustfs-rio-v2"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -10156,7 +10160,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-s3-ops" name = "rustfs-s3-ops"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"hotpath", "hotpath",
"rustfs-s3-types", "rustfs-s3-types",
@@ -10164,7 +10168,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-s3-types" name = "rustfs-s3-types"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"hotpath", "hotpath",
"serde", "serde",
@@ -10173,7 +10177,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-s3select-api" name = "rustfs-s3select-api"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
@@ -10203,7 +10207,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-s3select-query" name = "rustfs-s3select-query"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"async-recursion", "async-recursion",
"async-trait", "async-trait",
@@ -10222,7 +10226,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-scanner" name = "rustfs-scanner"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
@@ -10262,7 +10266,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-security-governance" name = "rustfs-security-governance"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"hotpath", "hotpath",
"thiserror 2.0.20", "thiserror 2.0.20",
@@ -10270,7 +10274,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-signer" name = "rustfs-signer"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"base64-simd", "base64-simd",
"bytes", "bytes",
@@ -10288,7 +10292,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-storage-api" name = "rustfs-storage-api"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"hotpath", "hotpath",
@@ -10303,7 +10307,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-targets" name = "rustfs-targets"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"async-nats", "async-nats",
@@ -10357,7 +10361,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-test-utils" name = "rustfs-test-utils"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"hotpath", "hotpath",
"rustfs-data-usage", "rustfs-data-usage",
@@ -10373,7 +10377,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-tls-runtime" name = "rustfs-tls-runtime"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"hotpath", "hotpath",
@@ -10394,7 +10398,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-trusted-proxies" name = "rustfs-trusted-proxies"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -10431,7 +10435,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-utils" name = "rustfs-utils"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"base64-simd", "base64-simd",
"blake2", "blake2",
@@ -10473,7 +10477,7 @@ dependencies = [
[[package]] [[package]]
name = "rustfs-zip" name = "rustfs-zip"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
dependencies = [ dependencies = [
"astral-tokio-tar", "astral-tokio-tar",
"async-compression", "async-compression",
+48 -48
View File
@@ -69,7 +69,7 @@ edition = "2024"
license = "Apache-2.0" license = "Apache-2.0"
repository = "https://github.com/rustfs/rustfs" repository = "https://github.com/rustfs/rustfs"
rust-version = "1.97.1" rust-version = "1.97.1"
version = "1.0.0-rc.1" version = "1.0.0-rc.2"
homepage = "https://rustfs.com" homepage = "https://rustfs.com"
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. " description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"] keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
@@ -86,52 +86,52 @@ redundant_clone = "warn"
[workspace.dependencies] [workspace.dependencies]
# RustFS Internal Crates # RustFS Internal Crates
rustfs = { path = "./rustfs", version = "1.0.0-rc.1" } rustfs = { path = "./rustfs", version = "1.0.0-rc.2" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.1" } rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.2" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.1" } rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.2" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.1" } rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.2" }
rustfs-common = { path = "crates/common", version = "1.0.0-rc.1" } rustfs-common = { path = "crates/common", version = "1.0.0-rc.2" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.1" } rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.2" }
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.1" } rustfs-config = { path = "./crates/config", version = "1.0.0-rc.2" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.1" } rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.2" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.1" } rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.2" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.1" } rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.2" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.1" } rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.2" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.1" } rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.2" }
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.1" } rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.2" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.1" } rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.2" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.1" } rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.2" }
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.1" } rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.2" }
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.1" } rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.2" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.1" } rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.2" }
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.1" } rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.2" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.1" } rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.2" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.1" } rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.2" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.1" } rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.2" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.1", default-features = false } rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.2", default-features = false }
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.1" } rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.2" }
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.1" } rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.2" }
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.1" } rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.2" }
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.1" } rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.2" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.1" } rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.2" }
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.1" } rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.2" }
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.1" } rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.2" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.1" } rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.2" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.1" } rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.2" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.1" } rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.2" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.1" } rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.2" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.1" } rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.2" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.1" } rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.2" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.1" } rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.2" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.1" } rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.2" }
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.1" } rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.2" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.1" } rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.2" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.1" } rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.2" }
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.1" } rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.2" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.1" } rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.2" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.1" } rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.2" }
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.1" } rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.2" }
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.1" } rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.2" }
# Async Runtime and Networking # Async Runtime and Networking
async-channel = "2.5.0" async-channel = "2.5.0"
@@ -171,7 +171,7 @@ tower = { version = "0.5.3" }
tower-http = { version = "0.7.0" } tower-http = { version = "0.7.0" }
# Serialization and Data Formats # Serialization and Data Formats
apache-avro = "0.22.0" apache-avro = { version = "0.22.0", features = ["snappy", "zstandard"] }
bytes = { version = "1.12.1" } bytes = { version = "1.12.1" }
bytesize = "2.7.0" bytesize = "2.7.0"
byteorder = "1.5.0" byteorder = "1.5.0"
+1 -1
View File
@@ -116,7 +116,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version # Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.1 docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.2
``` ```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
+1 -1
View File
@@ -113,7 +113,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行 # 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.1 docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.2
``` ```
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录: 如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
+4 -1
View File
@@ -67,7 +67,10 @@ fn configured_capture_log_path(temp_dir: &str) -> Option<String> {
capture_log_path(Path::new(&log_dir), temp_dir).map(|path| path.to_string_lossy().into_owned()) capture_log_path(Path::new(&log_dir), temp_dir).map(|path| path.to_string_lossy().into_owned())
} }
fn capture_command_logs(command: &mut Command, log_path: Option<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { pub(crate) fn capture_command_logs(
command: &mut Command,
log_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let Some(log_path) = log_path else { let Some(log_path) = log_path else {
return Ok(()); return Ok(());
}; };
+663 -3
View File
@@ -2,6 +2,7 @@
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path}; use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use serial_test::serial; use serial_test::serial;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -25,6 +26,15 @@ fn generate_compressible_data(size: usize) -> Vec<u8> {
data data
} }
/// Deterministic 2048-byte-period binary pattern that compresses extremely well: every part
/// yields many compressed blocks, which is exactly the shape that reproduced the mid-payload
/// Pending truncation (rustfs/rustfs#5957).
fn generate_high_ratio_binary_data(size: usize, seed: u8) -> Vec<u8> {
(0..size)
.map(|i| ((i as u64).wrapping_mul(2_654_435_761).wrapping_add(seed as u64) >> 3) as u8)
.collect()
}
fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> Vec<PathBuf> { fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> Vec<PathBuf> {
let bucket_path = PathBuf::from(temp_dir).join(bucket); let bucket_path = PathBuf::from(temp_dir).join(bucket);
let mut part_files = Vec::new(); let mut part_files = Vec::new();
@@ -55,9 +65,14 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
env.cleanup_existing_processes().await?; env.cleanup_existing_processes().await?;
let binary_path = rustfs_binary_path(); let binary_path = rustfs_binary_path();
let process = Command::new(&binary_path) // Route the child's stdout/stderr through the shared RUSTFS_E2E_LOG_DIR
// capture (survives the temp-dir cleanup on Drop and is uploaded as a CI
// artifact); without the env var the child inherits stdio as before.
let mut command = Command::new(&binary_path);
command
.env("RUSTFS_CONSOLE_ENABLE", "false") .env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUSTFS_COMPRESSION_ENABLED", "true") .env("RUSTFS_COMPRESSION_ENABLED", "true")
.env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true")
.args([ .args([
"--address", "--address",
&env.address, &env.address,
@@ -66,8 +81,9 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
"--secret-key", "--secret-key",
&env.secret_key, &env.secret_key,
&env.temp_dir, &env.temp_dir,
]) ]);
.spawn()?; crate::common::capture_command_logs(&mut command, env.capture_log_path.as_deref())?;
let process = command.spawn()?;
env.process = Some(process); env.process = Some(process);
@@ -154,3 +170,647 @@ async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error +
env.stop_server(); env.stop_server();
Ok(()) Ok(())
} }
const MULTIPART_COMPRESSION_BUCKET: &str = "compression-multipart-bucket";
const MPU_PART1_SIZE: usize = 5 * 1024 * 1024;
const MPU_PART2_SIZE: usize = 1024 * 1024;
async fn multipart_upload(
client: &aws_sdk_s3::Client,
bucket: &str,
key: &str,
parts: &[&[u8]],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let create = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
let mut completed_parts = Vec::with_capacity(parts.len());
for (i, part) in parts.iter().enumerate() {
let part_number = (i + 1) as i32;
let upload = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from(part.to_vec()))
.send()
.await?;
completed_parts.push(
CompletedPart::builder()
.part_number(part_number)
.e_tag(upload.e_tag().unwrap_or_default())
.build(),
);
}
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
.send()
.await?;
Ok(())
}
async fn fetch_range(
client: &aws_sdk_s3::Client,
bucket: &str,
key: &str,
range: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
let response = client.get_object().bucket(bucket).key(key).range(range).send().await?;
Ok(response.body.collect().await?.into_bytes().to_vec())
}
/// Multipart disk compression roundtrip: parts are written as independent
/// compressed streams and every GET shape must reassemble the original bytes
/// (rustfs/rustfs#5957: multipart uploads previously bypassed disk compression
/// entirely).
#[tokio::test]
#[serial]
async fn test_compression_multipart_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting multipart compression roundtrip test");
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_with_compression(&mut env).await?;
let client = env.create_s3_client();
env.create_test_bucket(MULTIPART_COMPRESSION_BUCKET).await?;
let object_key = "multipart-compressible.txt";
let part1 = generate_compressible_data(MPU_PART1_SIZE);
let part2 = generate_compressible_data(MPU_PART2_SIZE);
let mut original_data = part1.clone();
original_data.extend_from_slice(&part2);
let total_size = original_data.len();
multipart_upload(&client, MULTIPART_COMPRESSION_BUCKET, object_key, &[&part1, &part2]).await?;
let head_response = client
.head_object()
.bucket(MULTIPART_COMPRESSION_BUCKET)
.key(object_key)
.send()
.await?;
assert_eq!(
head_response.content_length().unwrap_or(0) as usize,
total_size,
"Content-Length should be the logical object size"
);
let part_files = find_part_files(&env.temp_dir, MULTIPART_COMPRESSION_BUCKET, object_key);
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
assert!(
total_physical_size < (total_size / 2) as u64,
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
);
info!("Multipart physical storage size: {total_physical_size} bytes (compressed from {total_size} bytes)");
// Full GET must reassemble both independently compressed parts.
let get_response = client
.get_object()
.bucket(MULTIPART_COMPRESSION_BUCKET)
.key(object_key)
.send()
.await?;
let downloaded = get_response.body.collect().await?.into_bytes();
assert_eq!(downloaded.len(), total_size);
assert_eq!(&downloaded[..], &original_data[..], "full GET data mismatch");
// Range fully inside part 1.
let range_inside_part1 = fetch_range(&client, MULTIPART_COMPRESSION_BUCKET, object_key, "bytes=1024-999423").await?;
assert_eq!(&range_inside_part1[..], &original_data[1024..999424], "part-1 range mismatch");
// Range crossing the part boundary.
let boundary_start = MPU_PART1_SIZE - 128 * 1024;
let boundary_end = MPU_PART1_SIZE + 128 * 1024 - 1;
let range_crossing = fetch_range(
&client,
MULTIPART_COMPRESSION_BUCKET,
object_key,
&format!("bytes={boundary_start}-{boundary_end}"),
)
.await?;
assert_eq!(
&range_crossing[..],
&original_data[boundary_start..boundary_end + 1],
"boundary-crossing range mismatch"
);
// Range fully inside part 2.
let part2_start = MPU_PART1_SIZE + 4096;
let part2_end = MPU_PART1_SIZE + 256 * 1024 - 1;
let range_inside_part2 = fetch_range(
&client,
MULTIPART_COMPRESSION_BUCKET,
object_key,
&format!("bytes={part2_start}-{part2_end}"),
)
.await?;
assert_eq!(
&range_inside_part2[..],
&original_data[part2_start..part2_end + 1],
"part-2 range mismatch"
);
// Suffix range (last 128 KiB, entirely in part 2).
let suffix_len = 128 * 1024;
let suffix = fetch_range(&client, MULTIPART_COMPRESSION_BUCKET, object_key, &format!("bytes=-{suffix_len}")).await?;
assert_eq!(&suffix[..], &original_data[total_size - suffix_len..], "suffix range mismatch");
// partNumber GETs must return each original part.
for (part_number, expected) in [(1, &part1), (2, &part2)] {
let response = client
.get_object()
.bucket(MULTIPART_COMPRESSION_BUCKET)
.key(object_key)
.part_number(part_number)
.send()
.await?;
let body = response.body.collect().await?.into_bytes();
assert_eq!(&body[..], &expected[..], "partNumber={part_number} GET mismatch");
}
info!("Multipart compression roundtrip test passed");
env.delete_test_bucket(MULTIPART_COMPRESSION_BUCKET).await?;
env.stop_server();
Ok(())
}
const MPU_HIGH_RATIO_BUCKET: &str = "compression-mpu-high-ratio-bucket";
/// High-ratio binary multipart payload: the object key is on the compression allow-list, so the
/// disk-compression path runs and each part is stored as many compressed blocks — the shape that
/// reproduced the mid-payload Pending truncation (rustfs/rustfs#5957). Every GET shape must return
/// the exact original bytes, and the stored size must show the data really was compressed.
#[tokio::test]
#[serial]
async fn test_compression_multipart_high_ratio_binary_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting multipart high-ratio binary compression roundtrip test");
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_with_compression(&mut env).await?;
let client = env.create_s3_client();
env.create_test_bucket(MPU_HIGH_RATIO_BUCKET).await?;
let object_key = "multipart-high-ratio.txt";
let part1 = generate_high_ratio_binary_data(MPU_PART1_SIZE, 7);
let part2 = generate_high_ratio_binary_data(MPU_PART2_SIZE, 61);
let mut original_data = part1.clone();
original_data.extend_from_slice(&part2);
let total_size = original_data.len();
multipart_upload(&client, MPU_HIGH_RATIO_BUCKET, object_key, &[&part1, &part2]).await?;
let head_response = client
.head_object()
.bucket(MPU_HIGH_RATIO_BUCKET)
.key(object_key)
.send()
.await?;
assert_eq!(
head_response.content_length().unwrap_or(0) as usize,
total_size,
"Content-Length should be the logical object size"
);
// This pattern compresses to roughly 1/50 of its logical size, so a comfortably loose 2x
// margin still proves the parts were stored compressed rather than raw or double-encoded.
let part_files = find_part_files(&env.temp_dir, MPU_HIGH_RATIO_BUCKET, object_key);
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
assert!(
total_physical_size < (total_size as u64) / 2,
"Physical size {total_physical_size} should be far below the logical size {total_size} for high-ratio data"
);
info!("High-ratio multipart physical storage size: {total_physical_size} bytes (logical {total_size} bytes)");
info!("step: full GET");
let get_response = client
.get_object()
.bucket(MPU_HIGH_RATIO_BUCKET)
.key(object_key)
.send()
.await?;
let downloaded = get_response.body.collect().await?.into_bytes();
assert_eq!(downloaded.len(), total_size);
assert_eq!(&downloaded[..], &original_data[..], "full GET data mismatch");
// Range crossing the part boundary.
info!("step: boundary range GET");
let boundary_start = MPU_PART1_SIZE - 128 * 1024;
let boundary_end = MPU_PART1_SIZE + 128 * 1024 - 1;
let range_crossing = fetch_range(
&client,
MPU_HIGH_RATIO_BUCKET,
object_key,
&format!("bytes={boundary_start}-{boundary_end}"),
)
.await?;
assert_eq!(
&range_crossing[..],
&original_data[boundary_start..boundary_end + 1],
"boundary-crossing range mismatch"
);
// partNumber GET for the trailing part.
info!("step: partNumber GET");
let part2_response = client
.get_object()
.bucket(MPU_HIGH_RATIO_BUCKET)
.key(object_key)
.part_number(2)
.send()
.await?;
let part2_body = part2_response.body.collect().await?.into_bytes();
assert_eq!(&part2_body[..], &part2[..], "partNumber=2 GET mismatch");
info!("Multipart high-ratio binary compression roundtrip test passed");
env.delete_test_bucket(MPU_HIGH_RATIO_BUCKET).await?;
env.stop_server();
Ok(())
}
const MPU_COPY_COMPRESSION_BUCKET: &str = "compression-mpu-copy-bucket";
const MPU_COPY_SOURCE_SIZE: usize = 6 * 1024 * 1024;
const MPU_COPY_RANGE_LEN: usize = 5 * 1024 * 1024;
/// UploadPartCopy feeds a part from an already stored (and already compressed) object. The copied
/// range must be decompressed on read and re-compressed into the destination part, so the final
/// object has to match "source prefix + uploaded tail" byte for byte.
#[tokio::test]
#[serial]
async fn test_compression_multipart_upload_part_copy_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting multipart upload-part-copy compression roundtrip test");
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_with_compression(&mut env).await?;
let client = env.create_s3_client();
env.create_test_bucket(MPU_COPY_COMPRESSION_BUCKET).await?;
// Source object: a plain PUT that goes through the single-stream compression path.
let source_key = "copy-source.txt";
let source_data = generate_compressible_data(MPU_COPY_SOURCE_SIZE);
client
.put_object()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(source_key)
.body(ByteStream::from(source_data.clone()))
.send()
.await?;
// Destination object: part 1 copied from the source, part 2 uploaded directly.
let target_key = "copy-target.txt";
let part2 = generate_compressible_data(MPU_PART2_SIZE);
let mut expected_data = source_data[..MPU_COPY_RANGE_LEN].to_vec();
expected_data.extend_from_slice(&part2);
let total_size = expected_data.len();
let create = client
.create_multipart_upload()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.send()
.await?;
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
let copy_part = client
.upload_part_copy()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.upload_id(&upload_id)
.part_number(1)
.copy_source(format!("{MPU_COPY_COMPRESSION_BUCKET}/{source_key}"))
.copy_source_range(format!("bytes=0-{}", MPU_COPY_RANGE_LEN - 1))
.send()
.await?;
let copy_etag = copy_part
.copy_part_result()
.and_then(|r| r.e_tag())
.ok_or("missing copy part etag")?
.to_string();
let uploaded_part = client
.upload_part()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.upload_id(&upload_id)
.part_number(2)
.body(ByteStream::from(part2.clone()))
.send()
.await?;
client
.complete_multipart_upload()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.upload_id(&upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(CompletedPart::builder().part_number(1).e_tag(copy_etag).build())
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(uploaded_part.e_tag().unwrap_or_default())
.build(),
)
.build(),
)
.send()
.await?;
let head_response = client
.head_object()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.send()
.await?;
assert_eq!(
head_response.content_length().unwrap_or(0) as usize,
total_size,
"Content-Length should be the logical object size"
);
let part_files = find_part_files(&env.temp_dir, MPU_COPY_COMPRESSION_BUCKET, target_key);
assert!(!part_files.is_empty(), "expected on-disk part files for the copied object");
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
assert!(
total_physical_size < (total_size / 2) as u64,
"Physical size {total_physical_size} should be well below original size {total_size} (copied part compression applied)"
);
let get_response = client
.get_object()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.send()
.await?;
let downloaded = get_response.body.collect().await?.into_bytes();
assert_eq!(downloaded.len(), total_size);
assert_eq!(&downloaded[..], &expected_data[..], "copied multipart GET data mismatch");
info!("Multipart upload-part-copy compression roundtrip test passed");
env.delete_test_bucket(MPU_COPY_COMPRESSION_BUCKET).await?;
env.stop_server();
Ok(())
}
const MPU_THREE_PARTS_BUCKET: &str = "compression-mpu-three-parts-bucket";
const MPU_THREE_PARTS_TAIL_SIZE: usize = 512 * 1024;
/// Three-part upload with uneven part sizes: each partNumber GET must map back to exactly one
/// compressed part stream, and a suffix range must resolve inside the trailing part.
#[tokio::test]
#[serial]
async fn test_compression_multipart_three_parts_part_number_gets() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting three-part multipart compression partNumber test");
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_with_compression(&mut env).await?;
let client = env.create_s3_client();
env.create_test_bucket(MPU_THREE_PARTS_BUCKET).await?;
let object_key = "multipart-three-parts.txt";
let part1 = generate_compressible_data(MPU_PART1_SIZE);
let part2 = generate_compressible_data(MPU_PART1_SIZE);
let part3 = generate_compressible_data(MPU_THREE_PARTS_TAIL_SIZE);
let mut original_data = part1.clone();
original_data.extend_from_slice(&part2);
original_data.extend_from_slice(&part3);
let total_size = original_data.len();
multipart_upload(&client, MPU_THREE_PARTS_BUCKET, object_key, &[&part1, &part2, &part3]).await?;
let head_response = client
.head_object()
.bucket(MPU_THREE_PARTS_BUCKET)
.key(object_key)
.send()
.await?;
assert_eq!(
head_response.content_length().unwrap_or(0) as usize,
total_size,
"Content-Length should be the logical object size"
);
let part_files = find_part_files(&env.temp_dir, MPU_THREE_PARTS_BUCKET, object_key);
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
assert!(
total_physical_size < (total_size / 2) as u64,
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
);
// Every partNumber GET must return exactly the bytes of the corresponding uploaded part.
for (part_number, expected) in [(1, &part1), (2, &part2), (3, &part3)] {
let response = client
.get_object()
.bucket(MPU_THREE_PARTS_BUCKET)
.key(object_key)
.part_number(part_number)
.send()
.await?;
let body = response.body.collect().await?.into_bytes();
assert_eq!(&body[..], &expected[..], "partNumber={part_number} GET mismatch");
}
// Suffix range (last 64 KiB) resolves inside the trailing part.
let suffix_len = 64 * 1024;
let suffix = fetch_range(&client, MPU_THREE_PARTS_BUCKET, object_key, &format!("bytes=-{suffix_len}")).await?;
assert_eq!(&suffix[..], &original_data[total_size - suffix_len..], "suffix range mismatch");
info!("Three-part multipart compression partNumber test passed");
env.delete_test_bucket(MPU_THREE_PARTS_BUCKET).await?;
env.stop_server();
Ok(())
}
const MPU_SSE_COMPRESSION_BUCKET: &str = "compression-mpu-sse-bucket";
async fn start_rustfs_with_compression_and_sse(
env: &mut RustFSTestEnvironment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use base64::Engine;
env.cleanup_existing_processes().await?;
let binary_path = rustfs_binary_path();
let master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
// Server output goes to a file inside the per-test temp dir so a failing
// run can be diagnosed from the child's logs.
let server_log = std::fs::File::create(format!("{}/server.log", env.temp_dir))?;
let server_log_err = server_log.try_clone()?;
let process = Command::new(&binary_path)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUSTFS_COMPRESSION_ENABLED", "true")
.env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true")
.env("RUSTFS_SSE_S3_MASTER_KEY", master_key)
.env("RUST_LOG", "rustfs=info,rustfs_ecstore=info")
.stdout(std::process::Stdio::from(server_log))
.stderr(std::process::Stdio::from(server_log_err))
.args([
"--address",
&env.address,
"--access-key",
&env.access_key,
"--secret-key",
&env.secret_key,
&env.temp_dir,
])
.spawn()?;
env.process = Some(process);
info!("Waiting for RustFS server with compression + SSE-S3 enabled on {}", env.address);
for i in 0..30 {
if TcpStream::connect(&env.address).await.is_ok() {
info!("RustFS server is ready after {} attempts", i + 1);
return Ok(());
}
if i == 29 {
return Err("RustFS server failed to become ready".into());
}
sleep(Duration::from_secs(1)).await;
}
Ok(())
}
/// SSE-S3 + disk compression multipart: each part is compressed and then encrypted, and every GET
/// shape must still return the original plaintext bytes. Physical size must shrink because the
/// compression runs before encryption.
#[tokio::test]
#[serial]
async fn test_compression_multipart_sse_s3_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use aws_sdk_s3::types::ServerSideEncryption;
init_logging();
info!("Starting SSE-S3 multipart compression roundtrip test");
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_with_compression_and_sse(&mut env).await?;
let client = env.create_s3_client();
env.create_test_bucket(MPU_SSE_COMPRESSION_BUCKET).await?;
let object_key = "multipart-sse-compressible.txt";
let part1 = generate_compressible_data(MPU_PART1_SIZE);
let part2 = generate_compressible_data(MPU_PART2_SIZE);
let mut original_data = part1.clone();
original_data.extend_from_slice(&part2);
let total_size = original_data.len();
let create = client
.create_multipart_upload()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.server_side_encryption(ServerSideEncryption::Aes256)
.send()
.await?;
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
let mut completed_parts = Vec::new();
for (i, part) in [&part1, &part2].into_iter().enumerate() {
let part_number = (i + 1) as i32;
let upload = client
.upload_part()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from(part.clone()))
.send()
.await?;
completed_parts.push(
CompletedPart::builder()
.part_number(part_number)
.e_tag(upload.e_tag().unwrap_or_default())
.build(),
);
}
client
.complete_multipart_upload()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
.send()
.await?;
let head_response = client
.head_object()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.send()
.await?;
assert_eq!(
head_response.content_length().unwrap_or(0) as usize,
total_size,
"Content-Length should be the logical object size"
);
assert_eq!(
head_response.server_side_encryption(),
Some(&ServerSideEncryption::Aes256),
"HEAD must report SSE-S3"
);
let part_files = find_part_files(&env.temp_dir, MPU_SSE_COMPRESSION_BUCKET, object_key);
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
assert!(
total_physical_size < (total_size / 2) as u64,
"Physical size {total_physical_size} should be well below original size {total_size} (compress-then-encrypt applied)"
);
let get_response = client
.get_object()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.send()
.await?;
let downloaded = get_response.body.collect().await?.into_bytes();
assert_eq!(downloaded.len(), total_size);
assert_eq!(&downloaded[..], &original_data[..], "SSE-S3 multipart full GET data mismatch");
// Range crossing the part boundary must decrypt and decompress across parts.
let boundary_start = MPU_PART1_SIZE - 64 * 1024;
let boundary_end = MPU_PART1_SIZE + 64 * 1024 - 1;
let range_crossing = fetch_range(
&client,
MPU_SSE_COMPRESSION_BUCKET,
object_key,
&format!("bytes={boundary_start}-{boundary_end}"),
)
.await?;
assert_eq!(
&range_crossing[..],
&original_data[boundary_start..boundary_end + 1],
"SSE-S3 boundary-crossing range mismatch"
);
// partNumber GET for the trailing part.
let part2_response = client
.get_object()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.part_number(2)
.send()
.await?;
let part2_body = part2_response.body.collect().await?.into_bytes();
assert_eq!(&part2_body[..], &part2[..], "SSE-S3 partNumber=2 GET mismatch");
info!("SSE-S3 multipart compression roundtrip test passed");
env.delete_test_bucket(MPU_SSE_COMPRESSION_BUCKET).await?;
env.stop_server();
Ok(())
}
+91 -1
View File
@@ -76,6 +76,18 @@ const SOURCE_MTIME_HEADERS: [&str; 2] = ["x-rustfs-source-mtime", "x-minio-sourc
const SOURCE_REPLICATION_REQUEST_HEADERS: [&str; 2] = const SOURCE_REPLICATION_REQUEST_HEADERS: [&str; 2] =
["x-rustfs-source-replication-request", "x-minio-source-replication-request"]; ["x-rustfs-source-replication-request", "x-minio-source-replication-request"];
const SOURCE_ETAG_HEADERS: [&str; 2] = ["x-rustfs-source-etag", "x-minio-source-etag"]; const SOURCE_ETAG_HEADERS: [&str; 2] = ["x-rustfs-source-etag", "x-minio-source-etag"];
const SOURCE_TAGGING_TIMESTAMP_HEADERS: [&str; 2] = [
"x-rustfs-source-replication-tagging-timestamp",
"x-minio-source-replication-tagging-timestamp",
];
const SOURCE_RETENTION_TIMESTAMP_HEADERS: [&str; 2] = [
"x-rustfs-source-replication-retention-timestamp",
"x-minio-source-replication-retention-timestamp",
];
const SOURCE_LEGALHOLD_TIMESTAMP_HEADERS: [&str; 2] = [
"x-rustfs-source-replication-legalhold-timestamp",
"x-minio-source-replication-legalhold-timestamp",
];
const RESERVED_BUCKET_PREFIXES: [&str; 3] = ["xn--", "sthree-", "amzn-s3-demo-"]; const RESERVED_BUCKET_PREFIXES: [&str; 3] = ["xn--", "sthree-", "amzn-s3-demo-"];
const RESERVED_BUCKET_SUFFIXES: [&str; 6] = ["-s3alias", "--ol-s3", ".mrap", "--x-s3", "--table-s3", "-an"]; const RESERVED_BUCKET_SUFFIXES: [&str; 6] = ["-s3alias", "--ol-s3", ".mrap", "--x-s3", "--table-s3", "-an"];
@@ -118,6 +130,25 @@ pub enum FaultAction {
WrongEtag, WrongEtag,
} }
/// Replication LWW timestamp headers observed on a request, journaled so
/// sender-side tests can assert what a real target would receive.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReplicationTimestampHeaders {
pub tagging: Option<String>,
pub retention: Option<String>,
pub legalhold: Option<String>,
}
impl ReplicationTimestampHeaders {
fn from_headers(headers: &HeaderMap) -> Self {
Self {
tagging: header_value(headers, &SOURCE_TAGGING_TIMESTAMP_HEADERS).map(bounded_journal_value),
retention: header_value(headers, &SOURCE_RETENTION_TIMESTAMP_HEADERS).map(bounded_journal_value),
legalhold: header_value(headers, &SOURCE_LEGALHOLD_TIMESTAMP_HEADERS).map(bounded_journal_value),
}
}
}
/// Credential-free request metadata retained for deterministic assertions. /// Credential-free request metadata retained for deterministic assertions.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestRecord { pub struct RequestRecord {
@@ -131,6 +162,7 @@ pub struct RequestRecord {
pub part_number: Option<i32>, pub part_number: Option<i32>,
pub content_length: Option<u64>, pub content_length: Option<u64>,
pub consumed_bytes: Option<usize>, pub consumed_bytes: Option<usize>,
pub replication_timestamps: ReplicationTimestampHeaders,
pub fault: Option<FaultAction>, pub fault: Option<FaultAction>,
} }
@@ -536,7 +568,15 @@ impl S3Access for FaultAccess {
.get(CONTENT_LENGTH) .get(CONTENT_LENGTH)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok()); .and_then(|value| value.parse().ok());
let fault = record_request(&self.control, operation, context.method().clone(), parsed, content_length); let replication_timestamps = ReplicationTimestampHeaders::from_headers(context.headers());
let fault = record_request(
&self.control,
operation,
context.method().clone(),
parsed,
content_length,
replication_timestamps,
);
if let Some(RequestFault { if let Some(RequestFault {
action: FaultAction::Status(status), action: FaultAction::Status(status),
.. ..
@@ -589,6 +629,7 @@ fn record_request(
method: Method, method: Method,
parsed: ParsedRequest, parsed: ParsedRequest,
content_length: Option<u64>, content_length: Option<u64>,
replication_timestamps: ReplicationTimestampHeaders,
) -> Option<RequestFault> { ) -> Option<RequestFault> {
let mut state = lock(control); let mut state = lock(control);
let action = parsed let action = parsed
@@ -613,6 +654,7 @@ fn record_request(
part_number: parsed.part_number, part_number: parsed.part_number,
content_length, content_length,
consumed_bytes: None, consumed_bytes: None,
replication_timestamps,
fault: action.clone(), fault: action.clone(),
}); });
action.map(|action| RequestFault { sequence, action }) action.map(|action| RequestFault { sequence, action })
@@ -1699,6 +1741,52 @@ mod tests {
.await?) .await?)
} }
#[tokio::test]
async fn journals_replication_timestamp_headers() -> Result<(), BoxError> {
let target = FakeS3Target::start().await?;
target.create_bucket("target-bucket");
let client = client(&target);
client
.put_object()
.bucket("target-bucket")
.key("plain")
.body(ByteStream::from_static(b"plain"))
.send()
.await?;
client
.put_object()
.bucket("target-bucket")
.key("stamped")
.body(ByteStream::from_static(b"stamped"))
.customize()
.map_request(move |mut request| {
let headers = request.headers_mut();
headers.insert("x-rustfs-source-replication-tagging-timestamp", "2026-01-02T03:04:05Z");
headers.insert("x-minio-source-replication-retention-timestamp", "2026-01-02T03:04:06Z");
headers.insert("x-rustfs-source-replication-legalhold-timestamp", "2026-01-02T03:04:07Z");
Ok::<_, std::convert::Infallible>(request)
})
.send()
.await?;
let requests = target.requests();
let plain = requests
.iter()
.find(|record| record.operation == Operation::PutObject && record.key.as_deref() == Some("plain"))
.expect("plain PUT must be journaled");
assert_eq!(plain.replication_timestamps, ReplicationTimestampHeaders::default());
let stamped = requests
.iter()
.find(|record| record.operation == Operation::PutObject && record.key.as_deref() == Some("stamped"))
.expect("stamped PUT must be journaled");
assert_eq!(stamped.replication_timestamps.tagging.as_deref(), Some("2026-01-02T03:04:05Z"));
assert_eq!(stamped.replication_timestamps.retention.as_deref(), Some("2026-01-02T03:04:06Z"));
assert_eq!(stamped.replication_timestamps.legalhold.as_deref(), Some("2026-01-02T03:04:07Z"));
Ok(())
}
macro_rules! assert_sdk_error { macro_rules! assert_sdk_error {
($error:expr, $status:expr, $code:expr) => {{ ($error:expr, $status:expr, $code:expr) => {{
let error = &$error; let error = &$error;
@@ -2985,6 +3073,7 @@ mod tests {
part_number: None, part_number: None,
}, },
Some(0), Some(0),
ReplicationTimestampHeaders::default(),
); );
} }
let records = lock(&control).requests.clone(); let records = lock(&control).requests.clone();
@@ -3006,6 +3095,7 @@ mod tests {
part_number: None, part_number: None,
}, },
None, None,
ReplicationTimestampHeaders::default(),
); );
{ {
let bounded_records = lock(&bounded_control); let bounded_records = lock(&bounded_control);
@@ -1828,33 +1828,36 @@ async fn four_node_compressed_inline_fallback() -> TestResult {
Ok(()) Ok(())
} }
/// Multipart disk compression is live again, so a compression-enabled cluster classifies multipart objects as compressed and the roundtrip (full GET plus partNumber GET) must still return the original bytes.
/// Reverting the multipart compression fix must fail this test.
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn four_node_multipart_ignores_disk_compression_fallback() -> TestResult { async fn four_node_multipart_disk_compression_roundtrip() -> TestResult {
init_logging(); init_logging();
let collector = OtlpMetricCollector::start().await?; let collector = OtlpMetricCollector::start().await?;
let mut cluster = RustFSTestClusterEnvironment::new(4).await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
configure_reader_metric_cluster(&mut cluster, &collector); configure_reader_metric_cluster(&mut cluster, &collector);
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
cluster.start().await?; cluster.start().await?;
let bucket = "inline-multipart-compression-fallback"; let bucket = "inline-multipart-compression-roundtrip";
cluster.create_test_bucket(bucket).await?; cluster.create_test_bucket(bucket).await?;
let client = cluster.create_s3_client(0)?; let client = cluster.create_s3_client(0)?;
let key = "multipart/compression-disabled.txt"; let key = "multipart/compressed.txt";
let (body, second_part, etag) = put_two_part_multipart(&client, bucket, key).await?; let (body, second_part, etag) = put_two_part_multipart(&client, bucket, key).await?;
assert_reader_path( assert_reader_path(
&collector, &collector,
&client, &client,
ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), LEGACY_DUPLEX, MULTIPART), ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), LEGACY_DUPLEX, COMPRESSED),
) )
.await?; .await?;
assert_part_number_reader_path( assert_part_number_reader_path(
&collector, &collector,
&client, &client,
PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), MULTIPART, LEGACY_DUPLEX), PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), COMPRESSED, LEGACY_DUPLEX),
) )
.await?; .await?;
@@ -1871,6 +1874,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key); cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
configure_mixed_msgpack_cluster(&mut cluster, &collector)?; configure_mixed_msgpack_cluster(&mut cluster, &collector)?;
cluster.start().await?; cluster.start().await?;
@@ -1890,14 +1894,21 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
ReaderPathExpectation::for_class( ReaderPathExpectation::for_class(
ReaderObject::new(bucket, multipart_key, &multipart_body, multipart_etag.as_deref(), None), ReaderObject::new(bucket, multipart_key, &multipart_body, multipart_etag.as_deref(), None),
LEGACY_DUPLEX, LEGACY_DUPLEX,
MULTIPART, COMPRESSED,
), ),
) )
.await?; .await?;
assert_part_number_reader_path( assert_part_number_reader_path(
&collector, &collector,
&client, &client,
PartNumberReaderPathExpectation::new(bucket, multipart_key, &second_part, multipart_body.len(), MULTIPART, LEGACY_DUPLEX), PartNumberReaderPathExpectation::new(
bucket,
multipart_key,
&second_part,
multipart_body.len(),
COMPRESSED,
LEGACY_DUPLEX,
),
) )
.await?; .await?;
assert_msgpack_decode_observed(&collector, &decode_before).await?; assert_msgpack_decode_observed(&collector, &decode_before).await?;
@@ -2353,7 +2364,11 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_
hot_client.create_bucket().bucket(bucket).send().await?; hot_client.create_bucket().bucket(bucket).send().await?;
put_lifecycle_with_transition_retry(&hot_client, bucket, &tier_name).await?; put_lifecycle_with_transition_retry(&hot_client, bucket, &tier_name).await?;
let key = "transition/mixed-multipart.bin"; // `.zip` sits on the disk-compression exclusion list: this test pins
// msgpack compat controls across ILM transition, and a compressed object
// would classify as `compressed` instead of `remote` (and the warm-tier
// read path does not decode compression — tracked separately).
let key = "transition/mixed-multipart.zip";
let (body, second_part, etag) = put_two_part_multipart(&hot_client, bucket, key).await?; let (body, second_part, etag) = put_two_part_multipart(&hot_client, bucket, key).await?;
wait_for_transition(&hot_client, bucket, key, &tier_name).await?; wait_for_transition(&hot_client, bucket, key, &tier_name).await?;
assert!( assert!(
@@ -97,7 +97,7 @@ async fn start_enforcing_ilm_server(env: &mut LocalKMSTestEnvironment) -> TestRe
let envs = [ let envs = [
("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"), ("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"),
("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "false"), ("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true"),
("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_CYCLE", "1"),
("RUSTFS_ILM_PROCESS_TIME", "1"), ("RUSTFS_ILM_PROCESS_TIME", "1"),
("RUSTFS_ILM_DEBUG_DAY_SECS", "2"), ("RUSTFS_ILM_DEBUG_DAY_SECS", "2"),
@@ -486,7 +486,6 @@ async fn ilm_expiration_on_sse_kms_bucket_under_enforcement() -> TestResult {
/// depend on scanner scheduling; the 1s scanner cycle stays on as a backstop. /// depend on scanner scheduling; the 1s scanner cycle stays on as a backstop.
#[tokio::test] #[tokio::test]
#[serial] #[serial]
#[ignore = "pins rustfs/rustfs#6025: GET on a transitioned managed-SSE object silently returns corrupt bytes (fails with enforcement on AND off, so it is not an authorization regression); un-ignore with the fix"]
async fn ilm_transition_on_sse_kms_bucket_under_enforcement_reads_back() -> TestResult { async fn ilm_transition_on_sse_kms_bucket_under_enforcement_reads_back() -> TestResult {
init_logging(); init_logging();
+27 -17
View File
@@ -131,16 +131,19 @@ pub mod bucket {
} }
pub mod metadata_sys { pub mod metadata_sys {
#[cfg(feature = "test-util")]
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
pub use crate::bucket::metadata_sys::{ pub use crate::bucket::metadata_sys::{
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock, BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, get, get_accelerate_config, get_bucket_policy, acquire_bucket_metadata_transaction_lock_for_incarnation, capture_bucket_metadata_incarnation, delete,
delete_if_incarnation, delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy,
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config, get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config, get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
get_object_lock_config, get_object_lock_config_state, get_public_access_block_config, get_quota_config, get_object_lock_config, get_object_lock_config_state, get_public_access_block_config, get_quota_config,
get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config,
get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation, set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
update_under_transaction_lock, update_quota_if_incarnation, update_under_transaction_lock,
}; };
} }
@@ -182,17 +185,18 @@ pub mod bucket {
mrf_backlog_observability_snapshot, mrf_backlog_observability_snapshot,
}; };
pub use crate::bucket::replication::{ pub use crate::bucket::replication::{
BucketReplicationResyncStatus, BucketReplicationStats, BucketStats, DeleteReplicationConfigSnapshot, BucketReplicationResyncStatus, BucketReplicationStat, BucketReplicationStats, BucketStats,
DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool, MrfOpKind, MrfReplicateEntry, DeleteReplicationConfigSnapshot, DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool, InQueueMetric,
MustReplicateOptions, ObjectOpts, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, MrfOpKind, MrfReplicateEntry, MustReplicateOptions, ObjectOpts, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION,
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATE_INCOMING_DELETE, REPLICATION_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATE_INCOMING_DELETE,
REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, ReplicateDecision, ReplicateObjectInfo, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS,
ReplicationBatchAdmission, ReplicationConfig, ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicateDecision, ReplicateObjectInfo, ReplicationBatchAdmission, ReplicationConfig,
ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationDeleteScheduleInput,
ReplicationObjectIO, ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
ReplicationScannerBridge, ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
ReplicationTargetValidationError, ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
TargetReplicationResyncStatus, VersionPurgeStatusType, commit_force_delete_intent, complete_force_delete_intent, ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, TargetReplicationResyncStatus,
VersionPurgeStatusType, XferStats, commit_force_delete_intent, complete_force_delete_intent,
delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool, delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
get_global_replication_stats, init_background_replication, invalid_replication_config_status_field, get_global_replication_stats, init_background_replication, invalid_replication_config_status_field,
persist_force_delete_intent, read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta, persist_force_delete_intent, read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta,
@@ -276,7 +280,9 @@ pub mod cluster {
} }
pub mod compression { pub mod compression {
pub use crate::io_support::compress::{MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible, is_disk_compression_enabled}; pub use crate::io_support::compress::{
MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible, is_disk_compression_enabled, is_multipart_disk_compression_enabled,
};
} }
pub mod config { pub mod config {
@@ -316,7 +322,7 @@ pub mod data_usage {
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage, DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache, init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
invalidate_data_usage_snapshot_cache, live_bucket_usage_computations, load_admin_data_usage_from_backend_cached, invalidate_data_usage_snapshot_cache, live_bucket_usage_computations, load_admin_data_usage_from_backend_cached,
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached, load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached, quota_object_size,
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory, record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory, record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer, refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
@@ -403,8 +409,11 @@ pub mod metrics {
} }
pub mod notification { pub mod notification {
#[cfg(any(test, feature = "test-util"))]
pub use crate::services::notification_sys::rotate_cross_pool_fence_fleet_proof_for_test;
pub use crate::services::notification_sys::{ pub use crate::services::notification_sys::{
NotificationPeerErr, NotificationSys, get_global_notification_sys, new_global_notification_sys, CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, acquire_cross_pool_fence_fleet_proof,
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, new_global_notification_sys,
start_remote_version_state_fleet_probe, start_remote_version_state_fleet_probe,
}; };
} }
@@ -464,7 +473,8 @@ pub mod set_disk {
#[cfg(feature = "test-util")] #[cfg(feature = "test-util")]
pub mod test_util { pub mod test_util {
pub use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
} }
} }
+70 -4
View File
@@ -58,7 +58,9 @@ use rustfs_utils::http::{
}; };
use rustfs_utils::http::{ use rustfs_utils::http::{
SUFFIX_FORCE_DELETE, SUFFIX_SOURCE_DELETEMARKER, SUFFIX_SOURCE_ETAG, SUFFIX_SOURCE_MTIME, SUFFIX_SOURCE_REPLICATION_CHECK, SUFFIX_FORCE_DELETE, SUFFIX_SOURCE_DELETEMARKER, SUFFIX_SOURCE_ETAG, SUFFIX_SOURCE_MTIME, SUFFIX_SOURCE_REPLICATION_CHECK,
SUFFIX_SOURCE_REPLICATION_REQUEST, SUFFIX_SOURCE_VERSION_ID, insert_header, SUFFIX_SOURCE_REPLICATION_LEGALHOLD_TIMESTAMP, SUFFIX_SOURCE_REPLICATION_REQUEST,
SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP, SUFFIX_SOURCE_REPLICATION_TAGGING_TIMESTAMP, SUFFIX_SOURCE_VERSION_ID,
insert_header,
}; };
use rustls_pki_types::pem::PemObject; use rustls_pki_types::pem::PemObject;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -1476,9 +1478,12 @@ impl Default for AdvancedPutOptions {
replication_status: ReplicationStatusType::Pending, replication_status: ReplicationStatusType::Pending,
source_mtime: OffsetDateTime::now_utc(), source_mtime: OffsetDateTime::now_utc(),
replication_request: false, replication_request: false,
retention_timestamp: OffsetDateTime::now_utc(), // UNIX_EPOCH means "never modified": header() must not emit a
tagging_timestamp: OffsetDateTime::now_utc(), // timestamp header for it, otherwise a receiver would treat an
legalhold_timestamp: OffsetDateTime::now_utc(), // unset category as a modification made right now.
retention_timestamp: OffsetDateTime::UNIX_EPOCH,
tagging_timestamp: OffsetDateTime::UNIX_EPOCH,
legalhold_timestamp: OffsetDateTime::UNIX_EPOCH,
replication_validity_check: false, replication_validity_check: false,
} }
} }
@@ -1675,6 +1680,16 @@ impl PutObjectOptions {
); );
} }
for (suffix, timestamp) in [
(SUFFIX_SOURCE_REPLICATION_TAGGING_TIMESTAMP, self.internal.tagging_timestamp),
(SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP, self.internal.retention_timestamp),
(SUFFIX_SOURCE_REPLICATION_LEGALHOLD_TIMESTAMP, self.internal.legalhold_timestamp),
] {
if timestamp.unix_timestamp() != 0 {
insert_header(&mut header, suffix, timestamp.format(&Rfc3339).unwrap_or_default());
}
}
if self.internal.replication_request { if self.internal.replication_request {
insert_header(&mut header, SUFFIX_SOURCE_REPLICATION_REQUEST, "true"); insert_header(&mut header, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
} }
@@ -2842,6 +2857,57 @@ mod tests {
); );
} }
#[test]
fn put_object_headers_carry_replication_timestamp_headers() {
// MinIO receivers resolve concurrent tag/retention/legal-hold edits by
// last-writer-wins on these headers (object-api-options.go parses them
// as RFC3339); a replica without them loses every conflict resolution.
let mut opts = PutObjectOptions::default();
opts.internal.replication_request = true;
let tagging = OffsetDateTime::from_unix_timestamp(1_700_000_001).expect("valid timestamp");
let retention = OffsetDateTime::from_unix_timestamp(1_700_000_002).expect("valid timestamp");
let legalhold = OffsetDateTime::from_unix_timestamp(1_700_000_003).expect("valid timestamp");
opts.internal.tagging_timestamp = tagging;
opts.internal.retention_timestamp = retention;
opts.internal.legalhold_timestamp = legalhold;
let header = opts.header();
for (suffix, expected) in [
("source-replication-tagging-timestamp", tagging),
("source-replication-retention-timestamp", retention),
("source-replication-legalhold-timestamp", legalhold),
] {
assert_eq!(
rustfs_utils::http::get_header(&header, suffix).as_deref(),
Some(expected.format(&Rfc3339).expect("RFC3339 timestamp").as_str()),
"replication put requests must carry the {suffix} header"
);
}
}
#[test]
fn put_object_headers_omit_unset_replication_timestamps() {
// UNIX_EPOCH means "never modified on the source"; sending it would
// make the receiver treat an unset category as a fresh modification.
let mut opts = PutObjectOptions::default();
opts.internal.replication_request = true;
opts.internal.tagging_timestamp = OffsetDateTime::UNIX_EPOCH;
opts.internal.retention_timestamp = OffsetDateTime::UNIX_EPOCH;
opts.internal.legalhold_timestamp = OffsetDateTime::UNIX_EPOCH;
let header = opts.header();
for suffix in [
"source-replication-tagging-timestamp",
"source-replication-retention-timestamp",
"source-replication-legalhold-timestamp",
] {
assert!(
rustfs_utils::http::get_header(&header, suffix).is_none(),
"unset {suffix} must not be sent to replication targets"
);
}
}
#[tokio::test] #[tokio::test]
async fn get_remote_target_client_internal_rejects_loopback_endpoint() { async fn get_remote_target_client_internal_rejects_loopback_endpoint() {
let sys = BucketTargetSys::default(); let sys = BucketTargetSys::default();
@@ -46,15 +46,13 @@ use crate::bucket::lifecycle::transition_transaction::run_transition_transaction
use crate::bucket::object_lock::ObjectLockApi; use crate::bucket::object_lock::ObjectLockApi;
use crate::bucket::versioning::VersioningApi as _; use crate::bucket::versioning::VersioningApi as _;
use crate::bucket::versioning_sys::BucketVersioningSys; use crate::bucket::versioning_sys::BucketVersioningSys;
use crate::client::object_api_utils::new_getobjectreader;
use crate::disk::error::DiskError; use crate::disk::error::DiskError;
use crate::disk::{DeleteOptions, Disk, DiskAPI, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, STORAGE_FORMAT_FILE}; use crate::disk::{DeleteOptions, Disk, DiskAPI, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, STORAGE_FORMAT_FILE};
use crate::error::Error; use crate::error::Error;
use crate::error::StorageError; use crate::error::StorageError;
use crate::error::{ use crate::error::{is_err_object_not_found, is_err_read_quorum, is_err_version_not_found, is_network_or_host_down};
error_resp_to_object_err, is_err_object_not_found, is_err_read_quorum, is_err_version_not_found, is_network_or_host_down,
};
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions}; use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions};
use crate::object_api::{ObjectEncryptionResolver, ReadPlan};
use crate::services::tier::{ use crate::services::tier::{
tier::{TierConfigMgr, TierOperationLease, tier_destination_id_from_metadata}, tier::{TierConfigMgr, TierOperationLease, tier_destination_id_from_metadata},
warm_backend::WarmBackendGetOpts, warm_backend::WarmBackendGetOpts,
@@ -4400,9 +4398,10 @@ pub async fn get_transitioned_object_reader(
h: &HeaderMap, h: &HeaderMap,
oi: &ObjectInfo, oi: &ObjectInfo,
opts: &ObjectOptions, opts: &ObjectOptions,
resolver: Option<&dyn ObjectEncryptionResolver>,
) -> Result<GetObjectReader, std::io::Error> { ) -> Result<GetObjectReader, std::io::Error> {
let tier_config_mgr = runtime_sources::tier_config_mgr_handle(); let tier_config_mgr = runtime_sources::tier_config_mgr_handle();
get_transitioned_object_reader_with_tier_manager(bucket, object, rs, h, oi, opts, &tier_config_mgr).await get_transitioned_object_reader_with_tier_manager(bucket, object, rs, h, oi, opts, &tier_config_mgr, resolver).await
} }
fn validate_transition_remote_version(oi: &ObjectInfo) -> Result<bool, std::io::Error> { fn validate_transition_remote_version(oi: &ObjectInfo) -> Result<bool, std::io::Error> {
@@ -4422,6 +4421,10 @@ fn validate_transition_remote_version(oi: &ObjectInfo) -> Result<bool, std::io::
} }
} }
// The resolver joins the tier manager as the second injected port this read
// needs; grouping the request half into a struct would churn every call site of
// a bug fix.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn get_transitioned_object_reader_with_tier_manager( pub(crate) async fn get_transitioned_object_reader_with_tier_manager(
bucket: &str, bucket: &str,
object: &str, object: &str,
@@ -4430,6 +4433,7 @@ pub(crate) async fn get_transitioned_object_reader_with_tier_manager(
oi: &ObjectInfo, oi: &ObjectInfo,
opts: &ObjectOptions, opts: &ObjectOptions,
tier_config_mgr: &Arc<RwLock<TierConfigMgr>>, tier_config_mgr: &Arc<RwLock<TierConfigMgr>>,
resolver: Option<&dyn ObjectEncryptionResolver>,
) -> Result<GetObjectReader, std::io::Error> { ) -> Result<GetObjectReader, std::io::Error> {
validate_transition_remote_version(oi)?; validate_transition_remote_version(oi)?;
let expected_identity = tier_destination_id_from_metadata(&oi.user_defined)?; let expected_identity = tier_destination_id_from_metadata(&oi.user_defined)?;
@@ -4447,11 +4451,16 @@ pub(crate) async fn get_transitioned_object_reader_with_tier_manager(
tgt_client.validate_remote_version_id(&oi.transitioned_object.version_id)?; tgt_client.validate_remote_version_id(&oi.transitioned_object.version_id)?;
let ret = new_getobjectreader(rs, oi, opts, h); // The same read plan the local path uses, so the tier fetch is positioned in
if let Err(err) = ret { // the object's *stored* coordinate system and the stream is handed the same
return Err(error_resp_to_object_err(err, vec![bucket, object])); // decrypt/decompress transforms. Reading an encrypted object's ciphertext
} // through a plaintext-coordinate range and skipping the transform is how a
let (get_fn, off, length) = ret.expect("get_transitioned_object_reader should succeed after error check"); // transitioned SSE object used to come back as silently corrupt bytes of the
// right length (rustfs/rustfs#6025).
let plan = ReadPlan::build_for_request(rs.clone(), oi, opts, h, resolver)
.await
.map_err(|err| std::io::Error::other(format!("building the read plan for {bucket}/{object} failed: {err}")))?;
let (off, length) = (plan.storage_offset() as i64, plan.storage_length());
let mut gopts = WarmBackendGetOpts::default(); let mut gopts = WarmBackendGetOpts::default();
if off >= 0 && length >= 0 { if off >= 0 && length >= 0 {
@@ -4488,7 +4497,10 @@ pub(crate) async fn get_transitioned_object_reader_with_tier_manager(
); );
e e
})?; })?;
Ok(attach_tier_operation_lease(get_fn(reader, h.clone()), tgt_client)) let object_reader = plan
.into_object_reader(Box::new(reader), oi)
.map_err(|err| std::io::Error::other(format!("wrapping the tier stream for {bucket}/{object} failed: {err}")))?;
Ok(attach_tier_operation_lease(object_reader, tgt_client))
} }
struct TierOperationLeaseReader { struct TierOperationLeaseReader {
@@ -5776,6 +5788,7 @@ mod tests {
&object_info, &object_info,
&ObjectOptions::default(), &ObjectOptions::default(),
&manager, &manager,
None,
) )
.await .await
.expect("transitioned reader should open"); .expect("transitioned reader should open");
@@ -5840,6 +5853,7 @@ mod tests {
&object_info, &object_info,
&ObjectOptions::default(), &ObjectOptions::default(),
&manager, &manager,
None,
) )
.await .await
{ {
@@ -5880,6 +5894,7 @@ mod tests {
&object_info, &object_info,
&ObjectOptions::default(), &ObjectOptions::default(),
&manager, &manager,
None,
) )
.await .await
{ {
@@ -6117,6 +6132,7 @@ mod tests {
&oi, &oi,
&ObjectOptions::default(), &ObjectOptions::default(),
&manager, &manager,
None,
) )
.await .await
{ {
@@ -6140,6 +6156,7 @@ mod tests {
&oi, &oi,
&ObjectOptions::default(), &ObjectOptions::default(),
&manager, &manager,
None,
) )
.await .await
{ {
+160 -1
View File
@@ -50,6 +50,72 @@ use uuid::Uuid;
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60); const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
#[cfg(any(test, feature = "test-util"))]
struct ConfigWriteLockProbeState {
bucket: String,
arrived: tokio::sync::Notify,
}
#[cfg(any(test, feature = "test-util"))]
static CONFIG_WRITE_LOCK_PROBES: std::sync::OnceLock<StdMutex<Vec<Arc<ConfigWriteLockProbeState>>>> = std::sync::OnceLock::new();
#[cfg(any(test, feature = "test-util"))]
pub struct ConfigWriteLockProbe {
state: Arc<ConfigWriteLockProbeState>,
}
#[cfg(any(test, feature = "test-util"))]
impl ConfigWriteLockProbe {
pub fn install(bucket: &str) -> Self {
let state = Arc::new(ConfigWriteLockProbeState {
bucket: bucket.to_string(),
arrived: tokio::sync::Notify::new(),
});
let mut probes = CONFIG_WRITE_LOCK_PROBES
.get_or_init(|| StdMutex::new(Vec::new()))
.lock()
.expect("config write lock probe mutex should not poison");
assert!(
!probes.iter().any(|current| current.bucket == state.bucket),
"config write lock probe must be unique for a bucket"
);
probes.push(Arc::clone(&state));
drop(probes);
Self { state }
}
pub async fn wait_until_attempted(&self) {
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
.await
.expect("bucket config update should attempt the transaction lock");
}
}
#[cfg(any(test, feature = "test-util"))]
impl Drop for ConfigWriteLockProbe {
fn drop(&mut self) {
let mut probes = CONFIG_WRITE_LOCK_PROBES
.get_or_init(|| StdMutex::new(Vec::new()))
.lock()
.expect("config write lock probe mutex should not poison");
probes.retain(|state| !Arc::ptr_eq(state, &self.state));
}
}
#[cfg(any(test, feature = "test-util"))]
fn notify_config_write_lock_attempt(bucket: &str) {
let probe = CONFIG_WRITE_LOCK_PROBES
.get_or_init(|| StdMutex::new(Vec::new()))
.lock()
.expect("config write lock probe mutex should not poison")
.iter()
.find(|probe| probe.bucket == bucket)
.cloned();
if let Some(probe) = probe {
probe.arrived.notify_one();
}
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum MetadataLoadMode { enum MetadataLoadMode {
Initial, Initial,
@@ -590,6 +656,41 @@ pub async fn update_under_transaction_lock(
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data).await update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data).await
} }
/// Clear one config file while the caller holds this bucket's transaction lock.
pub async fn delete_under_transaction_lock(
guard: &BucketMetadataMutationGuard,
bucket: &str,
config_file: &str,
) -> Result<OffsetDateTime> {
guard.ensure_valid(bucket)?;
delete_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file).await
}
pub async fn update_quota_if_incarnation(
bucket: &str,
data: Vec<u8>,
expected_incarnation_id: Uuid,
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
) -> Result<OffsetDateTime> {
let sys = get_bucket_metadata_sys()?;
let guard = Box::pin(acquire_config_write_guard_for_incarnation(
sys.clone(),
bucket,
Some(expected_incarnation_id),
))
.await?;
if !crate::services::notification_sys::cross_pool_fence_fleet_proof_matches(proof) {
return Err(Error::NamespaceLockQuorumUnavailable {
mode: "quota_capability",
bucket: bucket.to_string(),
object: rustfs_config::QUOTA_CONFIG_FILE.to_string(),
required: 1,
achieved: 0,
});
}
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data).await
}
pub async fn update_bucket_targets_under_transaction_lock( pub async fn update_bucket_targets_under_transaction_lock(
guard: &BucketMetadataMutationGuard, guard: &BucketMetadataMutationGuard,
bucket: &str, bucket: &str,
@@ -704,6 +805,14 @@ pub async fn acquire_bucket_metadata_transaction_lock(bucket: &str) -> Result<Bu
acquire_config_write_guard(get_bucket_metadata_sys()?, bucket).await acquire_config_write_guard(get_bucket_metadata_sys()?, bucket).await
} }
/// Acquire the bucket transaction lock only if its incarnation still matches.
pub async fn acquire_bucket_metadata_transaction_lock_for_incarnation(
bucket: &str,
expected_incarnation_id: Uuid,
) -> Result<BucketMetadataMutationGuard> {
acquire_config_write_guard_for_incarnation(get_bucket_metadata_sys()?, bucket, Some(expected_incarnation_id)).await
}
pub(crate) async fn acquire_bucket_metadata_transaction_lock_in( pub(crate) async fn acquire_bucket_metadata_transaction_lock_in(
ctx: &crate::runtime::instance::InstanceContext, ctx: &crate::runtime::instance::InstanceContext,
bucket: &str, bucket: &str,
@@ -734,7 +843,26 @@ async fn acquire_transaction_lock_with_sys(
let lock = api let lock = api
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket)) .new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
.await?; .await?;
Ok(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?) let acquire = lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout());
#[cfg(any(test, feature = "test-util"))]
{
tokio::pin!(acquire);
let mut notified = false;
let guard = futures::future::poll_fn(|cx| match std::future::Future::poll(acquire.as_mut(), cx) {
std::task::Poll::Pending => {
if !notified {
notify_config_write_lock_attempt(bucket);
notified = true;
}
std::task::Poll::Pending
}
std::task::Poll::Ready(result) => std::task::Poll::Ready(result),
})
.await?;
Ok(guard)
}
#[cfg(not(any(test, feature = "test-util")))]
Ok(acquire.await?)
} }
/// The lock resource name is deliberately still the `bucket-targets` one it /// The lock resource name is deliberately still the `bucket-targets` one it
@@ -889,6 +1017,37 @@ pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_in(
} }
} }
/// Re-read the quota configuration and bucket incarnation from the same
/// authoritative metadata blob while the caller holds the bucket metadata
/// transaction read lock.
pub(crate) async fn get_quota_config_and_incarnation_from_disk_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<(Option<BucketQuota>, Uuid, OffsetDateTime)> {
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
match bucket_meta_sys
.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)
.await?
{
BucketMetadataAuthority::Authoritative(metadata)
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
{
Ok((
metadata.quota_config.clone(),
metadata.bucket_incarnation_id,
metadata.quota_config_updated_at,
))
}
BucketMetadataAuthority::Authoritative(_) => {
Err(Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}")))
}
BucketMetadataAuthority::MissingBucket => Err(Error::BucketNotFound(bucket.to_string())),
BucketMetadataAuthority::Fabricated => Err(Error::other(format!("bucket quota metadata is not authoritative: {bucket}"))),
}
}
pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> { pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?; let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await; let bucket_meta_sys = bucket_meta_sys_lock.read().await;
+38 -2
View File
@@ -52,6 +52,7 @@ impl QuotaChecker {
) -> Result<QuotaCheckResult, QuotaError> { ) -> Result<QuotaCheckResult, QuotaError> {
let start_time = Instant::now(); let start_time = Instant::now();
let quota_config = self.get_quota_config(bucket).await?; let quota_config = self.get_quota_config(bucket).await?;
let uses_durable_reservations = quota_config.uses_durable_reservations();
// If no quota limit is set, allow operation // If no quota limit is set, allow operation
let quota_limit = match quota_config.quota { let quota_limit = match quota_config.quota {
@@ -67,6 +68,7 @@ impl QuotaChecker {
quota_limit: None, quota_limit: None,
operation_size, operation_size,
remaining: None, remaining: None,
uses_durable_reservations,
}); });
} }
Some(q) => q, Some(q) => q,
@@ -74,14 +76,17 @@ impl QuotaChecker {
let current_usage = self.get_real_time_usage(bucket).await?; let current_usage = self.get_real_time_usage(bucket).await?;
let admission_size = if uses_durable_reservations { 0 } else { operation_size };
let expected_usage = match operation { let expected_usage = match operation {
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => current_usage + operation_size, QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
current_usage.saturating_add(admission_size)
}
QuotaOperation::DeleteObject => current_usage.saturating_sub(operation_size), QuotaOperation::DeleteObject => current_usage.saturating_sub(operation_size),
}; };
let allowed = match operation { let allowed = match operation {
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => { QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
quota_config.check_operation_allowed(current_usage, operation_size) quota_config.check_operation_allowed(current_usage, admission_size)
} }
QuotaOperation::DeleteObject => true, QuotaOperation::DeleteObject => true,
}; };
@@ -105,6 +110,7 @@ impl QuotaChecker {
quota_limit: Some(quota_limit), quota_limit: Some(quota_limit),
operation_size, operation_size,
remaining, remaining,
uses_durable_reservations,
}; };
let duration = start_time.elapsed(); let duration = start_time.elapsed();
@@ -158,6 +164,26 @@ impl QuotaChecker {
.await .await
} }
pub async fn set_durable_quota_config_if_incarnation(
&mut self,
bucket: &str,
quota: BucketQuota,
expected_incarnation_id: uuid::Uuid,
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
) -> Result<OffsetDateTime, QuotaError> {
let json_data = serde_json::to_vec(&quota).map_err(|e| QuotaError::InvalidConfig {
reason: format!("Failed to serialize quota config: {}", e),
})?;
let start_time = Instant::now();
let updated_at =
crate::bucket::metadata_sys::update_quota_if_incarnation(bucket, json_data, expected_incarnation_id, proof)
.await
.map_err(QuotaError::StorageError)?;
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
Ok(updated_at)
}
async fn set_quota_config_for_incarnation( async fn set_quota_config_for_incarnation(
&mut self, &mut self,
bucket: &str, bucket: &str,
@@ -355,6 +381,7 @@ mod tests {
quota_limit: None, quota_limit: None,
operation_size: 1024, operation_size: 1024,
remaining: None, remaining: None,
uses_durable_reservations: false,
}; };
assert!(result.allowed); assert!(result.allowed);
@@ -378,4 +405,13 @@ mod tests {
let allowed = quota.check_operation_allowed(512, 1024); let allowed = quota.check_operation_allowed(512, 1024);
assert!(!allowed); assert!(!allowed);
} }
#[test]
fn legacy_quota_rejects_full_operation_while_v1_defers_net_growth() {
let legacy: BucketQuota = serde_json::from_str(r#"{"quota":5}"#).expect("legacy quota should parse");
let durable = BucketQuota::new(Some(5));
assert!(!legacy.check_operation_allowed(4, 2));
assert!(durable.uses_durable_reservations());
}
} }
+134 -8
View File
@@ -13,40 +13,100 @@
// limitations under the License. // limitations under the License.
pub mod checker; pub mod checker;
pub(crate) mod reservation;
use crate::error::Result; use crate::error::Result;
use rustfs_config::{ use rustfs_config::{
QUOTA_API_PATH, QUOTA_EXCEEDED_ERROR_CODE, QUOTA_INTERNAL_ERROR_CODE, QUOTA_INVALID_CONFIG_ERROR_CODE, QUOTA_API_PATH, QUOTA_EXCEEDED_ERROR_CODE, QUOTA_INTERNAL_ERROR_CODE, QUOTA_INVALID_CONFIG_ERROR_CODE,
QUOTA_NOT_FOUND_ERROR_CODE, QUOTA_NOT_FOUND_ERROR_CODE,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
use thiserror::Error; use thiserror::Error;
use time::OffsetDateTime; use time::OffsetDateTime;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum QuotaType { pub enum QuotaType {
/// Hard quota: reject immediately when exceeded /// Hard quota accounting.
#[default] #[default]
#[serde(alias = "HARD", alias = "hard")] #[serde(alias = "HARD", alias = "hard")]
Hard, Hard,
} }
pub(crate) const QUOTA_RESERVATION_PROTOCOL_V1: u32 = 1;
/// Bucket quota configuration. quota_type defaults to Hard when omitted. /// Bucket quota configuration. quota_type defaults to Hard when omitted.
#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)] #[derive(Debug, Default, Clone, PartialEq)]
pub struct BucketQuota { pub struct BucketQuota {
#[serde(default)]
pub quota: Option<u64>, pub quota: Option<u64>,
/// Defaults to Hard when missing. /// Defaults to Hard when missing.
#[serde(default)]
pub quota_type: QuotaType, pub quota_type: QuotaType,
/// Optional durable reservation protocol. The wire format gives older
/// nodes a zero hard quota so a mixed-version fleet fails closed.
pub reservation_protocol: Option<u32>,
/// Timestamp when this quota configuration was set (for audit purposes) /// Timestamp when this quota configuration was set (for audit purposes)
#[serde(default, with = "time::serde::rfc3339::option")]
pub created_at: Option<OffsetDateTime>, pub created_at: Option<OffsetDateTime>,
/// Accept updated_at for compatibility; not used. /// Accept updated_at for compatibility; not used.
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
pub updated_at: Option<OffsetDateTime>, pub updated_at: Option<OffsetDateTime>,
} }
#[derive(Deserialize, Serialize)]
struct BucketQuotaWire {
#[serde(default)]
quota: Option<u64>,
#[serde(default)]
quota_type: QuotaType,
#[serde(default, skip_serializing_if = "Option::is_none")]
reservation_protocol: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reservation_quota: Option<u64>,
#[serde(default, with = "time::serde::rfc3339::option")]
created_at: Option<OffsetDateTime>,
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
updated_at: Option<OffsetDateTime>,
}
impl Serialize for BucketQuota {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let durable = self.uses_durable_reservations();
BucketQuotaWire {
quota: if durable { Some(0) } else { self.quota },
quota_type: self.quota_type.clone(),
reservation_protocol: self.reservation_protocol,
reservation_quota: if durable { self.quota } else { None },
created_at: self.created_at,
updated_at: self.updated_at,
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for BucketQuota {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = BucketQuotaWire::deserialize(deserializer)?;
let quota = if wire.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V1) {
Some(
wire.reservation_quota
.ok_or_else(|| D::Error::custom("reservation_quota is required for reservation protocol v1"))?,
)
} else {
wire.quota
};
Ok(Self {
quota,
quota_type: wire.quota_type,
reservation_protocol: wire.reservation_protocol,
created_at: wire.created_at,
updated_at: wire.updated_at,
})
}
}
impl BucketQuota { impl BucketQuota {
/// Serialize to JSON bytes. Same format as parse_all_configs. /// Serialize to JSON bytes. Same format as parse_all_configs.
pub fn marshal_msg(&self) -> Result<Vec<u8>> { pub fn marshal_msg(&self) -> Result<Vec<u8>> {
@@ -63,6 +123,7 @@ impl BucketQuota {
Self { Self {
quota, quota,
quota_type: QuotaType::Hard, quota_type: QuotaType::Hard,
reservation_protocol: quota.map(|_| QUOTA_RESERVATION_PROTOCOL_V1),
created_at: Some(now), created_at: Some(now),
updated_at: None, updated_at: None,
} }
@@ -72,7 +133,19 @@ impl BucketQuota {
self.quota self.quota
} }
pub fn uses_durable_reservations(&self) -> bool {
self.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V1)
}
pub fn has_unsupported_reservation_protocol(&self) -> bool {
self.reservation_protocol
.is_some_and(|version| version != QUOTA_RESERVATION_PROTOCOL_V1)
}
pub fn check_operation_allowed(&self, current_usage: u64, operation_size: u64) -> bool { pub fn check_operation_allowed(&self, current_usage: u64, operation_size: u64) -> bool {
if operation_size == 0 {
return true;
}
if let Some(quota_limit) = self.quota { if let Some(quota_limit) = self.quota {
current_usage.saturating_add(operation_size) <= quota_limit current_usage.saturating_add(operation_size) <= quota_limit
} else { } else {
@@ -94,6 +167,7 @@ pub struct QuotaCheckResult {
pub quota_limit: Option<u64>, pub quota_limit: Option<u64>,
pub operation_size: u64, pub operation_size: u64,
pub remaining: Option<u64>, pub remaining: Option<u64>,
pub uses_durable_reservations: bool,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -210,7 +284,59 @@ mod tests {
let buf = q.marshal_msg().expect("marshal"); let buf = q.marshal_msg().expect("marshal");
let restored = BucketQuota::unmarshal(&buf).expect("unmarshal"); let restored = BucketQuota::unmarshal(&buf).expect("unmarshal");
assert_eq!(q.quota, restored.quota); assert_eq!(q.quota, restored.quota);
assert_eq!(q.quota_type, restored.quota_type); assert_eq!(restored.quota_type, QuotaType::Hard);
assert_eq!(restored.reservation_protocol, Some(QUOTA_RESERVATION_PROTOCOL_V1));
}
#[test]
fn clearing_quota_keeps_the_legacy_compatible_type() {
let quota = BucketQuota::new(None);
assert_eq!(quota.quota_type, QuotaType::Hard);
assert_eq!(quota.reservation_protocol, None);
assert!(!quota.uses_durable_reservations());
}
#[test]
fn durable_quota_makes_legacy_nodes_fail_closed() {
let json = serde_json::to_vec(&BucketQuota::new(Some(2048))).expect("durable quota should serialize");
let quota: BucketQuota = serde_json::from_slice(&json).expect("current quota version should parse");
assert!(quota.uses_durable_reservations());
assert_eq!(quota.quota, Some(2048));
#[derive(Deserialize)]
enum LegacyQuotaType {
Hard,
}
#[derive(Deserialize)]
struct LegacyBucketQuota {
#[allow(dead_code)]
quota: Option<u64>,
#[allow(dead_code)]
quota_type: LegacyQuotaType,
}
let legacy = serde_json::from_slice::<LegacyBucketQuota>(&json)
.expect("legacy readers should ignore the reservation protocol field");
assert_eq!(legacy.quota, Some(0));
assert!(matches!(legacy.quota_type, LegacyQuotaType::Hard));
}
#[test]
fn unknown_reservation_protocol_does_not_activate_v1() {
let quota: BucketQuota =
serde_json::from_str(r#"{"quota":0,"quota_type":"Hard","reservation_protocol":2,"reservation_quota":2048}"#)
.expect("future protocol should remain parseable");
assert!(!quota.uses_durable_reservations());
assert!(quota.has_unsupported_reservation_protocol());
}
#[test]
fn reservation_protocol_v1_requires_reservation_quota() {
let err = serde_json::from_str::<BucketQuota>(r#"{"quota":0,"quota_type":"Hard","reservation_protocol":1}"#)
.expect_err("v1 without its authoritative quota must fail closed");
assert!(err.to_string().contains("reservation_quota is required"));
} }
/// unmarshal accepts format without quota_type /// unmarshal accepts format without quota_type
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -81,6 +81,6 @@ pub use replication_queue_boundary::{
pub use replication_resync_boundary::{BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus}; pub use replication_resync_boundary::{BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus};
pub use replication_scanner_bridge::ReplicationScannerBridge; pub use replication_scanner_bridge::ReplicationScannerBridge;
pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog}; pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
pub use replication_stats_boundary::{BucketReplicationStats, BucketStats}; pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage}; pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage};
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge; pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
@@ -704,6 +704,12 @@ impl ReplicationStats {
} else { } else {
BucketReplicationStats::new() BucketReplicationStats::new()
}; };
// Stamp the serializable failure windows from the live samples: the
// samples themselves do not cross the peer-RPC wire, so this snapshot
// is what cluster aggregation and the metrics endpoints see.
for stat in replication_stats.stats.values_mut() {
stat.fail_stats.refresh_windows();
}
let uptime = if cache.contains_key(bucket) { let uptime = if cache.contains_key(bucket) {
SystemTime::now() SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
@@ -15,7 +15,9 @@
#[cfg(test)] #[cfg(test)]
pub(crate) use rustfs_replication::FailStats; pub(crate) use rustfs_replication::FailStats;
pub(crate) use rustfs_replication::{ pub(crate) use rustfs_replication::{
ActiveWorkerStat, BucketReplicationStat, InQueueMetric, ProxyMetric, ProxyStatsCache, QueueCache, ReplicationMetricScope, ActiveWorkerStat, ProxyMetric, ProxyStatsCache, QueueCache, ReplicationMetricScope, SRMetricsSummary,
SRMetricsSummary, XferStats,
}; };
pub use rustfs_replication::{BucketReplicationStats, BucketStats}; // Public so the admin wire DTOs (rustfs/src/admin/replication_metrics_wire.rs)
// can project the internal stats onto the minio-go response shapes through
// the storage_api facade chain.
pub use rustfs_replication::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
@@ -27,8 +27,10 @@ use rustfs_utils::http::{
AMZ_OBJECT_TAGGING, AMZ_SERVER_SIDE_ENCRYPTION, AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, AMZ_OBJECT_TAGGING, AMZ_SERVER_SIDE_ENCRYPTION, AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID,
AMZ_STORAGE_CLASS, AMZ_TAG_COUNT, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, AMZ_STORAGE_CLASS, AMZ_TAG_COUNT, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE,
HeaderExt as _, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP, HeaderExt as _, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP,
SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_TAGGING_TIMESTAMP, get_str, insert_header_map, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_SOURCE_REPLICATION_LEGALHOLD_TIMESTAMP,
is_internal_key, is_object_encryption_marker, is_replication_stripped_encryption_key, ssec_replication_transport_header, SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP, SUFFIX_SOURCE_REPLICATION_TAGGING_TIMESTAMP, SUFFIX_TAGGING_TIMESTAMP,
get_str, insert_header_map, is_internal_key, is_object_encryption_marker, is_replication_stripped_encryption_key,
ssec_replication_transport_header,
}; };
use time::OffsetDateTime; use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339; use time::format_description::well_known::Rfc3339;
@@ -119,6 +121,27 @@ fn classify_replication_source_encryption(metadata: &HashMap<String, String>) ->
} }
} }
fn is_legacy_source_replication_timestamp_key(key: &str) -> bool {
fn has_prefix_and_suffix(key: &str, prefix: &str, suffix: &str) -> bool {
let key = key.as_bytes();
key.len() == prefix.len() + suffix.len()
&& key[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
&& key[prefix.len()..].eq_ignore_ascii_case(suffix.as_bytes())
}
[
SUFFIX_SOURCE_REPLICATION_TAGGING_TIMESTAMP,
SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP,
SUFFIX_SOURCE_REPLICATION_LEGALHOLD_TIMESTAMP,
]
.iter()
.any(|suffix| {
["x-rustfs-", "x-minio-"]
.iter()
.any(|prefix| has_prefix_and_suffix(key, prefix, suffix))
})
}
pub(crate) fn replication_object_is_ssec_encrypted(user_defined: &HashMap<String, String>) -> bool { pub(crate) fn replication_object_is_ssec_encrypted(user_defined: &HashMap<String, String>) -> bool {
rustfs_replication::is_ssec_encrypted(user_defined) rustfs_replication::is_ssec_encrypted(user_defined)
} }
@@ -176,6 +199,11 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
continue; continue;
} }
if is_legacy_source_replication_timestamp_key(key) {
meta.insert(format!("x-amz-meta-{key}"), value.to_string());
continue;
}
if is_internal_key(key) || is_standard_header(key) { if is_internal_key(key) || is_standard_header(key) {
continue; continue;
} }
@@ -259,15 +287,23 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
if !tags.is_empty() { if !tags.is_empty() {
put_options.user_tags = tags; put_options.user_tags = tags;
put_options.internal.tagging_timestamp =
if let Some(timestamp) = get_str(&object_info.user_defined, SUFFIX_TAGGING_TIMESTAMP) {
OffsetDateTime::parse(&timestamp, &Rfc3339)
.map_err(|err| Error::other(format!("Failed to parse tagging timestamp: {err}")))?
} else {
object_info.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH)
};
} }
} }
// Load the stored tagging timestamp independently of whether any tags
// remain: DeleteObjectTagging leaves the object tagless but stamps this
// key, and the deletion's LWW timestamp must still reach the replica.
// With no stored key, fall back to mod_time only while tags exist
// (MinIO parity); a tagless object without the key was never tagged and
// keeps the epoch default (no header).
put_options.internal.tagging_timestamp = if let Some(timestamp) = get_str(&object_info.user_defined, SUFFIX_TAGGING_TIMESTAMP)
{
OffsetDateTime::parse(&timestamp, &Rfc3339)
.map_err(|err| Error::other(format!("Failed to parse tagging timestamp: {err}")))?
} else if !put_options.user_tags.is_empty() {
object_info.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH)
} else {
OffsetDateTime::UNIX_EPOCH
};
let metadata = &*object_info.user_defined; let metadata = &*object_info.user_defined;
@@ -283,13 +319,15 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
put_options.cache_control = cache_control.to_string(); put_options.cache_control = cache_control.to_string();
} }
if let Some(mode) = metadata.lookup(AMZ_OBJECT_LOCK_MODE) { if let Some(mode) = metadata.lookup(AMZ_OBJECT_LOCK_MODE).filter(|mode| !mode.is_empty()) {
put_options.mode = Some(ObjectLockRetentionMode::from(mode.to_uppercase().as_str())); put_options.mode = Some(ObjectLockRetentionMode::from(mode.to_uppercase().as_str()));
} }
if let Some(retain_until_date) = metadata.lookup(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE) { if let Some(retain_until_date) = metadata.lookup(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE) {
put_options.retain_until_date = OffsetDateTime::parse(retain_until_date, &Rfc3339) if !retain_until_date.is_empty() {
.map_err(|err| Error::other(format!("Failed to parse retain until date: {err}")))?; put_options.retain_until_date = OffsetDateTime::parse(retain_until_date, &Rfc3339)
.map_err(|err| Error::other(format!("Failed to parse retain until date: {err}")))?;
}
put_options.internal.retention_timestamp = put_options.internal.retention_timestamp =
if let Some(timestamp) = get_str(&object_info.user_defined, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP) { if let Some(timestamp) = get_str(&object_info.user_defined, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP) {
OffsetDateTime::parse(&timestamp, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH) OffsetDateTime::parse(&timestamp, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH)
@@ -694,6 +732,110 @@ mod tests {
assert!(options.internal.replication_request); assert!(options.internal.replication_request);
} }
/// DeleteObjectTagging leaves the object tagless but stamps the
/// tagging-timestamp internal key; the deletion's LWW timestamp must
/// still be loaded (and therefore sent) so the replica can order the
/// deletion against concurrent tag edits.
#[test]
fn replication_put_options_carry_tagging_timestamp_after_tag_deletion() {
let mut metadata = std::collections::HashMap::new();
rustfs_utils::http::insert_str(&mut metadata, SUFFIX_TAGGING_TIMESTAMP, "2026-01-02T03:04:05Z".to_string());
let object_info = ObjectInfo {
user_defined: Arc::new(metadata),
user_tags: Arc::new(String::new()),
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
version_id: Some(Uuid::nil()),
..Default::default()
};
let (options, _) = replication_put_object_options("", &object_info).expect("build put options");
assert!(options.user_tags.is_empty());
assert_eq!(
options.internal.tagging_timestamp,
OffsetDateTime::parse("2026-01-02T03:04:05Z", &Rfc3339).expect("valid timestamp"),
"the stored tagging timestamp must load independently of remaining tags"
);
// A tagless object without the stored key was never tagged: the epoch
// default keeps the header unsent.
let untagged = ObjectInfo {
user_tags: Arc::new(String::new()),
mod_time: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp")),
version_id: Some(Uuid::nil()),
..Default::default()
};
let (options, _) = replication_put_object_options("", &untagged).expect("build put options");
assert_eq!(options.internal.tagging_timestamp, OffsetDateTime::UNIX_EPOCH);
}
#[test]
fn replication_put_options_do_not_promote_legacy_user_timestamp_metadata() {
let legacy_keys = [
"x-rustfs-source-replication-tagging-timestamp",
"x-rustfs-source-replication-retention-timestamp",
"x-rustfs-source-replication-legalhold-timestamp",
"x-minio-source-replication-tagging-timestamp",
"x-minio-source-replication-retention-timestamp",
"x-minio-source-replication-legalhold-timestamp",
];
let object_info = ObjectInfo {
user_defined: Arc::new(
legacy_keys
.iter()
.map(|key| (key.to_string(), "2099-01-02T03:04:05Z".to_string()))
.collect(),
),
..Default::default()
};
let (options, _) = replication_put_object_options("", &object_info).expect("build put options");
for legacy_key in legacy_keys {
assert!(!options.user_metadata.contains_key(legacy_key));
assert_eq!(
options
.user_metadata
.get(&format!("x-amz-meta-{legacy_key}"))
.map(String::as_str),
Some("2099-01-02T03:04:05Z")
);
}
assert_eq!(options.internal.tagging_timestamp, OffsetDateTime::UNIX_EPOCH);
assert_eq!(options.internal.retention_timestamp, OffsetDateTime::UNIX_EPOCH);
assert_eq!(options.internal.legalhold_timestamp, OffsetDateTime::UNIX_EPOCH);
}
#[test]
fn replication_put_options_carry_retention_timestamp_after_clear() {
let mut metadata = HashMap::from([
(AMZ_OBJECT_LOCK_MODE.to_string(), String::new()),
(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.to_string(), String::new()),
]);
rustfs_utils::http::insert_str(&mut metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP, "2026-01-02T03:04:05Z".to_string());
let object_info = ObjectInfo {
user_defined: Arc::new(metadata),
..Default::default()
};
let (options, _) = replication_put_object_options("", &object_info).expect("retention clear must replicate");
assert!(options.mode.is_none());
assert_eq!(options.retain_until_date, OffsetDateTime::UNIX_EPOCH);
assert_eq!(
options.internal.retention_timestamp,
OffsetDateTime::parse("2026-01-02T03:04:05Z", &Rfc3339).expect("valid timestamp")
);
let headers = options.header();
assert!(!headers.contains_key(AMZ_OBJECT_LOCK_MODE));
assert!(!headers.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE));
assert_eq!(
rustfs_utils::http::get_header(&headers, SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP).as_deref(),
Some("2026-01-02T03:04:05Z")
);
}
#[test] #[test]
fn replication_put_options_strip_encryption_metadata_from_plaintext_objects() { fn replication_put_options_strip_encryption_metadata_from_plaintext_objects() {
use rustfs_utils::http::object_encryption_keys::{INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER, SSEC_ORIGINAL_SIZE_HEADER}; use rustfs_utils::http::object_encryption_keys::{INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER, SSEC_ORIGINAL_SIZE_HEADER};
+52 -4
View File
@@ -40,7 +40,14 @@ impl ARN {
impl Display for ARN { impl Display for ARN {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "arn:rustfs:{}:{}:{}:{}", self.arn_type, self.region, self.id, self.bucket) // The `minio` partition is deliberate: madmin-go's ParseARN
// hard-rejects any other partition, so native mc/madmin tooling can
// only decode remote-target ARNs minted in this form (backlog#1675
// P1-7). Legacy `arn:rustfs:` ARNs persisted by older releases stay
// readable via the FromStr whitelist below; runtime matching between
// targets and replication rules is by full-string equality, so mixed
// partitions coexist safely.
write!(f, "arn:minio:{}:{}:{}:{}", self.arn_type, self.region, self.id, self.bucket)
} }
} }
@@ -48,7 +55,12 @@ impl FromStr for ARN {
type Err = std::io::Error; type Err = std::io::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
if !s.starts_with("arn:rustfs:") { // Partition whitelist, not just an `arn:` check: `BucketTargetType::
// from_str(...).unwrap_or_default()` below never fails, so this is
// the only structural gate rejecting foreign ARNs. `arn:rustfs:` is
// the legacy partition and must stay accepted forever (persisted
// bucket-targets.json / replication configs from older releases).
if !s.starts_with("arn:minio:") && !s.starts_with("arn:rustfs:") {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid ARN format")); return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid ARN format"));
} }
@@ -101,14 +113,50 @@ mod tests {
} }
/// RustFS commonly generates ARNs with an empty region: /// RustFS commonly generates ARNs with an empty region:
/// `arn:rustfs:replication::<deployment_id>:<bucket>`. /// `arn:minio:replication::<deployment_id>:<bucket>`.
#[test] #[test]
fn from_str_handles_empty_region_segment() { fn from_str_handles_empty_region_segment() {
let parsed = ARN::from_str("arn:rustfs:replication::depl-123:bucket-a").expect("valid ARN must parse"); let parsed = ARN::from_str("arn:minio:replication::depl-123:bucket-a").expect("valid ARN must parse");
assert_eq!(parsed.arn_type, BucketTargetType::ReplicationService); assert_eq!(parsed.arn_type, BucketTargetType::ReplicationService);
assert_eq!(parsed.region, "", "region segment is empty in this form"); assert_eq!(parsed.region, "", "region segment is empty in this form");
assert_eq!(parsed.id, "depl-123"); assert_eq!(parsed.id, "depl-123");
assert_eq!(parsed.bucket, "bucket-a"); assert_eq!(parsed.bucket, "bucket-a");
} }
/// madmin-go's `ParseARN` hard-rejects anything that does not start with
/// `arn:minio:`, so generated ARNs must use the `minio` partition or the
/// native mc/madmin tooling cannot decode remote-target listings.
#[test]
fn display_emits_minio_partition() {
let arn = ARN::new(
BucketTargetType::ReplicationService,
"depl-123".to_string(),
String::new(),
"bucket-a".to_string(),
);
assert_eq!(arn.to_string(), "arn:minio:replication::depl-123:bucket-a");
}
/// Persisted bucket-targets.json files from older RustFS releases carry
/// `arn:rustfs:` ARNs; the legacy partition must stay parseable forever.
#[test]
fn from_str_accepts_legacy_rustfs_partition() {
let parsed = ARN::from_str("arn:rustfs:replication:us-east-1:depl-123:bucket-a").expect("legacy ARN must parse");
assert_eq!(parsed.arn_type, BucketTargetType::ReplicationService);
assert_eq!(parsed.region, "us-east-1");
assert_eq!(parsed.id, "depl-123");
assert_eq!(parsed.bucket, "bucket-a");
}
/// The partition whitelist is the only structural gate: `BucketTargetType::
/// from_str(...).unwrap_or_default()` never fails, so any 6-segment string
/// would otherwise parse as `type=None`.
#[test]
fn from_str_rejects_unknown_partition() {
assert!(ARN::from_str("arn:aws:replication::depl-123:bucket-a").is_err());
assert!(ARN::from_str("not-an-arn").is_err());
}
} }
@@ -15,6 +15,7 @@
use crate::disk::disk_store::{get_drive_walkdir_peek_timeout, get_drive_walkdir_stall_timeout}; use crate::disk::disk_store::{get_drive_walkdir_peek_timeout, get_drive_walkdir_stall_timeout};
use crate::disk::error::DiskError; use crate::disk::error::DiskError;
use crate::disk::{self, DiskAPI, DiskStore, WalkDirOptions}; use crate::disk::{self, DiskAPI, DiskStore, WalkDirOptions};
use futures::future::join_all;
use metrics::counter; use metrics::counter;
use rustfs_filemeta::{MetaCacheEntries, MetaCacheEntry, MetacacheReader, is_io_eof}; use rustfs_filemeta::{MetaCacheEntries, MetaCacheEntry, MetacacheReader, is_io_eof};
use std::{ use std::{
@@ -655,6 +656,7 @@ async fn list_path_raw_inner(
errs.push(None); errs.push(None);
} }
let mut pending_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()]; let mut pending_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
let mut peek_outcomes: Vec<Option<PeekOutcome>> = std::iter::repeat_with(|| None).take(readers.len()).collect();
loop { loop {
let mut current = MetaCacheEntry::default(); let mut current = MetaCacheEntry::default();
@@ -676,6 +678,21 @@ async fn list_path_raw_inner(
let mut has_err = 0; let mut has_err = 0;
let mut agree = 0; let mut agree = 0;
// Start every missing head read in the same round so one stalled
// disk cannot multiply the wait budget by the erasure-set width.
// Outcomes are still consumed below in stable disk-index order.
let concurrent_peeks = readers.iter_mut().enumerate().filter_map(|(i, reader)| {
if errs[i].is_some() || pending_entries[i].is_some() {
return None;
}
let cancel = &revjob_rx;
Some(async move { (i, peek_with_timeout(cancel, reader, peek_timeout).await) })
});
for (i, outcome) in join_all(concurrent_peeks).await {
peek_outcomes[i] = Some(outcome);
}
for (i, r) in readers.iter_mut().enumerate() { for (i, r) in readers.iter_mut().enumerate() {
if errs[i].is_some() { if errs[i].is_some() {
has_err += 1; has_err += 1;
@@ -685,7 +702,10 @@ async fn list_path_raw_inner(
let entry = if let Some(entry) = pending_entries[i].take() { let entry = if let Some(entry) = pending_entries[i].take() {
entry entry
} else { } else {
match peek_with_timeout(&revjob_rx, r, peek_timeout).await { let Some(outcome) = peek_outcomes[i].take() else {
return Err(DiskError::Unexpected);
};
match outcome {
PeekOutcome::Ready(res) => { PeekOutcome::Ready(res) => {
if let Some(entry) = res { if let Some(entry) = res {
// info!("read entry disk: {}, name: {}", i, entry.name); // info!("read entry disk: {}, name: {}", i, entry.name);
@@ -1295,6 +1315,36 @@ mod tests {
assert_eq!(err, DiskError::Timeout); assert_eq!(err, DiskError::Timeout);
} }
#[tokio::test(start_paused = true)]
async fn list_path_raw_bounds_multiple_stalled_readers_by_one_peek_deadline() {
let peek_timeout = Duration::from_millis(20);
let started = tokio::time::Instant::now();
let err = list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None, None, None],
min_disks: 1,
test_reader_behaviors: vec![
TestReaderBehavior::Stall,
TestReaderBehavior::Stall,
TestReaderBehavior::Stall,
TestReaderBehavior::Stall,
],
peek_timeout: Some(peek_timeout),
..Default::default()
},
)
.await
.expect_err("all stalled readers should fail the listing");
assert_eq!(err, DiskError::Timeout);
assert_eq!(
started.elapsed(),
peek_timeout,
"reader deadlines must overlap instead of accumulating once per disk"
);
}
#[tokio::test] #[tokio::test]
async fn list_path_raw_waits_past_producer_stall_for_slow_progressing_reader() { async fn list_path_raw_waits_past_producer_stall_for_slow_progressing_reader() {
let entry = MetaCacheEntry { let entry = MetaCacheEntry {
@@ -229,17 +229,6 @@ pub fn http_resp_to_error_response(
err_resp err_resp
} }
pub fn err_transfer_acceleration_bucket(bucket_name: &str) -> ErrorResponse {
ErrorResponse {
status_code: StatusCode::BAD_REQUEST,
code: S3ErrorCode::InvalidArgument,
message: "The name of the bucket used for Transfer Acceleration must be DNS-compliant and must not contain periods .."
.to_string(),
bucket_name: bucket_name.to_string(),
..Default::default()
}
}
pub fn err_entity_too_large(total_size: i64, max_object_size: i64, bucket_name: &str, object_name: &str) -> ErrorResponse { pub fn err_entity_too_large(total_size: i64, max_object_size: i64, bucket_name: &str, object_name: &str) -> ErrorResponse {
let msg = format!( let msg = format!(
"Your proposed upload size {} exceeds the maximum allowed object size {} for single PUT operation.", "Your proposed upload size {} exceeds the maximum allowed object size {} for single PUT operation.",
@@ -295,16 +284,6 @@ pub fn err_invalid_argument(message: &str) -> ErrorResponse {
} }
} }
pub fn err_api_not_supported(message: &str) -> ErrorResponse {
ErrorResponse {
status_code: StatusCode::NOT_IMPLEMENTED,
code: S3ErrorCode::Custom("APINotSupported".into()),
message: message.to_string(),
request_id: "rustfs".to_string(),
..Default::default()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -135,6 +135,10 @@ impl Object {
Self { ..Default::default() } Self { ..Default::default() }
} }
#[allow(
dead_code,
reason = "MinIO-parity reader surface with no caller in this port (backlog#1823)"
)]
fn do_get_request(&self, request: &GetRequest) -> Result<GetResponse, std::io::Error> { fn do_get_request(&self, request: &GetRequest) -> Result<GetResponse, std::io::Error> {
let _ = request.did_offset_change; let _ = request.did_offset_change;
let _ = request.offset; let _ = request.offset;
@@ -150,12 +154,20 @@ impl Object {
)) ))
} }
#[allow(
dead_code,
reason = "MinIO-parity Object reader method with no caller in this port (backlog#1823)"
)]
fn set_offset(&mut self, bytes_read: i64) -> Result<(), std::io::Error> { fn set_offset(&mut self, bytes_read: i64) -> Result<(), std::io::Error> {
self.curr_offset += bytes_read; self.curr_offset += bytes_read;
Ok(()) Ok(())
} }
#[allow(
dead_code,
reason = "MinIO-parity Object reader method with no caller in this port (backlog#1823)"
)]
fn read(&mut self, b: &[u8]) -> Result<i64, std::io::Error> { fn read(&mut self, b: &[u8]) -> Result<i64, std::io::Error> {
let mut read_req = GetRequest { let mut read_req = GetRequest {
is_read_op: true, is_read_op: true,
@@ -180,6 +192,10 @@ impl Object {
Ok(response.size) Ok(response.size)
} }
#[allow(
dead_code,
reason = "MinIO-parity Object reader method with no caller in this port (backlog#1823)"
)]
fn stat(&self) -> Result<ObjectInfo, std::io::Error> { fn stat(&self) -> Result<ObjectInfo, std::io::Error> {
if !self.is_started || !self.object_info_set { if !self.is_started || !self.object_info_set {
let _ = self.do_get_request(&GetRequest { let _ = self.do_get_request(&GetRequest {
@@ -192,6 +208,10 @@ impl Object {
Ok(self.object_info.clone()) Ok(self.object_info.clone())
} }
#[allow(
dead_code,
reason = "MinIO-parity Object reader method with no caller in this port (backlog#1823)"
)]
fn read_at(&mut self, b: &[u8], offset: i64) -> Result<i64, std::io::Error> { fn read_at(&mut self, b: &[u8], offset: i64) -> Result<i64, std::io::Error> {
self.curr_offset = offset; self.curr_offset = offset;
@@ -219,6 +239,10 @@ impl Object {
Ok(response.size) Ok(response.size)
} }
#[allow(
dead_code,
reason = "MinIO-parity Object reader method with no caller in this port (backlog#1823)"
)]
fn seek(&mut self, offset: i64, whence: i64) -> Result<i64, std::io::Error> { fn seek(&mut self, offset: i64, whence: i64) -> Result<i64, std::io::Error> {
if !self.is_started || !self.object_info_set { if !self.is_started || !self.object_info_set {
let seek_req = GetRequest { let seek_req = GetRequest {
@@ -253,6 +277,10 @@ impl Object {
Ok(self.curr_offset) Ok(self.curr_offset)
} }
#[allow(
dead_code,
reason = "MinIO-parity Object reader method with no caller in this port (backlog#1823)"
)]
fn close(&mut self) -> Result<(), std::io::Error> { fn close(&mut self) -> Result<(), std::io::Error> {
self.is_closed = true; self.is_closed = true;
Ok(()) Ok(())
+1 -1
View File
@@ -37,7 +37,7 @@ use crate::client::{
api_put_object_common::optimal_part_info, api_put_object_common::optimal_part_info,
api_put_object_multipart::UploadPartParams, api_put_object_multipart::UploadPartParams,
api_s3_datatypes::{CompleteMultipartUpload, CompletePart, ObjectPart}, api_s3_datatypes::{CompleteMultipartUpload, CompletePart, ObjectPart},
constants::{ISO8601_DATEFORMAT, MAX_MULTIPART_PUT_OBJECT_SIZE, MIN_PART_SIZE, TOTAL_WORKERS}, constants::{ISO8601_DATEFORMAT, MAX_MULTIPART_PUT_OBJECT_SIZE, MIN_PART_SIZE},
credentials::SignatureType, credentials::SignatureType,
transition_api::{ReaderImpl, TransitionClient, UploadInfo}, transition_api::{ReaderImpl, TransitionClient, UploadInfo},
utils::{is_amz_header, is_minio_header, is_rustfs_header, is_standard_header, is_storageclass_header}, utils::{is_amz_header, is_minio_header, is_rustfs_header, is_standard_header, is_storageclass_header},
@@ -30,10 +30,6 @@ pub fn is_object(reader: &ReaderImpl) -> bool {
matches!(reader, ReaderImpl::ObjectBody(_)) matches!(reader, ReaderImpl::ObjectBody(_))
} }
pub fn is_read_at(reader: ReaderImpl) -> bool {
matches!(reader, ReaderImpl::ObjectBody(_))
}
pub fn optimal_part_info(object_size: i64, configured_part_size: u64) -> Result<(i64, i64, i64), std::io::Error> { pub fn optimal_part_info(object_size: i64, configured_part_size: u64) -> Result<(i64, i64, i64), std::io::Error> {
let unknown_size; let unknown_size;
let mut object_size = object_size; let mut object_size = object_size;
@@ -81,18 +81,6 @@ async fn read_multipart_part(reader: &mut ReaderImpl, want: usize) -> Result<Vec
} }
} }
pub struct UploadedPartRes {
pub error: std::io::Error,
pub part_num: i64,
pub size: i64,
pub part: ObjectPart,
}
pub struct UploadPartReq {
pub part_num: i64,
pub part: ObjectPart,
}
impl TransitionClient { impl TransitionClient {
pub async fn put_object_multipart_stream( pub async fn put_object_multipart_stream(
self: Arc<Self>, self: Arc<Self>,
+18 -38
View File
@@ -29,10 +29,6 @@ use crate::client::utils::base64_decode;
use super::transition_api; use super::transition_api;
pub struct ListAllMyBucketsResult {
pub owner: Owner,
}
#[derive(Debug, Default, Serialize, Deserialize)] #[derive(Debug, Default, Serialize, Deserialize)]
pub struct CommonPrefix { pub struct CommonPrefix {
pub prefix: String, pub prefix: String,
@@ -89,6 +85,10 @@ pub struct ListVersionsResult {
pub next_version_id_marker: String, pub next_version_id_marker: String,
} }
#[allow(
dead_code,
reason = "fields of a MinIO-parity list result that this port builds but never reads back (backlog#1823)"
)]
pub struct ListBucketResult { pub struct ListBucketResult {
common_prefixes: Vec<CommonPrefix>, common_prefixes: Vec<CommonPrefix>,
contents: Vec<transition_api::ObjectInfo>, contents: Vec<transition_api::ObjectInfo>,
@@ -102,6 +102,10 @@ pub struct ListBucketResult {
prefix: String, prefix: String,
} }
#[allow(
dead_code,
reason = "fields of a MinIO-parity list result that this port builds but never reads back (backlog#1823)"
)]
pub struct ListMultipartUploadsResult { pub struct ListMultipartUploadsResult {
bucket: String, bucket: String,
key_marker: String, key_marker: String,
@@ -117,16 +121,15 @@ pub struct ListMultipartUploadsResult {
common_prefixes: Vec<CommonPrefix>, common_prefixes: Vec<CommonPrefix>,
} }
#[allow(
dead_code,
reason = "fields of a MinIO-parity list result that this port builds but never reads back (backlog#1823)"
)]
pub struct Initiator { pub struct Initiator {
id: String, id: String,
display_name: String, display_name: String,
} }
pub struct CopyObjectResult {
pub etag: String,
pub last_modified: OffsetDateTime,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ObjectPart { pub struct ObjectPart {
pub etag: String, pub etag: String,
@@ -260,6 +263,7 @@ pub struct CompletePart {
} }
impl CompletePart { impl CompletePart {
#[allow(dead_code, reason = "MinIO-parity accessor with no caller in this port (backlog#1823)")]
fn checksum(&self, t: &ChecksumMode) -> String { fn checksum(&self, t: &ChecksumMode) -> String {
match t { match t {
ChecksumMode::ChecksumCRC32C => { ChecksumMode::ChecksumCRC32C => {
@@ -284,11 +288,6 @@ impl CompletePart {
} }
} }
pub struct CopyObjectPartResult {
pub etag: String,
pub last_modified: OffsetDateTime,
}
#[derive(Debug, Default, serde::Serialize)] #[derive(Debug, Default, serde::Serialize)]
#[serde(rename = "CompleteMultipartUpload")] #[serde(rename = "CompleteMultipartUpload")]
pub struct CompleteMultipartUpload { pub struct CompleteMultipartUpload {
@@ -357,10 +356,10 @@ impl CompleteMultipartUpload {
} }
} }
pub struct CreateBucketConfiguration { #[allow(
pub location: String, dead_code,
} reason = "live via quick_xml::de::from_str in bucket_cache.rs; serde deserialization is not a construction (backlog#1823)"
)]
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct DeleteObject { pub struct DeleteObject {
//api has //api has
@@ -368,21 +367,6 @@ pub struct DeleteObject {
pub version_id: String, pub version_id: String,
} }
pub struct DeletedObject {
//s3s has
pub key: String,
pub version_id: String,
pub deletemarker: bool,
pub deletemarker_version_id: String,
}
pub struct NonDeletedObject {
pub key: String,
pub code: String,
pub message: String,
pub version_id: String,
}
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct DeleteMultiObjects { pub struct DeleteMultiObjects {
pub quiet: bool, pub quiet: bool,
@@ -402,6 +386,7 @@ impl DeleteMultiObjects {
Ok(buf) Ok(buf)
} }
#[allow(dead_code, reason = "MinIO-parity XML helper with no caller in this port (backlog#1823)")]
pub fn unmarshal(buf: &[u8]) -> Result<Self, std::io::Error> { pub fn unmarshal(buf: &[u8]) -> Result<Self, std::io::Error> {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct WireDeleteObject { struct WireDeleteObject {
@@ -436,8 +421,3 @@ impl DeleteMultiObjects {
}) })
} }
} }
pub struct DeleteMultiObjectsResult {
pub deleted_objects: Vec<DeletedObject>,
pub undeleted_objects: Vec<NonDeletedObject>,
}
+4
View File
@@ -365,6 +365,10 @@ mod tests {
pub struct Checksum { pub struct Checksum {
checksum_type: ChecksumMode, checksum_type: ChecksumMode,
r: Vec<u8>, r: Vec<u8>,
#[allow(
dead_code,
reason = "checksum bookkeeping field kept beside the value it guards (backlog#1823)"
)]
computed: bool, computed: bool,
} }
-3
View File
@@ -32,8 +32,5 @@ pub const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
pub const UNSIGNED_PAYLOAD_TRAILER: &str = "STREAMING-UNSIGNED-PAYLOAD-TRAILER"; pub const UNSIGNED_PAYLOAD_TRAILER: &str = "STREAMING-UNSIGNED-PAYLOAD-TRAILER";
pub const TOTAL_WORKERS: i64 = 4;
pub const SIGN_V4_ALGORITHM: &str = "AWS4-HMAC-SHA256";
pub const ISO8601_DATEFORMAT: &[FormatItem<'_>] = pub const ISO8601_DATEFORMAT: &[FormatItem<'_>] =
format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond]Z"); format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond]Z");
+12 -19
View File
@@ -67,6 +67,10 @@ impl<P: Provider + Default> Credentials<P> {
Ok(self.creds.clone()) Ok(self.creds.clone())
} }
#[allow(
dead_code,
reason = "MinIO-parity credential surface with no caller in this port (backlog#1823)"
)]
fn expire(&mut self) { fn expire(&mut self) {
self.force_refresh = true; self.force_refresh = true;
} }
@@ -133,6 +137,10 @@ impl Provider for Static {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct STSError { pub struct STSError {
#[allow(
dead_code,
reason = "MinIO-parity STS error detail that this port never reads back (backlog#1823)"
)]
pub r#type: String, pub r#type: String,
pub code: String, pub code: String,
pub message: String, pub message: String,
@@ -141,6 +149,10 @@ pub struct STSError {
#[derive(Debug, Clone, thiserror::Error)] #[derive(Debug, Clone, thiserror::Error)]
pub struct ErrorResponse { pub struct ErrorResponse {
pub sts_error: STSError, pub sts_error: STSError,
#[allow(
dead_code,
reason = "MinIO-parity STS error detail that this port never reads back (backlog#1823)"
)]
pub request_id: String, pub request_id: String,
} }
@@ -158,22 +170,3 @@ impl ErrorResponse {
return self.sts_error.message.clone(); return self.sts_error.message.clone();
} }
} }
pub fn xml_decoder<T>(body: &[u8]) -> Result<T, Error>
where
for<'de> T: Deserialize<'de>,
{
match std::str::from_utf8(body) {
Ok(xml_body) => quick_xml::de::from_str::<T>(xml_body).map_err(|err| Error::new(ErrorKind::InvalidData, err.to_string())),
Err(err) => Err(Error::new(ErrorKind::InvalidData, err.to_string())),
}
}
pub fn xml_decode_and_body<T>(body_reader: &[u8]) -> Result<(Vec<u8>, T), std::io::Error>
where
for<'de> T: Deserialize<'de>,
{
let body = body_reader.to_vec();
let parsed = xml_decoder(&body)?;
Ok((body, parsed))
}
-1
View File
@@ -13,7 +13,6 @@
// limitations under the License. // limitations under the License.
// #730: S3 client compatibility models are kept while ECStore callers move to narrower facades. // #730: S3 client compatibility models are kept while ECStore callers move to narrower facades.
#![allow(dead_code)]
pub mod admin_handler_utils; pub mod admin_handler_utils;
pub mod api_error_response; pub mod api_error_response;
@@ -77,39 +77,6 @@ fn part_number_to_rangespec(oi: ObjectInfo, part_number: usize) -> Option<HTTPRa
}) })
} }
fn get_compressed_offsets(oi: ObjectInfo, offset: i64) -> (i64, i64, i64, i64, u64) {
let mut skip_length: i64 = 0;
let mut cumulative_actual_size: i64 = 0;
let mut first_part_idx: i64 = 0;
let mut compressed_offset: i64 = 0;
let mut part_skip: i64 = 0;
let mut decrypt_skip: i64 = 0;
let mut seq_num: u64 = 0;
for (i, part) in oi.parts.iter().enumerate() {
cumulative_actual_size += part.actual_size as i64;
if cumulative_actual_size <= offset {
compressed_offset += part.size as i64;
} else {
first_part_idx = i as i64;
skip_length = cumulative_actual_size - part.actual_size as i64;
break;
}
}
skip_length = offset - skip_length;
let parts: &[ObjectPartInfo] = &oi.parts;
if skip_length > 0
&& parts.len() > first_part_idx as usize
&& parts[first_part_idx as usize].index.as_ref().is_some_and(|idx| idx.len() > 0)
{
let _ = part_skip;
let _ = decrypt_skip;
let _ = seq_num;
}
(compressed_offset, part_skip, first_part_idx, decrypt_skip, seq_num)
}
pub fn new_getobjectreader<'a>( pub fn new_getobjectreader<'a>(
rs: &Option<HTTPRangeSpec>, rs: &Option<HTTPRangeSpec>,
oi: &'a ObjectInfo, oi: &'a ObjectInfo,
@@ -23,6 +23,7 @@ const X_OBS_VERSION_ID: &str = "x-obs-version-id";
const MAX_REMOTE_VERSION_ID_LEN: usize = 1024; const MAX_REMOTE_VERSION_ID_LEN: usize = 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[allow(dead_code, reason = "bucket versioning states kept as a complete vocabulary (backlog#1823)")]
pub(crate) enum BucketVersioningState { pub(crate) enum BucketVersioningState {
Unknown, Unknown,
Disabled, Disabled,
@@ -47,6 +48,7 @@ impl RemoteVersion {
} }
} }
#[allow(dead_code, reason = "MinIO-parity accessor with no caller in this port (backlog#1823)")]
pub(crate) fn exact_request_id(&self) -> Result<Option<&str>, Error> { pub(crate) fn exact_request_id(&self) -> Result<Option<&str>, Error> {
match self { match self {
Self::Unknown => Err(Error::new( Self::Unknown => Err(Error::new(
@@ -101,6 +101,10 @@ where
const C_UNKNOWN: i32 = -1; const C_UNKNOWN: i32 = -1;
const C_OFFLINE: i32 = 0; const C_OFFLINE: i32 = 0;
#[allow(
dead_code,
reason = "reachable only from the unused transition client methods below (backlog#1823)"
)]
const C_ONLINE: i32 = 1; const C_ONLINE: i32 = 1;
fn invalid_utf8_header_error(scope: &str, header_name: &str) -> std::io::Error { fn invalid_utf8_header_error(scope: &str, header_name: &str) -> std::io::Error {
@@ -320,6 +324,10 @@ impl TransitionClient {
Ok(client) Ok(client)
} }
#[allow(
dead_code,
reason = "MinIO-parity transition client surface with no caller in this port (backlog#1823)"
)]
fn endpoint_url(&self) -> Url { fn endpoint_url(&self) -> Url {
self.endpoint_url.clone() self.endpoint_url.clone()
} }
@@ -348,12 +356,20 @@ impl TransitionClient {
.to_string()) .to_string())
} }
#[allow(
dead_code,
reason = "MinIO-parity transition client method with no caller in this port (backlog#1823)"
)]
fn trace_errors_only_off(&self) { fn trace_errors_only_off(&self) {
if let Ok(mut trace_errors_only) = self.trace_errors_only.lock() { if let Ok(mut trace_errors_only) = self.trace_errors_only.lock() {
*trace_errors_only = false; *trace_errors_only = false;
} }
} }
#[allow(
dead_code,
reason = "MinIO-parity transition client method with no caller in this port (backlog#1823)"
)]
fn trace_off(&self) { fn trace_off(&self) {
if let Ok(mut is_trace_enabled) = self.is_trace_enabled.lock() { if let Ok(mut is_trace_enabled) = self.is_trace_enabled.lock() {
*is_trace_enabled = false; *is_trace_enabled = false;
@@ -363,12 +379,20 @@ impl TransitionClient {
} }
} }
#[allow(
dead_code,
reason = "MinIO-parity transition client method with no caller in this port (backlog#1823)"
)]
fn set_s3_transfer_accelerate(&self, accelerate_endpoint: &str) { fn set_s3_transfer_accelerate(&self, accelerate_endpoint: &str) {
if let Ok(mut endpoint) = self.s3_accelerate_endpoint.lock() { if let Ok(mut endpoint) = self.s3_accelerate_endpoint.lock() {
*endpoint = accelerate_endpoint.to_string(); *endpoint = accelerate_endpoint.to_string();
} }
} }
#[allow(
dead_code,
reason = "MinIO-parity transition client method with no caller in this port (backlog#1823)"
)]
fn set_s3_enable_dual_stack(&self, enabled: bool) { fn set_s3_enable_dual_stack(&self, enabled: bool) {
if let Ok(mut dual_stack) = self.s3_dual_stack_enabled.lock() { if let Ok(mut dual_stack) = self.s3_dual_stack_enabled.lock() {
*dual_stack = enabled; *dual_stack = enabled;
@@ -398,10 +422,18 @@ impl TransitionClient {
(hash_algos, hash_sums) (hash_algos, hash_sums)
} }
#[allow(
dead_code,
reason = "MinIO-parity transition client method with no caller in this port (backlog#1823)"
)]
fn is_online(&self) -> bool { fn is_online(&self) -> bool {
!self.is_offline() !self.is_offline()
} }
#[allow(
dead_code,
reason = "MinIO-parity transition client method with no caller in this port (backlog#1823)"
)]
fn mark_offline(&self) { fn mark_offline(&self) {
self.health_status self.health_status
.compare_exchange(C_ONLINE, C_OFFLINE, Ordering::SeqCst, Ordering::SeqCst); .compare_exchange(C_ONLINE, C_OFFLINE, Ordering::SeqCst, Ordering::SeqCst);
@@ -411,10 +443,18 @@ impl TransitionClient {
self.health_status.load(Ordering::SeqCst) == C_OFFLINE self.health_status.load(Ordering::SeqCst) == C_OFFLINE
} }
#[allow(
dead_code,
reason = "MinIO-parity transition client method with no caller in this port (backlog#1823)"
)]
fn health_check(hc_duration: Duration) { fn health_check(hc_duration: Duration) {
let _ = hc_duration; let _ = hc_duration;
} }
#[allow(
dead_code,
reason = "MinIO-parity transition client method with no caller in this port (backlog#1823)"
)]
fn dump_http(&self, req: &Request<s3s::Body>, resp: &Response<Incoming>) -> Result<(), std::io::Error> { fn dump_http(&self, req: &Request<s3s::Body>, resp: &Response<Incoming>) -> Result<(), std::io::Error> {
let mut resp_trace: Vec<u8>; let mut resp_trace: Vec<u8>;
@@ -1102,6 +1142,7 @@ impl Default for ObjectInfo {
} }
impl ObjectInfo { impl ObjectInfo {
#[allow(dead_code, reason = "MinIO-parity accessor with no caller in this port (backlog#1823)")]
pub(crate) fn remote_version( pub(crate) fn remote_version(
&self, &self,
capabilities: ProviderVersionCapabilities, capabilities: ProviderVersionCapabilities,
-4
View File
@@ -48,10 +48,6 @@ lazy_static! {
}; };
} }
pub fn is_standard_query_value(qs_key: &str) -> bool {
SUPPORTED_QUERY_VALUES[qs_key]
}
pub fn is_storageclass_header(header_key: &str) -> bool { pub fn is_storageclass_header(header_key: &str) -> bool {
header_key.to_lowercase() == X_AMZ_STORAGE_CLASS.as_str().to_lowercase() header_key.to_lowercase() == X_AMZ_STORAGE_CLASS.as_str().to_lowercase()
} }
@@ -233,17 +233,11 @@ pub struct NsScannerCapabilityRequest {
#[async_trait] #[async_trait]
pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug { pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>; async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
async fn open_read_fresh(&self, request: ReadStreamRequest) -> Result<FileReader> {
self.open_read(request).await
}
/// Opens an owned-chunk stream when this transport can retain receive-buffer /// Opens an owned-chunk stream when this transport can retain receive-buffer
/// ownership. `None` preserves the established `open_read` fallback. /// ownership. `None` preserves the established `open_read` fallback.
async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> { async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
Ok(None) Ok(None)
} }
async fn open_read_chunks_fresh(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
self.open_read_chunks(request).await
}
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>; async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>;
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>; async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
async fn open_ns_scanner(&self, _request: NsScannerStreamRequest) -> Result<FileReader> { async fn open_ns_scanner(&self, _request: NsScannerStreamRequest) -> Result<FileReader> {
@@ -275,15 +269,6 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
)) ))
} }
async fn open_read_fresh(&self, request: ReadStreamRequest) -> Result<FileReader> {
let url = build_read_file_stream_url(&request);
let mut headers = json_headers();
build_auth_headers(&url, &Method::GET, &mut headers)?;
Ok(Box::new(
HttpReader::new_fresh_connection_with_stall_timeout(url, Method::GET, headers, None, request.stall_timeout).await?,
))
}
async fn open_read_chunks(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> { async fn open_read_chunks(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
let url = build_read_file_stream_url(&request); let url = build_read_file_stream_url(&request);
let mut headers = json_headers(); let mut headers = json_headers();
@@ -293,16 +278,6 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
))) )))
} }
async fn open_read_chunks_fresh(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
let url = build_read_file_stream_url(&request);
let mut headers = json_headers();
build_auth_headers(&url, &Method::GET, &mut headers)?;
Ok(Some(Box::new(
HttpChunkReader::new_fresh_connection_with_stall_timeout(url, Method::GET, headers, None, request.stall_timeout)
.await?,
)))
}
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter> { async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter> {
let server_epoch = self.put_file_auth_capability(&request.endpoint).await?; let server_epoch = self.put_file_auth_capability(&request.endpoint).await?;
let nonce = server_epoch.map(|_| Uuid::new_v4()); let nonce = server_epoch.map(|_| Uuid::new_v4());
@@ -248,6 +248,16 @@ fn decode_remote_version_state_capability(expected_member: &str, result: &[u8])
Ok(server_epoch) Ok(server_epoch)
} }
fn decode_cross_pool_fence_capability(expected_member: &str, result: &[u8]) -> Result<(u32, Uuid)> {
let version = result
.get(..4)
.and_then(|value| value.try_into().ok())
.map(u32::from_be_bytes)
.ok_or_else(|| Error::other("peer returned an invalid cross-pool fence capability version"))?;
let epoch = decode_remote_version_state_capability(expected_member, &result[4..])?;
Ok((version, epoch))
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PeerLiveEventsBatch { pub struct PeerLiveEventsBatch {
pub events: Vec<u8>, pub events: Vec<u8>,
@@ -1288,6 +1298,16 @@ impl PeerRestClient {
Ok((self.topology_member.clone(), epoch)) Ok((self.topology_member.clone(), epoch))
} }
pub async fn probe_cross_pool_fence(&self, topology_fingerprint: String) -> Result<(String, u32, Uuid)> {
let mut probe = rustfs_protos::CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX.to_vec();
probe.extend_from_slice(Uuid::new_v4().as_bytes());
let result = self
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, topology_fingerprint, probe)
.await?;
let (supported_version, epoch) = decode_cross_pool_fence_capability(&self.topology_member, &result)?;
Ok((self.topology_member.clone(), supported_version, epoch))
}
pub async fn load_bucket_metadata(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> { pub async fn load_bucket_metadata(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
self.finalize_result( self.finalize_result(
async { async {
@@ -2738,6 +2758,24 @@ mod tests {
assert!(decode_remote_version_state_capability("node-a:9000", &nil).is_err()); assert!(decode_remote_version_state_capability("node-a:9000", &nil).is_err());
} }
#[test]
fn cross_pool_fence_capability_decoder_fails_closed() {
let epoch = Uuid::new_v4();
let result = rustfs_protos::encode_cross_pool_fence_capability(1, "node-a:9000", epoch.as_bytes())
.expect("small capability response should encode");
assert_eq!(
decode_cross_pool_fence_capability("node-a:9000", &result).expect("valid capability should decode"),
(1, epoch)
);
for malformed in [&[][..], &[0, 0, 0][..], &result[..result.len() - 1]] {
assert!(decode_cross_pool_fence_capability("node-a:9000", malformed).is_err());
}
assert!(decode_cross_pool_fence_capability("node-b:9000", &result).is_err());
let nil = rustfs_protos::encode_cross_pool_fence_capability(1, "node-a:9000", Uuid::nil().as_bytes())
.expect("small capability response should encode");
assert!(decode_cross_pool_fence_capability("node-a:9000", &nil).is_err());
}
struct TierMutationResponseFixture<'a> { struct TierMutationResponseFixture<'a> {
version: u32, version: u32,
phase: TierMutationRpcPhase, phase: TierMutationRpcPhase,
+74 -563
View File
@@ -57,17 +57,15 @@ use serde::{Serialize, de::DeserializeOwned};
use std::{ use std::{
io::Cursor, io::Cursor,
path::PathBuf, path::PathBuf,
pin::Pin,
sync::{ sync::{
Arc, Arc,
atomic::{AtomicU32, Ordering}, atomic::{AtomicU32, Ordering},
}, },
task::{Context, Poll},
time::Duration, time::Duration,
}; };
use tokio::time; use tokio::time;
use tokio::{ use tokio::{
io::{self, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}, io::{self, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::TcpStream, net::TcpStream,
time::timeout, time::timeout,
}; };
@@ -216,231 +214,6 @@ where
} }
} }
fn is_retryable_remote_body_error(error: &io::Error) -> bool {
if error
.get_ref()
.and_then(|source| source.downcast_ref::<rustfs_rio::BodyStalled>())
.is_some()
{
return true;
}
matches!(
error.kind(),
io::ErrorKind::ConnectionReset
| io::ErrorKind::BrokenPipe
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::UnexpectedEof
)
}
fn resumed_read_request(request: &ReadStreamRequest, emitted: usize) -> io::Result<ReadStreamRequest> {
let offset = request
.offset
.checked_add(emitted)
.ok_or_else(|| io::Error::other("remote read resume offset overflow"))?;
let length = if request.length == 0 {
0
} else {
request
.length
.checked_sub(emitted)
.ok_or_else(|| io::Error::other("remote read resume offset exceeds requested length"))?
};
Ok(ReadStreamRequest {
offset,
length,
..request.clone()
})
}
type ReadResumeFuture = tokio::task::JoinHandle<Result<FileReader>>;
struct RetryingRemoteReader {
reader: Option<FileReader>,
transport: Arc<dyn InternodeDataTransport>,
request: ReadStreamRequest,
emitted: usize,
retried: bool,
resume: Option<ReadResumeFuture>,
}
impl RetryingRemoteReader {
fn new(reader: FileReader, transport: Arc<dyn InternodeDataTransport>, request: ReadStreamRequest) -> Self {
Self {
reader: Some(reader),
transport,
request,
emitted: 0,
retried: false,
resume: None,
}
}
fn start_resume(&mut self) -> io::Result<()> {
if self.request.length != 0 && self.emitted >= self.request.length {
self.reader = None;
return Ok(());
}
let request = resumed_read_request(&self.request, self.emitted)?;
let transport = Arc::clone(&self.transport);
self.resume = Some(tokio::spawn(async move { transport.open_read_fresh(request).await }));
Ok(())
}
}
impl AsyncRead for RetryingRemoteReader {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
loop {
if let Some(resume) = self.resume.as_mut() {
match Pin::new(resume).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(Ok(reader))) => {
self.resume = None;
self.reader = Some(reader);
}
Poll::Ready(Ok(Err(error))) => {
self.resume = None;
return Poll::Ready(Err(io::Error::other(error)));
}
Poll::Ready(Err(error)) => {
self.resume = None;
return Poll::Ready(Err(io::Error::other(error)));
}
}
}
let Some(reader) = self.reader.as_mut() else {
return Poll::Ready(Ok(()));
};
let before = buf.filled().len();
match Pin::new(reader).poll_read(cx, buf) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(())) => {
let produced = buf.filled().len() - before;
self.emitted = match self.emitted.checked_add(produced) {
Some(emitted) => emitted,
None => return Poll::Ready(Err(io::Error::other("remote read emitted byte count overflow"))),
};
return Poll::Ready(Ok(()));
}
Poll::Ready(Err(error)) if !self.retried && is_retryable_remote_body_error(&error) => {
self.retried = true;
if let Err(resume_error) = self.start_resume() {
return Poll::Ready(Err(resume_error));
}
continue;
}
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
}
}
}
}
type ChunkResumeFuture = tokio::task::JoinHandle<Result<Option<rustfs_rio::ChunkReaderBox>>>;
struct RetryingRemoteChunkReader {
reader: Option<rustfs_rio::ChunkReaderBox>,
transport: Arc<dyn InternodeDataTransport>,
request: ReadStreamRequest,
emitted: usize,
retried: bool,
resume: Option<ChunkResumeFuture>,
}
impl RetryingRemoteChunkReader {
fn new(reader: rustfs_rio::ChunkReaderBox, transport: Arc<dyn InternodeDataTransport>, request: ReadStreamRequest) -> Self {
Self {
reader: Some(reader),
transport,
request,
emitted: 0,
retried: false,
resume: None,
}
}
fn start_resume(&mut self) -> io::Result<()> {
if self.request.length != 0 && self.emitted >= self.request.length {
self.reader = None;
return Ok(());
}
let request = resumed_read_request(&self.request, self.emitted)?;
let transport = Arc::clone(&self.transport);
self.resume = Some(tokio::spawn(async move { transport.open_read_chunks_fresh(request).await }));
Ok(())
}
}
impl AsyncRead for RetryingRemoteChunkReader {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
if buf.remaining() == 0 {
return Poll::Ready(Ok(()));
}
match rustfs_rio::ChunkReader::poll_read_chunk(self.as_mut(), cx, buf.remaining()) {
Poll::Ready(Ok(Some(chunk))) => {
buf.put_slice(&chunk);
Poll::Ready(Ok(()))
}
Poll::Ready(Ok(None)) => Poll::Ready(Ok(())),
Poll::Ready(Err(error)) => Poll::Ready(Err(error)),
Poll::Pending => Poll::Pending,
}
}
}
impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader {
fn poll_read_chunk(mut self: Pin<&mut Self>, cx: &mut Context<'_>, max: usize) -> Poll<io::Result<Option<Bytes>>> {
loop {
if let Some(resume) = self.resume.as_mut() {
match Pin::new(resume).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(Ok(Some(reader)))) => {
self.resume = None;
self.reader = Some(reader);
}
Poll::Ready(Ok(Ok(None))) => {
self.resume = None;
self.reader = None;
return Poll::Ready(Err(io::Error::other("remote resume transport did not provide a chunk reader")));
}
Poll::Ready(Ok(Err(error))) => {
self.resume = None;
return Poll::Ready(Err(io::Error::other(error)));
}
Poll::Ready(Err(error)) => {
self.resume = None;
return Poll::Ready(Err(io::Error::other(error)));
}
}
}
let Some(reader) = self.reader.as_mut() else {
return Poll::Ready(Ok(None));
};
match rustfs_rio::ChunkReader::poll_read_chunk(Pin::new(reader.as_mut()), cx, max) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(Some(chunk))) => {
self.emitted = match self.emitted.checked_add(chunk.len()) {
Some(emitted) => emitted,
None => return Poll::Ready(Err(io::Error::other("remote read emitted byte count overflow"))),
};
return Poll::Ready(Ok(Some(chunk)));
}
Poll::Ready(Ok(None)) => return Poll::Ready(Ok(None)),
Poll::Ready(Err(error)) if !self.retried && is_retryable_remote_body_error(&error) => {
self.retried = true;
if let Err(resume_error) = self.start_resume() {
return Poll::Ready(Err(resume_error));
}
continue;
}
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
}
}
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct RemoteDisk { pub struct RemoteDisk {
pub id: Mutex<Option<Uuid>>, pub id: Mutex<Option<Uuid>>,
@@ -1586,6 +1359,71 @@ fn validate_decoded_file_info(file_info: &FileInfo) -> Result<()> {
file_info.validate_for_metadata_read().map_err(Into::into) file_info.validate_for_metadata_read().map_err(Into::into)
} }
impl RemoteDisk {
#[tracing::instrument(level = "trace", skip_all)]
pub(crate) async fn rename_data_borrowed(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
trace!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
endpoint = %self.endpoint,
src_volume,
src_path,
dst_volume,
dst_path,
op = "rename_data",
state = "started",
"Remote disk RPC started"
);
self.execute_with_timeout_for_op(
"rename_data",
|| async {
let file_info = compat_json(fi)?;
let file_info_bin = encode_file_info_msgpack(fi)?;
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let mut request = Request::new(RenameDataRequest {
disk: self.endpoint.to_string(),
src_volume: src_volume.to_string(),
src_path: src_path.to_string(),
file_info,
dst_volume: dst_volume.to_string(),
dst_path: dst_path.to_string(),
file_info_bin: file_info_bin.into(),
});
let canonical_body = rustfs_protos::canonical_rename_data_request_body(request.get_ref());
attach_mutation_body_digest(&mut request, canonical_body, "rename_data")?;
let response = client.rename_data(request).await?.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
let rename_data_resp = decode_msgpack_or_json::<RenameDataResp>(
&response.rename_data_resp_bin,
&response.rename_data_resp,
"RenameDataResp",
)?;
Ok(rename_data_resp)
},
get_max_timeout_duration(),
)
.await
}
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl DiskAPI for RemoteDisk { impl DiskAPI for RemoteDisk {
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
@@ -2511,58 +2349,8 @@ impl DiskAPI for RemoteDisk {
dst_volume: &str, dst_volume: &str,
dst_path: &str, dst_path: &str,
) -> Result<RenameDataResp> { ) -> Result<RenameDataResp> {
trace!( self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
event = EVENT_REMOTE_DISK_RPC, .await
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
endpoint = %self.endpoint,
src_volume,
src_path,
dst_volume,
dst_path,
op = "rename_data",
state = "started",
"Remote disk RPC started"
);
self.execute_with_timeout_for_op(
"rename_data",
|| async {
let file_info = compat_json(&fi)?;
let file_info_bin = encode_file_info_msgpack(&fi)?;
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let mut request = Request::new(RenameDataRequest {
disk: self.endpoint.to_string(),
src_volume: src_volume.to_string(),
src_path: src_path.to_string(),
file_info,
dst_volume: dst_volume.to_string(),
dst_path: dst_path.to_string(),
file_info_bin: file_info_bin.into(),
});
let canonical_body = rustfs_protos::canonical_rename_data_request_body(request.get_ref());
attach_mutation_body_digest(&mut request, canonical_body, "rename_data")?;
let response = client.rename_data(request).await?.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
let rename_data_resp = decode_msgpack_or_json::<RenameDataResp>(
&response.rename_data_resp_bin,
&response.rename_data_resp,
"RenameDataResp",
)?;
Ok(rename_data_resp)
},
get_max_timeout_duration(),
)
.await
} }
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
@@ -2696,7 +2484,7 @@ impl DiskAPI for RemoteDisk {
} }
let disk = self.disk_ref().await; let disk = self.disk_ref().await;
let stall_timeout = get_object_disk_read_timeout(); let stall_timeout = get_object_disk_read_timeout();
let request = ReadStreamRequest { self.open_read_with_retry(ReadStreamRequest {
endpoint: self.endpoint.grid_host(), endpoint: self.endpoint.grid_host(),
disk, disk,
volume: volume.to_string(), volume: volume.to_string(),
@@ -2704,9 +2492,8 @@ impl DiskAPI for RemoteDisk {
offset, offset,
length, length,
stall_timeout: (!stall_timeout.is_zero()).then_some(stall_timeout), stall_timeout: (!stall_timeout.is_zero()).then_some(stall_timeout),
}; })
let reader = self.open_read_with_retry(request.clone()).await?; .await
Ok(Box::new(RetryingRemoteReader::new(reader, Arc::clone(&self.data_transport), request)))
} }
async fn read_file_stream_chunks( async fn read_file_stream_chunks(
@@ -2721,7 +2508,7 @@ impl DiskAPI for RemoteDisk {
} }
let disk = self.disk_ref().await; let disk = self.disk_ref().await;
let stall_timeout = get_object_disk_read_timeout(); let stall_timeout = get_object_disk_read_timeout();
let request = ReadStreamRequest { self.open_read_chunks_with_retry(ReadStreamRequest {
endpoint: self.endpoint.grid_host(), endpoint: self.endpoint.grid_host(),
disk, disk,
volume: volume.to_string(), volume: volume.to_string(),
@@ -2729,12 +2516,8 @@ impl DiskAPI for RemoteDisk {
offset, offset,
length, length,
stall_timeout: (!stall_timeout.is_zero()).then_some(stall_timeout), stall_timeout: (!stall_timeout.is_zero()).then_some(stall_timeout),
}; })
let reader = self.open_read_chunks_with_retry(request.clone()).await?; .await
Ok(reader.map(|reader| {
Box::new(RetryingRemoteChunkReader::new(reader, Arc::clone(&self.data_transport), request))
as rustfs_rio::ChunkReaderBox
}))
} }
/// Buffered read for remote disks. /// Buffered read for remote disks.
@@ -4355,278 +4138,6 @@ mod tests {
} }
} }
#[derive(Debug, Clone)]
enum ResumeReadStep {
PartialThenReset(Vec<u8>),
Data(Vec<u8>),
}
#[derive(Debug, Default)]
struct ResumeTransport {
read_steps: Mutex<Vec<ResumeReadStep>>,
chunk_steps: Mutex<Vec<ResumeReadStep>>,
read_requests: Mutex<Vec<ReadStreamRequest>>,
chunk_requests: Mutex<Vec<ReadStreamRequest>>,
fresh_read_requests: Mutex<Vec<ReadStreamRequest>>,
fresh_chunk_requests: Mutex<Vec<ReadStreamRequest>>,
}
impl ResumeTransport {
fn with_read_steps(read_steps: Vec<ResumeReadStep>) -> Self {
Self {
read_steps: Mutex::new(read_steps),
..Self::default()
}
}
fn with_chunk_steps(chunk_steps: Vec<ResumeReadStep>) -> Self {
Self {
chunk_steps: Mutex::new(chunk_steps),
..Self::default()
}
}
}
#[derive(Debug)]
struct ChunkPartialThenErrorReader {
data: Option<Bytes>,
error: Option<io::Error>,
}
impl rustfs_rio::ChunkReader for ChunkPartialThenErrorReader {
fn poll_read_chunk(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, max: usize) -> Poll<io::Result<Option<Bytes>>> {
if let Some(mut data) = self.data.take() {
let take = data.len().min(max);
let chunk = data.split_to(take);
if !data.is_empty() {
self.data = Some(data);
}
return Poll::Ready(Ok(Some(chunk)));
}
if let Some(error) = self.error.take() {
return Poll::Ready(Err(error));
}
Poll::Ready(Ok(None))
}
}
impl AsyncRead for ChunkPartialThenErrorReader {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Err(io::Error::other("chunk reader must use chunk handoff")))
}
}
fn resume_step_reader(step: ResumeReadStep) -> FileReader {
match step {
ResumeReadStep::PartialThenReset(data) => Box::new(PartialThenErrorReader {
cursor: Cursor::new(data),
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
}),
ResumeReadStep::Data(data) => Box::new(Cursor::new(data)),
}
}
fn resume_step_chunk_reader(step: ResumeReadStep) -> rustfs_rio::ChunkReaderBox {
match step {
ResumeReadStep::PartialThenReset(data) => Box::new(ChunkPartialThenErrorReader {
data: Some(Bytes::from(data)),
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
}),
ResumeReadStep::Data(data) => Box::new(ChunkPartialThenErrorReader {
data: Some(Bytes::from(data)),
error: None,
}),
}
}
#[async_trait::async_trait]
impl InternodeDataTransport for ResumeTransport {
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader> {
self.read_requests
.lock()
.expect("read request lock should not be poisoned")
.push(request);
let step = self
.read_steps
.lock()
.expect("read steps lock should not be poisoned")
.remove(0);
Ok(resume_step_reader(step))
}
async fn open_read_fresh(&self, request: ReadStreamRequest) -> Result<FileReader> {
self.fresh_read_requests
.lock()
.expect("fresh read request lock should not be poisoned")
.push(request.clone());
self.open_read(request).await
}
async fn open_read_chunks(&self, request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
self.chunk_requests
.lock()
.expect("chunk request lock should not be poisoned")
.push(request);
let step = self
.chunk_steps
.lock()
.expect("chunk steps lock should not be poisoned")
.remove(0);
Ok(Some(resume_step_chunk_reader(step)))
}
async fn open_read_chunks_fresh(&self, request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
self.fresh_chunk_requests
.lock()
.expect("fresh chunk request lock should not be poisoned")
.push(request.clone());
self.open_read_chunks(request).await
}
async fn open_write(&self, _request: WriteStreamRequest) -> Result<FileWriter> {
panic!("open_write should not be used in remote read resume tests");
}
async fn open_walk_dir(&self, _request: WalkDirStreamRequest) -> Result<FileReader> {
panic!("open_walk_dir should not be used in remote read resume tests");
}
fn name(&self) -> &'static str {
"resume-test"
}
fn capabilities(&self) -> InternodeDataTransportCapabilities {
InternodeDataTransportCapabilities::tcp_http()
}
}
fn resume_request(length: usize) -> ReadStreamRequest {
ReadStreamRequest {
endpoint: "http://remote".to_string(),
disk: "disk".to_string(),
volume: "volume".to_string(),
path: "path".to_string(),
offset: 7,
length,
stall_timeout: None,
}
}
#[tokio::test]
async fn remote_reader_resumes_from_emitted_bytes_without_duplicates() {
let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::Data(b"456789".to_vec())]));
let request = resume_request(10);
let reader = resume_step_reader(ResumeReadStep::PartialThenReset(b"0123".to_vec()));
let mut reader = RetryingRemoteReader::new(reader, transport.clone(), request);
let mut output = Vec::new();
reader
.read_to_end(&mut output)
.await
.expect("one body reset should be resumed");
assert_eq!(output, b"0123456789");
let requests = transport
.read_requests
.lock()
.expect("read request lock should not be poisoned");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].offset, 11);
assert_eq!(requests[0].length, 6);
assert_eq!(
transport
.fresh_read_requests
.lock()
.expect("fresh read request lock should not be poisoned")
.len(),
1
);
}
#[tokio::test]
async fn remote_chunk_reader_resumes_from_emitted_bytes_without_duplicates() {
let transport = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::Data(b"456789".to_vec())]));
let request = resume_request(10);
let reader = resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"0123".to_vec()));
let mut reader = RetryingRemoteChunkReader::new(reader, transport.clone(), request);
let mut output = Vec::new();
reader
.read_to_end(&mut output)
.await
.expect("chunk body reset should be resumed");
assert_eq!(output, b"0123456789");
let requests = transport
.chunk_requests
.lock()
.expect("chunk request lock should not be poisoned");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].offset, 11);
assert_eq!(requests[0].length, 6);
assert_eq!(
transport
.fresh_chunk_requests
.lock()
.expect("fresh chunk request lock should not be poisoned")
.len(),
1
);
}
#[tokio::test]
async fn remote_reader_retries_at_most_once_and_preserves_non_retryable_errors() {
let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::PartialThenReset(b"456".to_vec())]));
let mut reader = RetryingRemoteReader::new(
resume_step_reader(ResumeReadStep::PartialThenReset(b"0123".to_vec())),
transport.clone(),
resume_request(7),
);
let error = reader
.read_to_end(&mut Vec::new())
.await
.expect_err("second reset must not retry");
assert_eq!(error.kind(), std_io::ErrorKind::ConnectionReset);
assert_eq!(
transport
.read_requests
.lock()
.expect("read request lock should not be poisoned")
.len(),
1
);
let transport = Arc::new(ResumeTransport::default());
let reader = PartialThenErrorReader {
cursor: Cursor::new(b"data".to_vec()),
error: Some(io::Error::new(std_io::ErrorKind::PermissionDenied, "permission denied")),
};
let mut reader = RetryingRemoteReader::new(Box::new(reader), transport.clone(), resume_request(4));
let error = reader
.read_to_end(&mut Vec::new())
.await
.expect_err("non-retryable errors must not retry");
assert_eq!(error.kind(), std_io::ErrorKind::PermissionDenied);
assert!(
transport
.read_requests
.lock()
.expect("read request lock should not be poisoned")
.is_empty()
);
}
#[test]
fn resumed_read_request_checks_large_offsets() {
let request = ReadStreamRequest {
offset: usize::MAX - 1,
length: 0,
..resume_request(0)
};
assert!(resumed_read_request(&request, 2).is_err());
let request = resume_request(4);
assert!(resumed_read_request(&request, 5).is_err());
}
fn init_tracing(filter_level: Level) { fn init_tracing(filter_level: Level) {
INIT.call_once(|| { INIT.call_once(|| {
let _ = tracing_subscriber::fmt() let _ = tracing_subscriber::fmt()
@@ -12,6 +12,16 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
//! Per-disk usage snapshots persisted under the metadata bucket.
//!
//! **Nothing calls into this module.** It landed complete with tests in #5307
//! (2026-07-27) and its aggregation entry point,
//! [`crate::data_usage::aggregate_local_snapshots`], has never had a caller in
//! the tree's history. The live data-usage path is
//! `load_data_usage_from_backend` / `store_data_usage_in_backend`. The items
//! below therefore carry individual `dead_code` allows rather than a module
//! blanket, so the gap stays greppable until it is either wired up or removed.
use crate::data_usage::BucketUsageInfo; use crate::data_usage::BucketUsageInfo;
use crate::disk::RUSTFS_META_BUCKET; use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -26,10 +36,12 @@ pub const DATA_USAGE_DIR: &str = "datausage";
/// Directory used to store incremental scan state files under the metadata bucket. /// Directory used to store incremental scan state files under the metadata bucket.
pub const DATA_USAGE_STATE_DIR: &str = "datausage/state"; pub const DATA_USAGE_STATE_DIR: &str = "datausage/state";
/// Snapshot file format version, allows forward compatibility if the structure evolves. /// Snapshot file format version, allows forward compatibility if the structure evolves.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub const LOCAL_USAGE_SNAPSHOT_VERSION: u32 = 1; pub const LOCAL_USAGE_SNAPSHOT_VERSION: u32 = 1;
/// Additional metadata describing which disk produced the snapshot. /// Additional metadata describing which disk produced the snapshot.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub struct LocalUsageSnapshotMeta { pub struct LocalUsageSnapshotMeta {
/// Disk UUID stored as a string for simpler serialization. /// Disk UUID stored as a string for simpler serialization.
pub disk_id: String, pub disk_id: String,
@@ -43,6 +55,7 @@ pub struct LocalUsageSnapshotMeta {
/// Usage snapshot produced by a single disk. /// Usage snapshot produced by a single disk.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub struct LocalUsageSnapshot { pub struct LocalUsageSnapshot {
/// Format version recorded in the snapshot. /// Format version recorded in the snapshot.
pub format_version: u32, pub format_version: u32,
@@ -64,6 +77,7 @@ pub struct LocalUsageSnapshot {
pub objects_total_size: u64, pub objects_total_size: u64,
} }
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
impl LocalUsageSnapshot { impl LocalUsageSnapshot {
/// Create an empty snapshot with the default format version filled in. /// Create an empty snapshot with the default format version filled in.
pub fn new(meta: LocalUsageSnapshotMeta) -> Self { pub fn new(meta: LocalUsageSnapshotMeta) -> Self {
@@ -99,11 +113,13 @@ impl LocalUsageSnapshot {
} }
/// Build the snapshot file name `<disk-id>.json`. /// Build the snapshot file name `<disk-id>.json`.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub fn snapshot_file_name(disk_id: &str) -> String { pub fn snapshot_file_name(disk_id: &str) -> String {
format!("{disk_id}.json") format!("{disk_id}.json")
} }
/// Build the object path relative to `RUSTFS_META_BUCKET`, e.g. `datausage/<disk-id>.json`. /// Build the object path relative to `RUSTFS_META_BUCKET`, e.g. `datausage/<disk-id>.json`.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub fn snapshot_object_path(disk_id: &str) -> String { pub fn snapshot_object_path(disk_id: &str) -> String {
format!("{}/{}", DATA_USAGE_DIR, snapshot_file_name(disk_id)) format!("{}/{}", DATA_USAGE_DIR, snapshot_file_name(disk_id))
} }
@@ -119,11 +135,13 @@ pub fn data_usage_state_dir(root: &Path) -> PathBuf {
} }
/// Build the absolute path to the snapshot file for the provided disk ID. /// Build the absolute path to the snapshot file for the provided disk ID.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub fn snapshot_path(root: &Path, disk_id: &str) -> PathBuf { pub fn snapshot_path(root: &Path, disk_id: &str) -> PathBuf {
data_usage_dir(root).join(snapshot_file_name(disk_id)) data_usage_dir(root).join(snapshot_file_name(disk_id))
} }
/// Read a snapshot from disk if it exists. /// Read a snapshot from disk if it exists.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub async fn read_snapshot(root: &Path, disk_id: &str) -> Result<Option<LocalUsageSnapshot>> { pub async fn read_snapshot(root: &Path, disk_id: &str) -> Result<Option<LocalUsageSnapshot>> {
let path = snapshot_path(root, disk_id); let path = snapshot_path(root, disk_id);
match fs::read(&path).await { match fs::read(&path).await {
@@ -138,6 +156,7 @@ pub async fn read_snapshot(root: &Path, disk_id: &str) -> Result<Option<LocalUsa
} }
/// Persist a snapshot to disk, creating directories as needed and overwriting any existing file. /// Persist a snapshot to disk, creating directories as needed and overwriting any existing file.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub async fn write_snapshot(root: &Path, disk_id: &str, snapshot: &LocalUsageSnapshot) -> Result<()> { pub async fn write_snapshot(root: &Path, disk_id: &str, snapshot: &LocalUsageSnapshot) -> Result<()> {
let dir = data_usage_dir(root); let dir = data_usage_dir(root);
fs::create_dir_all(&dir).await.map_err(Error::other)?; fs::create_dir_all(&dir).await.map_err(Error::other)?;
+136 -105
View File
@@ -13,7 +13,6 @@
// limitations under the License. // limitations under the License.
// #730: scanner/data-usage state is partially migrated and still owns staged cache helpers. // #730: scanner/data-usage state is partially migrated and still owns staged cache helpers.
#![allow(dead_code)]
pub mod local_snapshot; pub mod local_snapshot;
@@ -34,8 +33,8 @@ use crate::{
pub use local_snapshot::{LocalUsageSnapshot, read_snapshot as read_local_snapshot, snapshot_path}; pub use local_snapshot::{LocalUsageSnapshot, read_snapshot as read_local_snapshot, snapshot_path};
use rustfs_data_usage::{ use rustfs_data_usage::{
BucketTargetUsageInfo, BucketUsageInfo, CompressionTotalInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME, BucketTargetUsageInfo, BucketUsageInfo, CompressionTotalInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
DataUsageCache, DataUsageEntry, DataUsageInfo, DiskUsageStatus, LEGACY_DATA_USAGE_OBJECT_NAME, SizeHistogram, SizeSummary, DataUsageCache, DataUsageInfo, DiskUsageStatus, LEGACY_DATA_USAGE_OBJECT_NAME, SizeHistogram, VersionsHistogram,
VersionsHistogram, observed_data_usage_is_newer, observed_data_usage_is_newer,
}; };
use rustfs_io_metrics::record_system_path_failure; use rustfs_io_metrics::record_system_path_failure;
use rustfs_utils::path::SLASH_SEPARATOR; use rustfs_utils::path::SLASH_SEPARATOR;
@@ -55,7 +54,6 @@ use tracing::{debug, error, info, instrument};
// Data usage storage constants // Data usage storage constants
pub const DATA_USAGE_ROOT: &str = SLASH_SEPARATOR; pub const DATA_USAGE_ROOT: &str = SLASH_SEPARATOR;
const DATA_COMPRESSION_TOTAL_NAME: &str = ".compression.json"; const DATA_COMPRESSION_TOTAL_NAME: &str = ".compression.json";
const DATA_USAGE_BLOOM_NAME: &str = ".bloomcycle.bin";
pub const DATA_USAGE_CACHE_NAME: &str = ".usage-cache.bin"; pub const DATA_USAGE_CACHE_NAME: &str = ".usage-cache.bin";
const DATA_USAGE_CACHE_TTL_SECS: u64 = 30; const DATA_USAGE_CACHE_TTL_SECS: u64 = 30;
const LIVE_BUCKET_USAGE_MAX_ENTRIES: u64 = 1024; const LIVE_BUCKET_USAGE_MAX_ENTRIES: u64 = 1024;
@@ -313,11 +311,6 @@ lazy_static::lazy_static! {
LEGACY_DATA_USAGE_OBJECT_NAME LEGACY_DATA_USAGE_OBJECT_NAME
); );
static ref LEGACY_DATA_USAGE_OBJ_BACKUP_PATH: String = format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str()); static ref LEGACY_DATA_USAGE_OBJ_BACKUP_PATH: String = format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str());
pub static ref DATA_USAGE_BLOOM_NAME_PATH: String = format!("{}{}{}",
crate::disk::BUCKET_META_PREFIX,
SLASH_SEPARATOR,
DATA_USAGE_BLOOM_NAME
);
pub static ref DATA_COMPRESSION_TOTAL_NAME_PATH: String = format!("{}{}{}", pub static ref DATA_COMPRESSION_TOTAL_NAME_PATH: String = format!("{}{}{}",
crate::disk::BUCKET_META_PREFIX, crate::disk::BUCKET_META_PREFIX,
SLASH_SEPARATOR, SLASH_SEPARATOR,
@@ -858,6 +851,10 @@ async fn resolve_loaded_snapshot_pair_with_source(
} }
} }
#[allow(
dead_code,
reason = "primary/backup snapshot fallback asserted by this file's tests (backlog#1823)"
)]
async fn resolve_loaded_snapshot( async fn resolve_loaded_snapshot(
primary: Result<Vec<u8>, Error>, primary: Result<Vec<u8>, Error>,
backup: impl Future<Output = Result<Vec<u8>, Error>>, backup: impl Future<Output = Result<Vec<u8>, Error>>,
@@ -1187,6 +1184,10 @@ pub async fn invalidate_admin_data_usage_snapshot_cache() {
} }
/// Aggregate usage information from local disk snapshots. /// Aggregate usage information from local disk snapshots.
#[allow(
dead_code,
reason = "reached only through aggregate_local_snapshots, which has no caller (backlog#1823)"
)]
fn merge_snapshot(aggregated: &mut DataUsageInfo, mut snapshot: LocalUsageSnapshot, latest_update: &mut Option<SystemTime>) { fn merge_snapshot(aggregated: &mut DataUsageInfo, mut snapshot: LocalUsageSnapshot, latest_update: &mut Option<SystemTime>) {
if let Some(update) = snapshot.last_update if let Some(update) = snapshot.last_update
&& latest_update.is_none_or(|current| update > current) && latest_update.is_none_or(|current| update > current)
@@ -1220,6 +1221,10 @@ fn merge_snapshot(aggregated: &mut DataUsageInfo, mut snapshot: LocalUsageSnapsh
} }
} }
#[allow(
dead_code,
reason = "entry point of the local usage-snapshot feature, which has had no caller since it landed in #5307 (backlog#1823)"
)]
pub async fn aggregate_local_snapshots(store: Arc<ECStore>) -> Result<(Vec<DiskUsageStatus>, DataUsageInfo), Error> { pub async fn aggregate_local_snapshots(store: Arc<ECStore>) -> Result<(Vec<DiskUsageStatus>, DataUsageInfo), Error> {
let mut aggregated = DataUsageInfo::default(); let mut aggregated = DataUsageInfo::default();
let mut latest_update: Option<SystemTime> = None; let mut latest_update: Option<SystemTime> = None;
@@ -1355,7 +1360,7 @@ impl BucketUsageAccumulator {
return Ok(()); return Ok(());
} }
let object_size = object.size.max(0) as u64; let object_size = quota_object_size(object)?;
self.current_live_versions = self.current_live_versions.saturating_add(1); self.current_live_versions = self.current_live_versions.saturating_add(1);
self.size_histogram.add(object_size); self.size_histogram.add(object_size);
self.total_size = self.total_size.saturating_add(object_size); self.total_size = self.total_size.saturating_add(object_size);
@@ -1385,6 +1390,31 @@ impl BucketUsageAccumulator {
} }
} }
pub fn quota_object_size(object: &ObjectInfo) -> Result<u64, Error> {
let logical_size = u64::try_from(object.get_actual_size().map_err(Error::other)?).map_err(|_| Error::PartMissingOrCorrupt)?;
let persisted_part_size = if object.parts.is_empty() {
u64::try_from(object.size).map_err(|_| Error::PartMissingOrCorrupt)?
} else {
object.parts.iter().try_fold(0_u64, |total, part| {
// Compressed streaming objects persist -1 when the transformed
// part size is unknown. The physical part size remains a valid
// quota floor; reject only non-negative values that overflow.
let actual_size = if part.actual_size < 0 {
if object.is_compressed() {
0
} else {
return Err(Error::PartMissingOrCorrupt);
}
} else {
u64::try_from(part.actual_size).map_err(|_| Error::PartMissingOrCorrupt)?
};
let part_size = actual_size.max(u64::try_from(part.size).map_err(|_| Error::PartMissingOrCorrupt)?);
total.checked_add(part_size).ok_or(Error::PartMissingOrCorrupt)
})?
};
Ok(logical_size.max(persisted_part_size))
}
type UsageVersionPage = StorageListObjectVersionsInfo<ObjectInfo>; type UsageVersionPage = StorageListObjectVersionsInfo<ObjectInfo>;
pub async fn compute_bucket_usage(store: Arc<ECStore>, bucket_name: &str) -> Result<BucketUsageInfo, Error> { pub async fn compute_bucket_usage(store: Arc<ECStore>, bucket_name: &str) -> Result<BucketUsageInfo, Error> {
@@ -1742,11 +1772,6 @@ pub async fn record_bucket_object_write_unknown_previous_memory(bucket: &str, ne
entry.pending_scanner_position = None; entry.pending_scanner_position = None;
} }
/// Fast in-memory increment for immediate quota consistency.
pub async fn increment_bucket_usage_memory(bucket: &str, size_increment: u64) {
record_bucket_object_write_memory(bucket, None, size_increment).await;
}
/// Fast in-memory update for successful object deletes. /// Fast in-memory update for successful object deletes.
pub async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64, removed_current_object: bool) { pub async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64, removed_current_object: bool) {
ensure_bucket_usage_cached(bucket).await; ensure_bucket_usage_cached(bucket).await;
@@ -1789,11 +1814,6 @@ pub async fn record_bucket_delete_marker_memory(bucket: &str) {
entry.pending_scanner_position = None; entry.pending_scanner_position = None;
} }
/// Fast in-memory decrement for immediate quota consistency
pub async fn decrement_bucket_usage_memory(bucket: &str, size_decrement: u64) {
record_bucket_object_delete_memory(bucket, size_decrement, size_decrement > 0).await;
}
/// Get bucket usage from the authoritative cache for this topology. /// Get bucket usage from the authoritative cache for this topology.
async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> { async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> {
let store = runtime_sources::object_store_handle()?; let store = runtime_sources::object_store_handle()?;
@@ -1988,91 +2008,6 @@ pub async fn apply_bucket_usage_memory_overlay(data_usage_info: &mut DataUsageIn
apply_bucket_usage_memory_overlay_if_authoritative(data_usage_info, authoritative).await; apply_bucket_usage_memory_overlay_if_authoritative(data_usage_info, authoritative).await;
} }
/// Sync memory cache with backend data (called by scanner)
pub async fn sync_memory_cache_with_backend() -> Result<(), Error> {
if let Some(store) = runtime_sources::object_store_handle() {
match load_data_usage_from_backend(store.clone()).await {
Ok(data_usage_info) => {
replace_bucket_usage_memory_from_info(&data_usage_info).await;
}
Err(e) => {
debug!("Failed to sync memory cache with backend: {}", e);
}
}
}
Ok(())
}
/// Create a data usage cache entry from size summary
pub fn create_cache_entry_from_summary(summary: &SizeSummary) -> DataUsageEntry {
let mut entry = DataUsageEntry::default();
entry.add_sizes(summary);
entry
}
/// Convert data usage cache to DataUsageInfo
pub fn cache_to_data_usage_info(
cache: &DataUsageCache,
path: &str,
buckets: &[crate::storage_api_contracts::bucket::BucketInfo],
) -> DataUsageInfo {
let e = match cache.find(path) {
Some(e) => e,
None => return DataUsageInfo::default(),
};
let flat = cache.flatten(&e);
let mut buckets_usage = HashMap::new();
for bucket in buckets.iter() {
let e = match cache.find(&bucket.name) {
Some(e) => e,
None => continue,
};
let flat = cache.flatten(&e);
let mut bui = BucketUsageInfo {
size: flat.size as u64,
versions_count: flat.versions as u64,
objects_count: flat.objects as u64,
delete_markers_count: flat.delete_markers as u64,
object_size_histogram: flat.obj_sizes.to_map(),
object_versions_histogram: flat.obj_versions.to_map(),
..Default::default()
};
if let Some(rs) = &flat.replication_stats {
bui.replica_size = rs.replica_size;
bui.replica_count = rs.replica_count;
for (arn, stat) in rs.targets.iter() {
bui.replication_info.insert(
arn.clone(),
BucketTargetUsageInfo {
replication_pending_size: stat.pending_size,
replicated_size: stat.replicated_size,
replication_failed_size: stat.failed_size,
replication_pending_count: stat.pending_count,
replication_failed_count: stat.failed_count,
replicated_count: stat.replicated_count,
..Default::default()
},
);
}
}
buckets_usage.insert(bucket.name.clone(), bui);
}
DataUsageInfo {
last_update: cache.info.last_update,
objects_total_count: flat.objects as u64,
versions_total_count: flat.versions as u64,
delete_markers_total_count: flat.delete_markers as u64,
objects_total_size: flat.size as u64,
buckets_count: e.children.len() as u64,
buckets_usage,
..Default::default()
}
}
// Helper functions for DataUsageCache operations // Helper functions for DataUsageCache operations
pub async fn load_data_usage_cache(store: &crate::set_disk::SetDisks, name: &str) -> crate::error::Result<DataUsageCache> { pub async fn load_data_usage_cache(store: &crate::set_disk::SetDisks, name: &str) -> crate::error::Result<DataUsageCache> {
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET}; use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
@@ -3124,6 +3059,102 @@ mod tests {
assert_eq!(usage.object_versions_histogram.get("BETWEEN_1000_AND_10000"), Some(&1)); assert_eq!(usage.object_versions_histogram.get("BETWEEN_1000_AND_10000"), Some(&1));
} }
#[test]
fn bucket_usage_uses_the_larger_of_logical_and_physical_size() {
let mut metadata = HashMap::new();
rustfs_utils::http::insert_str(
&mut metadata,
rustfs_utils::http::SUFFIX_COMPRESSION,
"klauspost/compress/s2".to_string(),
);
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "4096".to_string());
let object = ObjectInfo {
name: "compressed".to_string(),
size: 128,
user_defined: Arc::new(metadata),
..Default::default()
};
let mut usage = BucketUsageAccumulator::default();
usage
.record("bucket", &object)
.expect("valid compressed metadata should be counted");
assert_eq!(usage.finish().size, 4096);
let mut framed_metadata = HashMap::new();
rustfs_utils::http::insert_str(
&mut framed_metadata,
rustfs_utils::http::SUFFIX_COMPRESSION,
"klauspost/compress/s2".to_string(),
);
rustfs_utils::http::insert_str(&mut framed_metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "1".to_string());
let framed = ObjectInfo {
name: "framed".to_string(),
size: 17,
user_defined: Arc::new(framed_metadata),
..Default::default()
};
assert_eq!(quota_object_size(&framed).expect("physical framing must remain quota-accounted"), 17);
let legacy_compressed_part = ObjectInfo {
name: "legacy-compressed-part".to_string(),
size: 1,
user_defined: Arc::new((*framed.user_defined).clone()),
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
size: 1,
actual_size: -1,
..Default::default()
}]),
..Default::default()
};
assert_eq!(
quota_object_size(&legacy_compressed_part).expect("unknown compressed part size is a valid sentinel"),
1
);
let uncompressed_negative_part = ObjectInfo {
name: "uncompressed-negative-part".to_string(),
size: 1,
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
size: 1,
actual_size: -1,
..Default::default()
}]),
..Default::default()
};
assert!(matches!(quota_object_size(&uncompressed_negative_part), Err(Error::PartMissingOrCorrupt)));
let mut corrupt_metadata = (*object.user_defined).clone();
rustfs_utils::http::insert_str(&mut corrupt_metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "-1".to_string());
let corrupt = ObjectInfo {
user_defined: Arc::new(corrupt_metadata),
..object
};
assert!(matches!(quota_object_size(&corrupt), Err(Error::PartMissingOrCorrupt)));
let mut poisoned_metadata = HashMap::new();
rustfs_utils::http::insert_str(
&mut poisoned_metadata,
rustfs_utils::http::SUFFIX_COMPRESSION,
"klauspost/compress/s2".to_string(),
);
rustfs_utils::http::insert_str(&mut poisoned_metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "1".to_string());
let poisoned = ObjectInfo {
name: "legacy-swift-metadata".to_string(),
size: 4096,
user_defined: Arc::new(poisoned_metadata),
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
size: 4096,
actual_size: 4096,
..Default::default()
}]),
..Default::default()
};
assert_eq!(
quota_object_size(&poisoned).expect("persisted part accounting must bound legacy user metadata"),
4096
);
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn live_bucket_usage_refreshes_are_coalesced_only_while_in_flight() { async fn live_bucket_usage_refreshes_are_coalesced_only_while_in_flight() {
+37
View File
@@ -190,6 +190,17 @@ pub(crate) const GET_METADATA_CACHE_REASON_VERSION_SUSPENDED: &str = "version_su
pub(crate) const GET_METADATA_CACHE_REASON_VERSIONED: &str = "versioned"; pub(crate) const GET_METADATA_CACHE_REASON_VERSIONED: &str = "versioned";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA: &str = "conflicting_metadata"; pub(crate) const GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA: &str = "conflicting_metadata";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER: &str = "delete_marker"; pub(crate) const GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER: &str = "delete_marker";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY: &str = "data_read_inline_body_verify";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED: &str = "data_read_inline_deleted";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY: &str = "data_read_inline_geometry";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH: &str = "data_read_inline_identity_mismatch";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD: &str = "data_read_inline_missing_payload";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD: &str = "data_read_inline_missing_shard";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE: &str = "data_read_inline_not_inline";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE: &str = "data_read_inline_part_shape";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE: &str = "data_read_inline_remote";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE: &str = "data_read_inline_size";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED: &str = "data_read_inline_transformed";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_ERROR: &str = "error"; pub(crate) const GET_METADATA_EARLY_STOP_REASON_ERROR: &str = "error";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM: &str = "insufficient_quorum"; pub(crate) const GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM: &str = "insufficient_quorum";
pub(crate) const GET_METADATA_EARLY_STOP_REASON_NOT_FOUND: &str = "not_found"; pub(crate) const GET_METADATA_EARLY_STOP_REASON_NOT_FOUND: &str = "not_found";
@@ -551,6 +562,32 @@ mod tests {
assert_eq!(GET_METADATA_CACHE_REASON_VERSIONED, "versioned"); assert_eq!(GET_METADATA_CACHE_REASON_VERSIONED, "versioned");
assert_eq!(GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, "conflicting_metadata"); assert_eq!(GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, "conflicting_metadata");
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER, "delete_marker"); assert_eq!(GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER, "delete_marker");
assert_eq!(
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY,
"data_read_inline_body_verify"
);
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED, "data_read_inline_deleted");
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY, "data_read_inline_geometry");
assert_eq!(
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH,
"data_read_inline_identity_mismatch"
);
assert_eq!(
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD,
"data_read_inline_missing_payload"
);
assert_eq!(
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD,
"data_read_inline_missing_shard"
);
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE, "data_read_inline_not_inline");
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE, "data_read_inline_part_shape");
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE, "data_read_inline_remote");
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE, "data_read_inline_size");
assert_eq!(
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED,
"data_read_inline_transformed"
);
assert_eq!(GET_METADATA_EARLY_STOP_REASON_ERROR, "error"); assert_eq!(GET_METADATA_EARLY_STOP_REASON_ERROR, "error");
assert_eq!(GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM, "insufficient_quorum"); assert_eq!(GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM, "insufficient_quorum");
assert_eq!(GET_METADATA_EARLY_STOP_REASON_NOT_FOUND, "not_found"); assert_eq!(GET_METADATA_EARLY_STOP_REASON_NOT_FOUND, "not_found");
+36 -7
View File
@@ -241,6 +241,40 @@ pub fn get_drive_list_dir_timeout() -> Duration {
) )
} }
pub(crate) trait DiskStoreRenameDataExt {
async fn rename_data_borrowed(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp>;
}
impl DiskStoreRenameDataExt for LocalDiskWrapper {
async fn rename_data_borrowed(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
self.track_disk_health_mutation(
"rename_data",
DiskMetricMutation::Write,
|| async {
self.disk
.rename_data_borrowed(src_volume, src_path, fi, dst_volume, dst_path)
.await
},
get_max_timeout_duration(),
)
.await
}
}
pub fn get_drive_walkdir_timeout() -> Duration { pub fn get_drive_walkdir_timeout() -> Duration {
get_drive_timeout_duration( get_drive_timeout_duration(
rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS, rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS,
@@ -1985,13 +2019,8 @@ impl DiskAPI for LocalDiskWrapper {
dst_volume: &str, dst_volume: &str,
dst_path: &str, dst_path: &str,
) -> Result<RenameDataResp> { ) -> Result<RenameDataResp> {
self.track_disk_health_mutation( self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
"rename_data", .await
DiskMetricMutation::Write,
|| async { self.disk.rename_data(src_volume, src_path, fi, dst_volume, dst_path).await },
get_max_timeout_duration(),
)
.await
} }
async fn list_dir(&self, origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> { async fn list_dir(&self, origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
+209 -25
View File
@@ -27,17 +27,18 @@ use crate::disk::{
BUCKET_META_PREFIX, CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN, BUCKET_META_PREFIX, CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN,
CHECK_PART_VOLUME_NOT_FOUND, CheckPartsResp, ConditionalFileUpdate, DataDirDeleteStatus, DeleteOptions, DiskAPI, DiskInfo, CHECK_PART_VOLUME_NOT_FOUND, CheckPartsResp, ConditionalFileUpdate, DataDirDeleteStatus, DeleteOptions, DiskAPI, DiskInfo,
DiskInfoOptions, DiskLocation, DiskMetrics, FileInfoVersions, FileReader, FileWriter, MmapCopyStageMetrics, OldCurrentSize, DiskInfoOptions, DiskLocation, DiskMetrics, FileInfoVersions, FileReader, FileWriter, MmapCopyStageMetrics, OldCurrentSize,
PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction, RUSTFS_META_BUCKET, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction,
RUSTFS_META_TMP_BUCKET, RUSTFS_META_TMP_DELETED_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_TMP_BUCKET, RUSTFS_META_TMP_DELETED_BUCKET,
STORAGE_FORMAT_FILE, STORAGE_FORMAT_FILE_BACKUP, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, STORAGE_FORMAT_FILE_BACKUP,
conv_part_err_to_int, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, conv_part_err_to_int,
endpoint::Endpoint, endpoint::Endpoint,
error::{DiskError, Error, FileAccessDeniedWithContext, Result}, error::{DiskError, Error, FileAccessDeniedWithContext, Result},
error_conv::{to_access_error, to_file_error, to_unformatted_disk_error, to_volume_error}, error_conv::{to_access_error, to_file_error, to_unformatted_disk_error, to_volume_error},
format::FormatV3, format::FormatV3,
fs::{O_APPEND, O_CREATE, O_RDONLY, O_TRUNC, O_WRONLY, access, lstat, lstat_std, remove, remove_all_std, remove_std, rename}, fs::{O_APPEND, O_CREATE, O_RDONLY, O_TRUNC, O_WRONLY, access, lstat, lstat_std, remove, remove_all_std, remove_std, rename},
os, is_quota_mutation_fence_path, os,
os::{check_path_length, is_dir_not_empty_error, is_empty_dir, is_root_disk, rename_all, rename_all_ignore_missing_source}, os::{check_path_length, is_dir_not_empty_error, is_empty_dir, is_root_disk, rename_all, rename_all_ignore_missing_source},
quota_mutation_fence_path,
}; };
use crate::erasure::coding::{self, bitrot_verify}; use crate::erasure::coding::{self, bitrot_verify};
use crate::runtime::sources as runtime_sources; use crate::runtime::sources as runtime_sources;
@@ -60,9 +61,7 @@ use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::fmt::Debug; use std::fmt::Debug;
use std::io::{Error as IoError, SeekFrom}; use std::io::{Error as IoError, SeekFrom};
#[cfg(target_os = "linux")] use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use std::time::Duration; use std::time::Duration;
use std::{ use std::{
@@ -2029,14 +2028,17 @@ static RENAME_DATA_REMOVE_DST_BASE_BEFORE_COMMIT: std::sync::Mutex<Option<(Strin
#[cfg(test)] #[cfg(test)]
type InlinePreparationHook = Box<dyn FnOnce() + Send>; type InlinePreparationHook = Box<dyn FnOnce() + Send>;
#[cfg(test)] #[cfg(test)]
type RenameDataPublicationHookKey = (PathBuf, String, String);
#[cfg(test)]
static INLINE_PREPARATION_BEFORE_BACKUP: std::sync::LazyLock<std::sync::Mutex<HashMap<String, InlinePreparationHook>>> = static INLINE_PREPARATION_BEFORE_BACKUP: std::sync::LazyLock<std::sync::Mutex<HashMap<String, InlinePreparationHook>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
#[cfg(test)] #[cfg(test)]
static INLINE_BEFORE_FILE_SYNC_ADMISSION: std::sync::LazyLock<std::sync::Mutex<HashMap<String, InlinePreparationHook>>> = static INLINE_BEFORE_FILE_SYNC_ADMISSION: std::sync::LazyLock<std::sync::Mutex<HashMap<String, InlinePreparationHook>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
#[cfg(test)] #[cfg(test)]
static RENAME_DATA_AFTER_FIRST_PUBLICATION: std::sync::LazyLock<std::sync::Mutex<HashMap<String, InlinePreparationHook>>> = static RENAME_DATA_AFTER_FIRST_PUBLICATION: std::sync::LazyLock<
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); std::sync::Mutex<HashMap<RenameDataPublicationHookKey, InlinePreparationHook>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
#[cfg(test)] #[cfg(test)]
static OWNED_FILE_WRITE_BEFORE_OPEN: std::sync::LazyLock<std::sync::Mutex<HashMap<PathBuf, InlinePreparationHook>>> = static OWNED_FILE_WRITE_BEFORE_OPEN: std::sync::LazyLock<std::sync::Mutex<HashMap<PathBuf, InlinePreparationHook>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
@@ -2108,11 +2110,11 @@ fn set_inline_before_file_sync_admission(dst_path: &str, hook: impl FnOnce() + S
} }
#[cfg(test)] #[cfg(test)]
fn set_rename_data_after_first_publication(dst_path: &str, hook: impl FnOnce() + Send + 'static) { fn set_rename_data_after_first_publication(root: &Path, dst_volume: &str, dst_path: &str, hook: impl FnOnce() + Send + 'static) {
RENAME_DATA_AFTER_FIRST_PUBLICATION RENAME_DATA_AFTER_FIRST_PUBLICATION
.lock() .lock()
.expect("test publication hook lock should not be poisoned") .expect("test publication hook lock should not be poisoned")
.insert(dst_path.to_string(), Box::new(hook)); .insert((root.to_path_buf(), dst_volume.to_string(), dst_path.to_string()), Box::new(hook));
} }
#[cfg(test)] #[cfg(test)]
@@ -2264,11 +2266,11 @@ fn run_inline_before_file_sync_admission(dst_path: &str) {
} }
#[cfg(test)] #[cfg(test)]
fn run_rename_data_after_first_publication(dst_path: &str) { fn run_rename_data_after_first_publication(root: &Path, dst_volume: &str, dst_path: &str) {
let hook = RENAME_DATA_AFTER_FIRST_PUBLICATION let hook = RENAME_DATA_AFTER_FIRST_PUBLICATION
.lock() .lock()
.expect("test publication hook lock should not be poisoned") .expect("test publication hook lock should not be poisoned")
.remove(dst_path); .remove(&(root.to_path_buf(), dst_volume.to_string(), dst_path.to_string()));
if let Some(hook) = hook { if let Some(hook) = hook {
hook(); hook();
} }
@@ -2366,9 +2368,6 @@ async fn remove_dst_base_before_commit(
#[cfg(not(test))] #[cfg(not(test))]
fn run_inline_preparation_before_backup(_dst_path: &str) {} fn run_inline_preparation_before_backup(_dst_path: &str) {}
#[cfg(not(test))]
fn run_rename_data_after_first_publication(_dst_path: &str) {}
#[cfg(not(test))] #[cfg(not(test))]
fn should_fail_after_delete_data_staged(_path: &str) -> bool { fn should_fail_after_delete_data_staged(_path: &str) -> bool {
false false
@@ -4756,6 +4755,25 @@ struct SnapshotLeaseEntry {
tokens: HashSet<SnapshotLeaseToken>, tokens: HashSet<SnapshotLeaseToken>,
pending_delete: Option<DeleteOptions>, pending_delete: Option<DeleteOptions>,
deleting: bool, deleting: bool,
mutation_fence: Option<Arc<QuotaMutationFenceState>>,
}
#[derive(Default)]
struct QuotaMutationFenceState {
revoked: AtomicBool,
running: AtomicUsize,
notify: Notify,
}
struct QuotaMutationFenceClaim {
state: Arc<QuotaMutationFenceState>,
}
impl Drop for QuotaMutationFenceClaim {
fn drop(&mut self) {
self.state.running.fetch_sub(1, Ordering::AcqRel);
self.state.notify.notify_waiters();
}
} }
#[derive(Default)] #[derive(Default)]
@@ -7320,6 +7338,34 @@ fn normalize_path_components(path: impl AsRef<Path>) -> PathBuf {
} }
impl LocalDisk { impl LocalDisk {
async fn claim_quota_mutation_fence(
&self,
volume: &str,
path: &str,
token: SnapshotLeaseToken,
) -> Result<Arc<QuotaMutationFenceClaim>> {
let key = SnapshotLeaseKey {
volume: RUSTFS_META_BUCKET.to_string(),
path: quota_mutation_fence_path(volume, path),
};
let state = {
let registry = self.snapshot_leases.lock().await;
let entry = registry.entries.get(&key).ok_or(DiskError::FileNotFound)?;
let state = entry.mutation_fence.as_ref().ok_or(DiskError::FileNotFound)?;
if !entry.tokens.contains(&token) || state.revoked.load(Ordering::Acquire) {
return Err(DiskError::FileNotFound);
}
state.running.fetch_add(1, Ordering::AcqRel);
Arc::clone(state)
};
if state.revoked.load(Ordering::Acquire) {
state.running.fetch_sub(1, Ordering::AcqRel);
state.notify.notify_waiters();
return Err(DiskError::FileNotFound);
}
Ok(Arc::new(QuotaMutationFenceClaim { state }))
}
async fn reserve_version_delete(&self, volume: &str, object: &str, data_dir: Uuid, rollback_dir: Uuid) -> Result<bool> { async fn reserve_version_delete(&self, volume: &str, object: &str, data_dir: Uuid, rollback_dir: Uuid) -> Result<bool> {
let path = format!("{object}/{data_dir}"); let path = format!("{object}/{data_dir}");
let data_path = self.io_get_object_path(volume, &path)?; let data_path = self.io_get_object_path(volume, &path)?;
@@ -8643,17 +8689,41 @@ impl DiskAPI for LocalDisk {
&self, &self,
src_volume: &str, src_volume: &str,
src_path: &str, src_path: &str,
mut fi: FileInfo, fi: FileInfo,
dst_volume: &str, dst_volume: &str,
dst_path: &str, dst_path: &str,
) -> Result<RenameDataResp> { ) -> Result<RenameDataResp> {
crate::hp_guard!("LocalDisk::rename_data"); crate::hp_guard!("LocalDisk::rename_data");
let mut fi = fi;
// A non-force DeleteBucket must not remove a directory while a local // A non-force DeleteBucket must not remove a directory while a local
// object commit is publishing into it. The peer's empty scan remains // object commit is publishing into it. The peer's empty scan remains
// optimistic; this lease establishes the local commit/delete order and // optimistic; this lease establishes the local commit/delete order and
// remains owned by any blocking syscall that outlives async cancellation. // remains owned by any blocking syscall that outlives async cancellation.
let destination_object_path = self.io_get_object_path(dst_volume, dst_path)?; let destination_object_path = self.io_get_object_path(dst_volume, dst_path)?;
let quota_fence_token =
match rustfs_utils::http::metadata_compat::get_consistent_str(&fi.metadata, QUOTA_MUTATION_FENCE_METADATA_SUFFIX) {
Some(value) => {
let token = Uuid::parse_str(value).map_err(|_| DiskError::FileCorrupt)?;
Some(SnapshotLeaseToken::from_slice(token.as_bytes())?)
}
None if rustfs_utils::http::metadata_compat::contains_key_str(
&fi.metadata,
QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
) =>
{
return Err(DiskError::FileCorrupt);
}
None => None,
};
rustfs_utils::http::metadata_compat::remove_str(&mut fi.metadata, QUOTA_MUTATION_FENCE_METADATA_SUFFIX);
let quota_fence_claim = match quota_fence_token {
Some(token) => Some(self.claim_quota_mutation_fence(dst_volume, dst_path, token).await?),
None => None,
};
let mutation_lease = os::acquire_rename_data_mutation_lease(&self.root, dst_volume, &destination_object_path).await; let mutation_lease = os::acquire_rename_data_mutation_lease(&self.root, dst_volume, &destination_object_path).await;
if let Some(claim) = quota_fence_claim {
mutation_lease.attach_external_guard(claim);
}
if fi.is_legacy_indexed_delete_marker() { if fi.is_legacy_indexed_delete_marker() {
fi.erasure.index = 0; fi.erasure.index = 0;
} }
@@ -8946,8 +9016,9 @@ impl DiskAPI for LocalDisk {
.await?; .await?;
return Err(err); return Err(err);
} }
#[cfg(test)]
if has_data_dir_path.is_some() { if has_data_dir_path.is_some() {
run_rename_data_after_first_publication(dst_path); run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
} }
// Crash-consistency injection: hard power loss after the data dir // Crash-consistency injection: hard power loss after the data dir
@@ -9380,7 +9451,8 @@ impl DiskAPI for LocalDisk {
let _ = remove_file_if_exists(staged_backup); let _ = remove_file_if_exists(staged_backup);
return Err(err); return Err(err);
} }
run_rename_data_after_first_publication(dst_path); #[cfg(test)]
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
if sync { if sync {
file_sync_admission = Some( file_sync_admission = Some(
os::acquire_file_sync_admission(self.file_sync_permits.clone()) os::acquire_file_sync_admission(self.file_sync_permits.clone())
@@ -9643,11 +9715,26 @@ impl DiskAPI for LocalDisk {
} }
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> { async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> {
let file_path = self.io_get_object_path(volume, path)?;
let key = SnapshotLeaseKey { let key = SnapshotLeaseKey {
volume: volume.to_string(), volume: volume.to_string(),
path: path.to_string(), path: path.to_string(),
}; };
if volume == RUSTFS_META_BUCKET && is_quota_mutation_fence_path(path) {
let mut registry = self.snapshot_leases.lock().await;
let entry = registry.entries.entry(key).or_default();
let state = entry
.mutation_fence
.get_or_insert_with(|| Arc::new(QuotaMutationFenceState::default()));
if state.revoked.load(Ordering::Acquire) {
return Err(DiskError::FileNotFound);
}
let token = SnapshotLeaseToken::new();
entry.tokens.insert(token);
return Ok(token);
}
let file_path = self.io_get_object_path(volume, path)?;
let _mutation_lease = os::acquire_rename_data_mutation_lease(&self.root, volume, &file_path).await;
let token = { let token = {
let mut registry = self.snapshot_leases.lock().await; let mut registry = self.snapshot_leases.lock().await;
if registry.entries.get(&key).is_some_and(|entry| entry.deleting) { if registry.entries.get(&key).is_some_and(|entry| entry.deleting) {
@@ -9675,6 +9762,48 @@ impl DiskAPI for LocalDisk {
volume: volume.to_string(), volume: volume.to_string(),
path: path.to_string(), path: path.to_string(),
}; };
if volume == RUSTFS_META_BUCKET && is_quota_mutation_fence_path(path) {
if !token.is_revoke_all() {
let mut registry = self.snapshot_leases.lock().await;
let Some(entry) = registry.entries.get_mut(&key) else {
return Ok(());
};
entry.tokens.remove(&token);
let removable = entry.tokens.is_empty()
&& entry
.mutation_fence
.as_ref()
.is_none_or(|state| state.running.load(Ordering::Acquire) == 0);
if removable {
registry.entries.remove(&key);
}
return Ok(());
}
let state = {
let mut registry = self.snapshot_leases.lock().await;
let Some(entry) = registry.entries.get_mut(&key) else {
return Ok(());
};
let Some(state) = entry.mutation_fence.as_ref().cloned() else {
registry.entries.remove(&key);
return Ok(());
};
state.revoked.store(true, Ordering::Release);
entry.tokens.clear();
state
};
loop {
let notified = state.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if state.running.load(Ordering::Acquire) == 0 {
break;
}
notified.await;
}
self.snapshot_leases.lock().await.entries.remove(&key);
return Ok(());
}
let opts = { let opts = {
let mut registry = self.snapshot_leases.lock().await; let mut registry = self.snapshot_leases.lock().await;
let Some(entry) = registry.entries.get_mut(&key) else { let Some(entry) = registry.entries.get_mut(&key) else {
@@ -10453,6 +10582,19 @@ impl DiskAPI for LocalDisk {
} }
} }
impl LocalDisk {
pub(crate) async fn rename_data_borrowed(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
<Self as DiskAPI>::rename_data(self, src_volume, src_path, fi.clone(), dst_volume, dst_path).await
}
}
async fn wait_for_startup_cleanup_signal( async fn wait_for_startup_cleanup_signal(
startup_cleanup_ready: &AtomicU32, startup_cleanup_ready: &AtomicU32,
startup_cleanup_notify: &Notify, startup_cleanup_notify: &Notify,
@@ -13007,7 +13149,7 @@ mod test {
let replacement_staging_parent_for_hook = replacement_staging_parent.clone(); let replacement_staging_parent_for_hook = replacement_staging_parent.clone();
let staged_metadata_for_hook = staged_metadata.clone(); let staged_metadata_for_hook = staged_metadata.clone();
let replacement_staged_metadata_for_hook = replacement_staged_metadata.clone(); let replacement_staged_metadata_for_hook = replacement_staged_metadata.clone();
set_rename_data_after_first_publication(object, move || { set_rename_data_after_first_publication(&disk.root, bucket, object, move || {
std::fs::rename(&object_dir_for_hook, &replacement_dir_for_hook) std::fs::rename(&object_dir_for_hook, &replacement_dir_for_hook)
.expect_err("the destination object identity must remain pinned until xl.meta commits"); .expect_err("the destination object identity must remain pinned until xl.meta commits");
std::fs::rename(&staging_parent_for_hook, &replacement_staging_parent_for_hook) std::fs::rename(&staging_parent_for_hook, &replacement_staging_parent_for_hook)
@@ -13274,7 +13416,7 @@ mod test {
let replacement_dir_for_hook = replacement_dir.clone(); let replacement_dir_for_hook = replacement_dir.clone();
let staged_metadata_for_hook = staged_metadata.clone(); let staged_metadata_for_hook = staged_metadata.clone();
let replacement_staged_metadata_for_hook = replacement_staged_metadata.clone(); let replacement_staged_metadata_for_hook = replacement_staged_metadata.clone();
set_rename_data_after_first_publication(object, move || { set_rename_data_after_first_publication(&disk.root, bucket, object, move || {
std::fs::rename(&object_dir_for_hook, &replacement_dir_for_hook) std::fs::rename(&object_dir_for_hook, &replacement_dir_for_hook)
.expect_err("the destination object identity must remain pinned after publishing its rollback backup"); .expect_err("the destination object identity must remain pinned after publishing its rollback backup");
std::fs::rename(&staged_metadata_for_hook, &replacement_staged_metadata_for_hook) std::fs::rename(&staged_metadata_for_hook, &replacement_staged_metadata_for_hook)
@@ -13640,7 +13782,7 @@ mod test {
let (entered_tx, entered_rx) = mpsc::channel(); let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel(); let (release_tx, release_rx) = mpsc::channel();
set_rename_data_after_first_publication(object, move || { set_rename_data_after_first_publication(&disk.root, bucket, object, move || {
entered_tx.send(()).expect("signal first publication"); entered_tx.send(()).expect("signal first publication");
release_rx.recv().expect("wait while delete_volume is blocked"); release_rx.recv().expect("wait while delete_volume is blocked");
}); });
@@ -14234,7 +14376,7 @@ mod test {
let (published_tx, published_rx) = mpsc::channel(); let (published_tx, published_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel(); let (release_tx, release_rx) = mpsc::channel();
set_rename_data_after_first_publication(object, move || { set_rename_data_after_first_publication(&disk.root, bucket, object, move || {
published_tx.send(()).expect("signal backup publication"); published_tx.send(()).expect("signal backup publication");
release_rx.recv().expect("wait for lock-order assertion"); release_rx.recv().expect("wait for lock-order assertion");
}); });
@@ -18889,6 +19031,48 @@ mod test {
assert!(matches!(disk.read_all(volume, &first_part).await, Err(DiskError::FileNotFound))); assert!(matches!(disk.read_all(volume, &first_part).await, Err(DiskError::FileNotFound)));
} }
#[tokio::test]
async fn quota_mutation_fence_revoke_waits_for_active_claim_and_rejects_late_claims() {
use tempfile::tempdir;
let root_dir = tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(root_dir.path().to_string_lossy().as_ref()).expect("endpoint should parse");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created"));
let bucket = "quota-fence-volume";
let object = "object";
let fence_path = quota_mutation_fence_path(bucket, object);
let token = disk
.acquire_snapshot_lease(RUSTFS_META_BUCKET, &fence_path)
.await
.expect("quota mutation token should be prepared");
let claim = disk
.claim_quota_mutation_fence(bucket, object, token)
.await
.expect("prepared token should be claimable");
let release_disk = Arc::clone(&disk);
let mut release = tokio::spawn(async move {
release_disk
.release_snapshot_lease(RUSTFS_META_BUCKET, &fence_path, SnapshotLeaseToken::revoke_all())
.await
});
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut release).await.is_err(),
"revoke must wait until an already claimed mutation has finished"
);
drop(claim);
tokio::time::timeout(Duration::from_secs(1), release)
.await
.expect("revoke should wake after the final claim drops")
.expect("revoke task should not panic")
.expect("revoke should succeed");
assert!(matches!(
disk.claim_quota_mutation_fence(bucket, object, token).await,
Err(DiskError::FileNotFound)
));
}
#[tokio::test] #[tokio::test]
async fn delete_version_keeps_later_part_until_snapshot_release() { async fn delete_version_keeps_later_part_until_snapshot_release() {
use tempfile::tempdir; use tempfile::tempdir;
+63 -4
View File
@@ -55,6 +55,7 @@ pub fn part_transaction_path(part_path: &str) -> String {
use crate::cluster::rpc::RemoteDisk; use crate::cluster::rpc::RemoteDisk;
use crate::cluster::rpc::build_internode_data_transport_from_env; use crate::cluster::rpc::build_internode_data_transport_from_env;
use crate::disk::disk_store::DiskStoreRenameDataExt;
use crate::disk::disk_store::LocalDiskWrapper; use crate::disk::disk_store::LocalDiskWrapper;
use crate::disk::health_state::RuntimeDriveHealthState; use crate::disk::health_state::RuntimeDriveHealthState;
use crate::disk::local::ScanGuard; use crate::disk::local::ScanGuard;
@@ -72,6 +73,28 @@ use time::OffsetDateTime;
use tokio::io::{AsyncRead, AsyncWrite}; use tokio::io::{AsyncRead, AsyncWrite};
use uuid::Uuid; use uuid::Uuid;
const QUOTA_MUTATION_FENCE_PREFIX: &str = "tmp/quota-mutation-fences/";
pub(crate) const QUOTA_MUTATION_FENCE_METADATA_SUFFIX: &str = "quota-mutation-fence-token";
pub(crate) fn quota_mutation_fence_path(bucket: &str, object: &str) -> String {
use sha2::{Digest, Sha256};
let mut input = Vec::with_capacity(bucket.len() + object.len() + 1);
input.extend_from_slice(bucket.as_bytes());
input.push(0);
input.extend_from_slice(object.as_bytes());
let digest = Sha256::digest(input);
format!(
"{QUOTA_MUTATION_FENCE_PREFIX}{}",
hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower)
)
}
pub(crate) fn is_quota_mutation_fence_path(path: &str) -> bool {
path.strip_prefix(QUOTA_MUTATION_FENCE_PREFIX)
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
}
pub type DiskStore = Arc<Disk>; pub type DiskStore = Arc<Disk>;
pub type FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>; pub type FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>;
@@ -96,6 +119,20 @@ impl SnapshotLeaseToken {
pub fn as_bytes(&self) -> &[u8; 16] { pub fn as_bytes(&self) -> &[u8; 16] {
self.0.as_bytes() self.0.as_bytes()
} }
pub(crate) fn as_uuid(self) -> Uuid {
self.0
}
#[doc(hidden)]
pub fn revoke_all() -> Self {
Self(Uuid::nil())
}
#[doc(hidden)]
pub fn is_revoke_all(self) -> bool {
self.0.is_nil()
}
} }
impl Default for SnapshotLeaseToken { impl Default for SnapshotLeaseToken {
@@ -398,10 +435,8 @@ impl DiskAPI for Disk {
dst_volume: &str, dst_volume: &str,
dst_path: &str, dst_path: &str,
) -> Result<RenameDataResp> { ) -> Result<RenameDataResp> {
match self { self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
Disk::Local(local_disk) => local_disk.rename_data(src_volume, src_path, fi, dst_volume, dst_path).await, .await
Disk::Remote(remote_disk) => remote_disk.rename_data(src_volume, src_path, fi, dst_volume, dst_path).await,
}
} }
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
@@ -631,6 +666,30 @@ impl DiskAPI for Disk {
} }
} }
impl Disk {
pub(crate) async fn rename_data_borrowed(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
match self {
Disk::Local(local_disk) => {
local_disk
.rename_data_borrowed(src_volume, src_path, fi, dst_volume, dst_path)
.await
}
Disk::Remote(remote_disk) => {
remote_disk
.rename_data_borrowed(src_volume, src_path, fi, dst_volume, dst_path)
.await
}
}
}
}
impl Disk { impl Disk {
pub async fn ns_scanner_server_epoch(&self) -> Result<Option<Uuid>> { pub async fn ns_scanner_server_epoch(&self) -> Result<Option<Uuid>> {
match self { match self {
+9
View File
@@ -306,12 +306,20 @@ fn disk_namespace_mutation_lock(path: &Path) -> Arc<NamespaceMutationLock> {
pub(crate) struct NamespaceMutationLease { pub(crate) struct NamespaceMutationLease {
_namespace_guard: OwnedMutexGuard<()>, _namespace_guard: OwnedMutexGuard<()>,
_volume_guard: Option<OwnedRwLockReadGuard<()>>, _volume_guard: Option<OwnedRwLockReadGuard<()>>,
external_guard: Mutex<Option<Arc<dyn Send + Sync>>>,
}
impl NamespaceMutationLease {
pub(crate) fn attach_external_guard(&self, guard: Arc<dyn Send + Sync>) {
*self.external_guard.lock() = Some(guard);
}
} }
async fn acquire_namespace_mutation_lease(path: &Path) -> Arc<NamespaceMutationLease> { async fn acquire_namespace_mutation_lease(path: &Path) -> Arc<NamespaceMutationLease> {
Arc::new(NamespaceMutationLease { Arc::new(NamespaceMutationLease {
_namespace_guard: disk_namespace_mutation_lock(path).lock_owned().await, _namespace_guard: disk_namespace_mutation_lock(path).lock_owned().await,
_volume_guard: None, _volume_guard: None,
external_guard: Mutex::new(None),
}) })
} }
@@ -327,6 +335,7 @@ pub(crate) async fn acquire_rename_data_mutation_lease(
Arc::new(NamespaceMutationLease { Arc::new(NamespaceMutationLease {
_namespace_guard: namespace_guard, _namespace_guard: namespace_guard,
_volume_guard: Some(volume_guard), _volume_guard: Some(volume_guard),
external_guard: Mutex::new(None),
}) })
} }
-69
View File
@@ -1075,9 +1075,6 @@ pub struct GenericError {
#[derive(Debug, thiserror::Error, PartialEq, Eq)] #[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ObjectApiError { pub enum ObjectApiError {
#[error("BackendDown")]
BackendDown(String),
#[error("The operation is not valid for the current state of the object {}/{}({})", .0.bucket, .0.object, .0.version_id)] #[error("The operation is not valid for the current state of the object {}/{}({})", .0.bucket, .0.object, .0.version_id)]
InvalidObjectState(GenericError), InvalidObjectState(GenericError),
} }
@@ -1094,72 +1091,6 @@ pub struct ErrorResponse {
pub host_id: String, pub host_id: String,
} }
pub fn error_resp_to_object_err(err: ErrorResponse, params: Vec<&str>) -> std::io::Error {
let mut bucket = "";
let mut object = "";
let mut version_id = "";
if !params.is_empty() {
bucket = params[0];
}
if params.len() >= 2 {
object = params[1];
}
if params.len() >= 3 {
version_id = params[2];
}
if is_network_or_host_down(&err.to_string(), false) {
return std::io::Error::other(ObjectApiError::BackendDown(format!("{err}")));
}
let err_ = std::io::Error::other(err.to_string());
let r_err = err;
let err;
let bucket = bucket.to_string();
let object = object.to_string();
let version_id = version_id.to_string();
match r_err.code {
S3ErrorCode::BucketNotEmpty => {
err = std::io::Error::other(StorageError::BucketNotEmpty("".to_string()).to_string());
}
S3ErrorCode::InvalidBucketName => {
err = std::io::Error::other(StorageError::BucketNameInvalid(bucket));
}
S3ErrorCode::InvalidPart => {
err = std::io::Error::other(StorageError::InvalidPart(0, bucket, object /* , version_id */));
}
S3ErrorCode::NoSuchBucket => {
err = std::io::Error::other(StorageError::BucketNotFound(bucket));
}
S3ErrorCode::NoSuchKey => {
if !object.is_empty() {
err = std::io::Error::other(StorageError::ObjectNotFound(bucket, object));
} else {
err = std::io::Error::other(StorageError::BucketNotFound(bucket));
}
}
S3ErrorCode::NoSuchVersion => {
if !object.is_empty() {
err = std::io::Error::other(StorageError::ObjectNotFound(bucket, object)); //, version_id);
} else {
err = std::io::Error::other(StorageError::BucketNotFound(bucket));
}
}
S3ErrorCode::AccessDenied => {
err = std::io::Error::other(StorageError::PrefixAccessDenied(bucket, object));
}
S3ErrorCode::NoSuchUpload => {
err = std::io::Error::other(StorageError::InvalidUploadID(bucket, object, version_id));
}
_ => {
err = err_;
}
}
err
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+4
View File
@@ -20,6 +20,10 @@ use std::sync::atomic::AtomicI64;
/// this type never grew past its counter. `total_events` is read by the /// this type never grew past its counter. `total_events` is read by the
/// notifier's log line but nothing increments it, so that field reports zero. /// notifier's log line but nothing increments it, so that field reports zero.
#[derive(Default)] #[derive(Default)]
#[allow(
dead_code,
reason = "held only by the dead ecstore EventNotifier; see services/event_notification.rs (backlog#1823)"
)]
pub struct TargetList { pub struct TargetList {
pub total_events: AtomicI64, pub total_events: AtomicI64,
} }
+22
View File
@@ -31,6 +31,13 @@ pub const ENV_DISK_COMPRESSION_MIME_TYPES: &str = "RUSTFS_COMPRESSION_MIME_TYPES
// Environment variable for additional extensions to exclude from compression (comma-separated, e.g. ".foo,.bar") // Environment variable for additional extensions to exclude from compression (comma-separated, e.g. ".foo,.bar")
pub const ENV_ADDED_EXCLUDE_COMPRESS_EXTENSIONS: &str = "RUSTFS_ADDED_EXCLUDE_COMPRESS_EXTENSIONS"; pub const ENV_ADDED_EXCLUDE_COMPRESS_EXTENSIONS: &str = "RUSTFS_ADDED_EXCLUDE_COMPRESS_EXTENSIONS";
// Environment variable to additionally enable disk compression for multipart uploads.
// Default off: nodes from before the resumable decompressor fix fail transient reads of
// compressed objects, so multipart compression stays dark until the operator confirms the
// fleet has converged on a fixed build.
// RUSTFS_COMPAT_TODO(multipart-compression-default-off-window): staged rollout switch for restored multipart compression, flipping the default to enabled on retirement. Remove after the minimum supported direct-upgrade release ships the resumable DecompressReader.
pub const ENV_DISK_COMPRESSION_MULTIPART_ENABLED: &str = "RUSTFS_COMPRESSION_MULTIPART_ENABLED";
pub const DEFAULT_DISK_COMPRESS_EXTENSIONS: &str = ".txt,.log,.csv,.json,.tar,.xml,.bin"; pub const DEFAULT_DISK_COMPRESS_EXTENSIONS: &str = ".txt,.log,.csv,.json,.tar,.xml,.bin";
pub const DEFAULT_DISK_COMPRESS_MIME_TYPES: &str = "text/*,application/json,application/xml,binary/octet-stream"; pub const DEFAULT_DISK_COMPRESS_MIME_TYPES: &str = "text/*,application/json,application/xml,binary/octet-stream";
@@ -171,6 +178,21 @@ pub fn is_disk_compression_enabled() -> bool {
DISK_COMPRESSION_CONFIG.get_or_init(parse_disk_compression_config).enabled DISK_COMPRESSION_CONFIG.get_or_init(parse_disk_compression_config).enabled
} }
// Parsed once at first use, mirroring DISK_COMPRESSION_CONFIG.
static MULTIPART_DISK_COMPRESSION_ENABLED: OnceLock<bool> = OnceLock::new();
/// Whether multipart uploads may advertise disk compression. Requires the
/// regular disk-compression gates to pass as well; this is the staged-rollout
/// switch that keeps multipart compression dark during rolling upgrades from
/// builds whose decompressor was not yet resumable.
pub fn is_multipart_disk_compression_enabled() -> bool {
*MULTIPART_DISK_COMPRESSION_ENABLED.get_or_init(|| {
env::var(ENV_DISK_COMPRESSION_MULTIPART_ENABLED)
.map(|s| matches!(s.to_ascii_lowercase().as_str(), "true" | "on" | "1"))
.unwrap_or(false)
})
}
fn is_disk_compressible_with_config(headers: &http::HeaderMap, object_name: &str, config: &DiskCompressionConfig) -> bool { fn is_disk_compressible_with_config(headers: &http::HeaderMap, object_name: &str, config: &DiskCompressionConfig) -> bool {
// Check if disk compression is enabled (read once at first use, then fixed for process lifetime) // Check if disk compression is enabled (read once at first use, then fixed for process lifetime)
if !config.enabled { if !config.enabled {
+1 -1
View File
@@ -22,7 +22,7 @@ use crate::bucket::replication::{
use crate::bucket::versioning::VersioningApi as _; use crate::bucket::versioning::VersioningApi as _;
use crate::config::storageclass; use crate::config::storageclass;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::io_support::rio::{HashReader, LimitReader}; use crate::io_support::rio::{HardLimitReader, HashReader};
use crate::storage_api_contracts::{ use crate::storage_api_contracts::{
lifecycle::{ExpirationOptions, TransitionedObject}, lifecycle::{ExpirationOptions, TransitionedObject},
range::HTTPRangeSpec, range::HTTPRangeSpec,
+475 -4
View File
@@ -479,7 +479,15 @@ enum ReadTransform {
}, },
} }
struct ReadPlan { /// How an object's stored bytes must be fetched and transformed to serve a
/// request.
///
/// Public so callers that fetch the stored bytes from somewhere other than the
/// local erasure set — the remote-tier read path — can position their own fetch
/// with [`ReadPlan::storage_offset`] / [`ReadPlan::storage_length`] and then
/// hand the resulting stream to [`ReadPlan::into_object_reader`], instead of
/// reimplementing the transform decisions (rustfs/rustfs#6025).
pub struct ReadPlan {
storage_offset: usize, storage_offset: usize,
storage_length: i64, storage_length: i64,
object_size: i64, object_size: i64,
@@ -487,6 +495,43 @@ struct ReadPlan {
} }
impl ReadPlan { impl ReadPlan {
/// Byte offset into the object's **stored** bytes where the fetch must
/// start. Encrypted and compressed objects address their storage in a
/// different coordinate system than the plaintext range the caller asked
/// for, which is exactly the distinction this plan resolves.
pub fn storage_offset(&self) -> usize {
self.storage_offset
}
/// Number of **stored** bytes the fetch must deliver, in the same
/// coordinate system as [`Self::storage_offset`].
pub fn storage_length(&self) -> i64 {
self.storage_length
}
/// Build the plan for a request without consuming a stream, so a caller
/// that has to issue its own positioned fetch can read the offsets first.
pub async fn build_for_request(
rs: Option<HTTPRangeSpec>,
oi: &ObjectInfo,
opts: &ObjectOptions,
h: &HeaderMap<HeaderValue>,
resolver: Option<&dyn ObjectEncryptionResolver>,
) -> Result<Self> {
Self::build_with_resolver(rs, oi, opts, h, resolver).await
}
/// Wrap `reader` — the stored bytes this plan asked for, already positioned
/// at [`Self::storage_offset`] — in the transforms that turn them into the
/// bytes the caller requested.
pub fn into_object_reader(
self,
reader: Box<dyn AsyncRead + Unpin + Send + Sync>,
oi: &ObjectInfo,
) -> Result<GetObjectReader> {
self.into_reader(reader, oi).map(|(reader, _, _)| reader)
}
#[cfg(test)] #[cfg(test)]
async fn build(rs: Option<HTTPRangeSpec>, oi: &ObjectInfo, opts: &ObjectOptions, h: &HeaderMap<HeaderValue>) -> Result<Self> { async fn build(rs: Option<HTTPRangeSpec>, oi: &ObjectInfo, opts: &ObjectOptions, h: &HeaderMap<HeaderValue>) -> Result<Self> {
Self::build_with_resolver(rs, oi, opts, h, Some(&tests::TEST_RESOLVER)).await Self::build_with_resolver(rs, oi, opts, h, Some(&tests::TEST_RESOLVER)).await
@@ -500,8 +545,17 @@ impl ReadPlan {
resolver: Option<&dyn ObjectEncryptionResolver>, resolver: Option<&dyn ObjectEncryptionResolver>,
) -> Result<Self> { ) -> Result<Self> {
let mut rs = rs; let mut rs = rs;
// A part number addresses the object's PLAINTEXT bytes. A restore read
// serves the stored representation instead (see
// [`restore_request_active`]), where that synthesized range would be
// reinterpreted as a storage range and truncate an encrypted or
// compressed payload by exactly its encoding overhead — the copy-back
// then fails its length check partway through
// (rustfs/rustfs#6025). An explicit caller range is already in storage
// coordinates on that path and is still honored.
if let Some(part_number) = opts.part_number if let Some(part_number) = opts.part_number
&& rs.is_none() && rs.is_none()
&& !restore_request_active(opts)
{ {
rs = http_range_spec_from_object_info(oi, part_number); rs = http_range_spec_from_object_info(oi, part_number);
} }
@@ -754,7 +808,7 @@ impl ReadPlan {
} }
} }
} else { } else {
Box::new(LimitReader::new(dec_reader, total_plaintext_size)) Box::new(HardLimitReader::new(dec_reader, decompressed_length))
}; };
let mut object_info = oi.clone(); let mut object_info = oi.clone();
@@ -846,7 +900,7 @@ impl ReadPlan {
)?; )?;
Box::new(ranged_reader) Box::new(ranged_reader)
} else { } else {
Box::new(LimitReader::new(decompressed_reader, total_plaintext_size)) Box::new(HardLimitReader::new(decompressed_reader, total_plaintext_size_i64))
} }
} else if plaintext_offset > 0 || plaintext_length != total_plaintext_size_i64 { } else if plaintext_offset > 0 || plaintext_length != total_plaintext_size_i64 {
Box::new(RangedDecompressReader::new( Box::new(RangedDecompressReader::new(
@@ -856,7 +910,7 @@ impl ReadPlan {
total_plaintext_size, total_plaintext_size,
)?) )?)
} else { } else {
Box::new(LimitReader::new(decrypted_reader, total_plaintext_size)) Box::new(HardLimitReader::new(decrypted_reader, total_plaintext_size_i64))
}; };
let mut object_info = oi.clone(); let mut object_info = oi.clone();
@@ -1727,6 +1781,423 @@ mod tests {
assert_eq!(actual, b"fghijkl"); assert_eq!(actual, b"fghijkl");
} }
/// Compresses one multipart part exactly like the write path does
/// (`WritePlan::with_compression` wraps each part in its own
/// `compression_reader`), returning the on-disk bytes and the storage-format
/// compression index.
async fn compressed_part_fixture(data: &[u8]) -> (Vec<u8>, Option<Bytes>) {
use crate::io_support::rio::TryGetIndex as _;
let mut compressor =
crate::io_support::rio::compression_reader(Cursor::new(data.to_vec()), CompressionAlgorithm::default(), false);
let mut compressed = Vec::new();
compressor.read_to_end(&mut compressed).await.expect("compress part stream");
let index = compressor
.try_get_index()
.map(crate::io_support::rio::compression_index_storage_bytes);
(compressed, index)
}
struct CompressedMultipartFixture {
object_info: ObjectInfo,
stored: Vec<u8>,
plaintext: Vec<u8>,
}
/// Builds the on-disk representation of a compressed multipart object: each
/// part is an independent compressed stream and the storage layer serves
/// their concatenation.
async fn compressed_multipart_fixture(part_sizes: &[usize]) -> CompressedMultipartFixture {
let pattern = b"compressed multipart read path fixture data ";
let mut plaintext = Vec::new();
let mut stored = Vec::new();
let mut parts = Vec::with_capacity(part_sizes.len());
for (i, part_size) in part_sizes.iter().enumerate() {
let mut part_plaintext = Vec::with_capacity(*part_size);
while part_plaintext.len() < *part_size {
part_plaintext.extend_from_slice(pattern);
part_plaintext.push(i as u8);
}
part_plaintext.truncate(*part_size);
let (compressed, index) = compressed_part_fixture(&part_plaintext).await;
parts.push(ObjectPartInfo {
number: i + 1,
size: compressed.len(),
actual_size: *part_size as i64,
index,
..Default::default()
});
stored.extend_from_slice(&compressed);
plaintext.extend_from_slice(&part_plaintext);
}
let mut user_defined = HashMap::new();
rustfs_utils::http::insert_str(
&mut user_defined,
rustfs_utils::http::SUFFIX_COMPRESSION,
crate::io_support::rio::compression_metadata_value(CompressionAlgorithm::default()),
);
rustfs_utils::http::insert_str(&mut user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, plaintext.len().to_string());
let object_info = ObjectInfo {
bucket: "test-bucket".to_string(),
name: "compressed-multipart".to_string(),
size: stored.len() as i64,
etag: Some(format!("6bcf86bed8807b8e78f0fc6e0a53079d-{}", part_sizes.len())),
parts: Arc::new(parts),
user_defined: Arc::new(user_defined),
..Default::default()
};
CompressedMultipartFixture {
object_info,
stored,
plaintext,
}
}
/// Plans the read once to learn the storage window, then serves exactly that
/// window — mirroring how `set_disk` feeds the erasure read into the
/// returned reader.
async fn read_compressed_multipart(
fixture: &CompressedMultipartFixture,
rs: Option<HTTPRangeSpec>,
opts: &ObjectOptions,
) -> Vec<u8> {
let headers = HeaderMap::new();
let (_, offset, length) =
GetObjectReader::new(Box::new(Cursor::new(Vec::new())), rs.clone(), &fixture.object_info, opts, &headers)
.await
.expect("plan compressed multipart read");
let end = offset + usize::try_from(length).expect("storage window length must be non-negative");
assert!(
end <= fixture.stored.len(),
"planned storage window {offset}..{end} exceeds stored stream of {} bytes",
fixture.stored.len()
);
let window = fixture.stored[offset..end].to_vec();
let (mut reader, replay_offset, replay_length) =
GetObjectReader::new(Box::new(Cursor::new(window)), rs, &fixture.object_info, opts, &headers)
.await
.expect("build compressed multipart reader");
assert_eq!((replay_offset, replay_length), (offset, length), "read plan must be deterministic");
reader.read_all().await.expect("read compressed multipart stream")
}
/// Byte pattern with a 2 KiB period: it compresses extremely well while
/// looking nothing like ASCII fixtures. Mirrors the e2e generator that
/// exposed a truncated full GET on high-ratio multipart payloads.
fn high_ratio_binary_payload(size: usize, seed: u8) -> Vec<u8> {
(0..size)
.map(|i| ((i as u64).wrapping_mul(2_654_435_761).wrapping_add(seed as u64) >> 3) as u8)
.collect()
}
#[tokio::test]
async fn compressed_multipart_full_get_handles_high_ratio_binary_payload() {
let part_sizes = [5 * 1024 * 1024_usize, 1024 * 1024];
let mut plaintext = Vec::new();
let mut stored = Vec::new();
let mut parts = Vec::with_capacity(part_sizes.len());
for (i, part_size) in part_sizes.iter().enumerate() {
let part_plaintext = high_ratio_binary_payload(*part_size, if i == 0 { 7 } else { 61 });
let (compressed, index) = compressed_part_fixture(&part_plaintext).await;
parts.push(ObjectPartInfo {
number: i + 1,
size: compressed.len(),
actual_size: *part_size as i64,
index,
..Default::default()
});
stored.extend_from_slice(&compressed);
plaintext.extend_from_slice(&part_plaintext);
}
let mut user_defined = HashMap::new();
rustfs_utils::http::insert_str(
&mut user_defined,
rustfs_utils::http::SUFFIX_COMPRESSION,
crate::io_support::rio::compression_metadata_value(CompressionAlgorithm::default()),
);
rustfs_utils::http::insert_str(&mut user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, plaintext.len().to_string());
let fixture = CompressedMultipartFixture {
object_info: ObjectInfo {
bucket: "test-bucket".to_string(),
name: "high-ratio-multipart".to_string(),
size: stored.len() as i64,
etag: Some("6bcf86bed8807b8e78f0fc6e0a53079d-2".to_string()),
parts: Arc::new(parts),
user_defined: Arc::new(user_defined),
..Default::default()
},
stored,
plaintext,
};
let read = read_compressed_multipart(&fixture, None, &ObjectOptions::default()).await;
assert_eq!(read.len(), fixture.plaintext.len(), "full GET must return the logical size");
assert_eq!(read, fixture.plaintext, "high-ratio multipart payload must survive the roundtrip");
}
/// Full GET over a compressed multipart object must decode across part
/// boundaries: every part is an independent compressed stream (this is also
/// the on-disk shape written by builds before rustfs/rustfs#5169 disabled
/// multipart compression, so this pins legacy-object readability).
#[tokio::test]
async fn compressed_multipart_full_get_decodes_across_part_boundaries() {
let fixture = compressed_multipart_fixture(&[3 * 1024 * 1024, 2 * 1024 * 1024, 512 * 1024]).await;
let read = read_compressed_multipart(&fixture, None, &ObjectOptions::default()).await;
assert_eq!(read.len(), fixture.plaintext.len(), "full GET must return the logical size");
assert_eq!(read, fixture.plaintext, "full GET must reassemble all parts");
}
#[tokio::test]
async fn compressed_multipart_range_get_crosses_part_boundary() {
let fixture = compressed_multipart_fixture(&[3 * 1024 * 1024, 2 * 1024 * 1024]).await;
let boundary = 3 * 1024 * 1024_i64;
let rs = HTTPRangeSpec {
is_suffix_length: false,
start: boundary - 100_000,
end: boundary + 100_000 - 1,
};
let read = read_compressed_multipart(&fixture, Some(rs), &ObjectOptions::default()).await;
let expected = &fixture.plaintext[(boundary - 100_000) as usize..(boundary + 100_000) as usize];
assert_eq!(read, expected, "boundary-crossing range must splice both parts");
}
#[tokio::test]
async fn compressed_multipart_range_get_seeks_into_later_part() {
let fixture = compressed_multipart_fixture(&[3 * 1024 * 1024, 4 * 1024 * 1024]).await;
// Deep inside part 2 so the plan skips part 1 entirely and (when the
// part carries an index) seeks within part 2.
let start = 3 * 1024 * 1024_i64 + 2 * 1024 * 1024_i64 + 137;
let rs = HTTPRangeSpec {
is_suffix_length: false,
start,
end: start + 64 * 1024 - 1,
};
let read = read_compressed_multipart(&fixture, Some(rs), &ObjectOptions::default()).await;
let expected = &fixture.plaintext[start as usize..(start + 64 * 1024) as usize];
assert_eq!(read, expected, "range inside a later part must decode from that part");
}
/// Parts written without a compression index (small parts skip the index in
/// the rio-v2 backend) must still be rangeable: the plan starts at the part
/// boundary and skips decompressed bytes.
#[tokio::test]
async fn compressed_multipart_range_get_works_without_part_indexes() {
let mut fixture = compressed_multipart_fixture(&[1024 * 1024, 1024 * 1024]).await;
let parts = fixture
.object_info
.parts
.iter()
.map(|part| ObjectPartInfo {
index: None,
..part.clone()
})
.collect::<Vec<_>>();
fixture.object_info.parts = Arc::new(parts);
let start = 1024 * 1024_i64 + 4096;
let rs = HTTPRangeSpec {
is_suffix_length: false,
start,
end: start + 32 * 1024 - 1,
};
let read = read_compressed_multipart(&fixture, Some(rs), &ObjectOptions::default()).await;
let expected = &fixture.plaintext[start as usize..(start + 32 * 1024) as usize];
assert_eq!(read, expected, "index-less parts must fall back to part-boundary skip");
}
#[tokio::test]
async fn compressed_multipart_part_number_get_returns_single_part() {
let part_sizes = [3 * 1024 * 1024, 2 * 1024 * 1024, 512 * 1024];
let fixture = compressed_multipart_fixture(&part_sizes).await;
let mut logical_offset = 0_usize;
for (i, part_size) in part_sizes.iter().enumerate() {
let opts = ObjectOptions {
part_number: Some(i + 1),
..Default::default()
};
let read = read_compressed_multipart(&fixture, None, &opts).await;
let expected = &fixture.plaintext[logical_offset..logical_offset + part_size];
assert_eq!(read.len(), *part_size, "partNumber={} GET must return the part's logical size", i + 1);
assert_eq!(read, expected, "partNumber={} GET must return the original part bytes", i + 1);
logical_offset += part_size;
}
}
#[tokio::test]
async fn compressed_multipart_suffix_range_reads_tail() {
let fixture = compressed_multipart_fixture(&[3 * 1024 * 1024, 1024 * 1024]).await;
let suffix_len = 128 * 1024_i64;
let rs = HTTPRangeSpec {
is_suffix_length: true,
start: suffix_len,
end: -1,
};
let read = read_compressed_multipart(&fixture, Some(rs), &ObjectOptions::default()).await;
let expected = &fixture.plaintext[fixture.plaintext.len() - suffix_len as usize..];
assert_eq!(read, expected, "suffix range must return the tail of the last part");
}
/// Builds an SSE-C + disk-compression multipart object exactly like the
/// write path: each part is compressed into its own stream and then
/// encrypted with the per-part key schedule. The fixture is
/// legacy-encryption-specific (`rustfs_rio::EncryptReader`), matching the
/// pre-existing `build_legacy_ssec_multipart_fixture` shape, while the
/// compression layer follows the active backend feature.
async fn compressed_encrypted_multipart_fixture(key_bytes: [u8; 32], part_sizes: &[usize]) -> CompressedMultipartFixture {
let pattern = b"compressed encrypted multipart fixture data ";
let mut plaintext = Vec::new();
let mut stored = Vec::new();
let mut parts = Vec::with_capacity(part_sizes.len());
for (i, part_size) in part_sizes.iter().enumerate() {
let part_number = i + 1;
let mut part_plaintext = Vec::with_capacity(*part_size);
while part_plaintext.len() < *part_size {
part_plaintext.extend_from_slice(pattern);
part_plaintext.push(part_number as u8);
}
part_plaintext.truncate(*part_size);
let (compressed, index) = compressed_part_fixture(&part_plaintext).await;
let mut part_cipher = Vec::new();
rustfs_rio::EncryptReader::new_multipart(Cursor::new(compressed), key_bytes, LEGACY_FIXTURE_BASE_NONCE, part_number)
.read_to_end(&mut part_cipher)
.await
.expect("encrypt compressed fixture part");
parts.push(ObjectPartInfo {
number: part_number,
size: part_cipher.len(),
actual_size: *part_size as i64,
index,
..Default::default()
});
stored.extend_from_slice(&part_cipher);
plaintext.extend_from_slice(&part_plaintext);
}
let mut user_defined = legacy_ssec_multipart_metadata(key_bytes, plaintext.len());
rustfs_utils::http::insert_str(
&mut user_defined,
rustfs_utils::http::SUFFIX_COMPRESSION,
crate::io_support::rio::compression_metadata_value(CompressionAlgorithm::default()),
);
rustfs_utils::http::insert_str(&mut user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, plaintext.len().to_string());
let object_info = ObjectInfo {
bucket: "test-bucket".to_string(),
name: "compressed-encrypted-multipart".to_string(),
size: stored.len() as i64,
etag: Some(format!("6bcf86bed8807b8e78f0fc6e0a53079d-{}", part_sizes.len())),
parts: Arc::new(parts),
user_defined: Arc::new(user_defined),
..Default::default()
};
CompressedMultipartFixture {
object_info,
stored,
plaintext,
}
}
async fn read_compressed_encrypted_multipart(
fixture: &CompressedMultipartFixture,
key_bytes: [u8; 32],
rs: Option<HTTPRangeSpec>,
opts: &ObjectOptions,
) -> Vec<u8> {
let headers = ssec_headers_from_key(key_bytes);
let (_, offset, length) =
GetObjectReader::new(Box::new(Cursor::new(Vec::new())), rs.clone(), &fixture.object_info, opts, &headers)
.await
.expect("plan compressed encrypted multipart read");
let end = offset + usize::try_from(length).expect("storage window length must be non-negative");
assert!(
end <= fixture.stored.len(),
"planned storage window {offset}..{end} exceeds stored stream of {} bytes",
fixture.stored.len()
);
let window = fixture.stored[offset..end].to_vec();
let (mut reader, replay_offset, replay_length) =
GetObjectReader::new(Box::new(Cursor::new(window)), rs, &fixture.object_info, opts, &headers)
.await
.expect("build compressed encrypted multipart reader");
assert_eq!((replay_offset, replay_length), (offset, length), "read plan must be deterministic");
reader.read_all().await.expect("read compressed encrypted multipart stream")
}
#[tokio::test]
async fn compressed_encrypted_multipart_full_get_roundtrip() {
let key_bytes = [0x6Eu8; 32];
let fixture = compressed_encrypted_multipart_fixture(key_bytes, &[3 * 1024 * 1024, 1024 * 1024]).await;
let read = read_compressed_encrypted_multipart(&fixture, key_bytes, None, &ObjectOptions::default()).await;
assert_eq!(read.len(), fixture.plaintext.len(), "full GET must return the logical size");
assert_eq!(read, fixture.plaintext, "SSE-C + compression full GET must reassemble all parts");
}
#[tokio::test]
async fn compressed_encrypted_multipart_range_crosses_part_boundary() {
let key_bytes = [0x6Eu8; 32];
let fixture = compressed_encrypted_multipart_fixture(key_bytes, &[3 * 1024 * 1024, 1024 * 1024]).await;
let boundary = 3 * 1024 * 1024_i64;
let rs = HTTPRangeSpec {
is_suffix_length: false,
start: boundary - 65_536,
end: boundary + 65_536 - 1,
};
let read = read_compressed_encrypted_multipart(&fixture, key_bytes, Some(rs), &ObjectOptions::default()).await;
let expected = &fixture.plaintext[(boundary - 65_536) as usize..(boundary + 65_536) as usize];
assert_eq!(read, expected, "SSE-C + compression boundary-crossing range must splice both parts");
}
#[tokio::test]
async fn compressed_encrypted_multipart_part_number_get_returns_single_part() {
let key_bytes = [0x6Eu8; 32];
let part_sizes = [3 * 1024 * 1024, 1024 * 1024];
let fixture = compressed_encrypted_multipart_fixture(key_bytes, &part_sizes).await;
let opts = ObjectOptions {
part_number: Some(2),
..Default::default()
};
let read = read_compressed_encrypted_multipart(&fixture, key_bytes, None, &opts).await;
let expected = &fixture.plaintext[part_sizes[0]..];
assert_eq!(read.len(), part_sizes[1], "partNumber=2 GET must return the part's logical size");
assert_eq!(read, expected, "partNumber=2 GET must return the original part bytes");
}
#[tokio::test] #[tokio::test]
async fn test_get_object_reader_rejects_ssec_read_without_headers() { async fn test_get_object_reader_rejects_ssec_read_without_headers() {
let object_info = ObjectInfo { let object_info = ObjectInfo {
+6
View File
@@ -277,6 +277,12 @@ pub struct ObjectOptions {
/// fence avoids recursively acquiring the read lock behind a queued writer. /// fence avoids recursively acquiring the read lock behind a queued writer.
pub bucket_lifecycle_lock_fence: Option<NamespaceLockFence>, pub bucket_lifecycle_lock_fence: Option<NamespaceLockFence>,
pub replication_request: bool, pub replication_request: bool,
/// Source-cluster LWW timestamps carried by an authorized replication
/// request; None when the source never modified the category. Only the
/// replication-authorized options builders may set these.
pub replication_tagging_timestamp: Option<OffsetDateTime>,
pub replication_retention_timestamp: Option<OffsetDateTime>,
pub replication_legalhold_timestamp: Option<OffsetDateTime>,
/// Authorized SSE-C replication passthrough: the body is already /// Authorized SSE-C replication passthrough: the body is already
/// ciphertext, so the write path must not encrypt or compress it and /// ciphertext, so the write path must not encrypt or compress it and
/// stores the restored encryption metadata verbatim. Only the /// stores the restored encryption metadata verbatim. Only the
@@ -23,6 +23,10 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::task::JoinSet; use tokio::task::JoinSet;
#[allow(
dead_code,
reason = "default operation label for the test-only AsyncBatchProcessor::new (backlog#1823)"
)]
const BATCH_PROCESSOR_OPERATION_CUSTOM: &str = "custom"; const BATCH_PROCESSOR_OPERATION_CUSTOM: &str = "custom";
const BATCH_PROCESSOR_OPERATION_READ: &str = "read"; const BATCH_PROCESSOR_OPERATION_READ: &str = "read";
const BATCH_PROCESSOR_OPERATION_WRITE: &str = "write"; const BATCH_PROCESSOR_OPERATION_WRITE: &str = "write";
@@ -211,6 +215,7 @@ pub struct AsyncBatchProcessor {
} }
impl AsyncBatchProcessor { impl AsyncBatchProcessor {
#[allow(dead_code, reason = "constructor used only by this file's tests (backlog#1823)")]
pub fn new(max_concurrent: usize) -> Self { pub fn new(max_concurrent: usize) -> Self {
Self::new_with_operation(max_concurrent, BATCH_PROCESSOR_OPERATION_CUSTOM) Self::new_with_operation(max_concurrent, BATCH_PROCESSOR_OPERATION_CUSTOM)
} }
@@ -26,11 +26,26 @@ use std::sync::atomic::Ordering;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::warn; use tracing::warn;
/// Dead ecstore-side notification skeleton.
///
/// The working notification stack is `rustfs-notify`, whose own `EventNotifier`
/// is the one bucket configuration actually drives. Nothing calls the methods
/// below; `init_bucket_targets` even logs that it is a no-op in this build.
/// Removing it means also retiring the `InstanceContext` slot that holds it
/// (backlog#939 Phase 5), so it is left explicit here rather than half-removed.
#[allow(
dead_code,
reason = "ecstore-side notification skeleton superseded by rustfs-notify; see module note (backlog#1823)"
)]
pub struct EventNotifier { pub struct EventNotifier {
target_list: TargetList, target_list: TargetList,
//bucket_rules_map: HashMap<String , HashMap<EventName, Rules>>, //bucket_rules_map: HashMap<String , HashMap<EventName, Rules>>,
} }
#[allow(
dead_code,
reason = "ecstore-side notification skeleton superseded by rustfs-notify; see module note (backlog#1823)"
)]
impl EventNotifier { impl EventNotifier {
pub fn new() -> Arc<RwLock<Self>> { pub fn new() -> Arc<RwLock<Self>> {
Arc::new(RwLock::new(Self { Arc::new(RwLock::new(Self {
-1
View File
@@ -13,7 +13,6 @@
// limitations under the License. // limitations under the License.
// #730: background service owners still contain staged notification/rebalance/tier paths. // #730: background service owners still contain staged notification/rebalance/tier paths.
#![allow(dead_code)]
pub(crate) mod batch_processor; pub(crate) mod batch_processor;
pub(crate) mod event_notification; pub(crate) mod event_notification;
+153 -70
View File
@@ -44,12 +44,14 @@ const CONSECUTIVE_FAILURE_THRESHOLD: u32 = 3;
const LOG_COMPONENT_ECSTORE: &str = "ecstore"; const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification"; const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification";
const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation"; const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation";
const EVENT_NOTIFICATION_CAPABILITY_PROBE: &str = "notification_capability_probe";
const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5); const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100); const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100);
const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5); const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10); const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5); const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30); const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 1;
/// Cached result from the last successful admin call to a peer. /// Cached result from the last successful admin call to a peer.
struct PeerAdminCache { struct PeerAdminCache {
@@ -95,15 +97,15 @@ lazy_static! {
} }
#[derive(Clone)] #[derive(Clone)]
struct RemoteVersionStateFleetProof { struct FleetCapabilityProof {
topology_fingerprint: String, topology_fingerprint: String,
peer_epochs: Arc<BTreeMap<String, Uuid>>, peer_epochs: Arc<BTreeMap<String, Uuid>>,
expires_at: Instant, expires_at: Instant,
} }
impl RemoteVersionStateFleetProof { impl FleetCapabilityProof {
fn token(&self) -> RemoteVersionStateFleetProofToken { fn token(&self) -> FleetCapabilityProofToken {
RemoteVersionStateFleetProofToken { FleetCapabilityProofToken {
topology_fingerprint: self.topology_fingerprint.clone(), topology_fingerprint: self.topology_fingerprint.clone(),
peer_epochs: self.peer_epochs.clone(), peer_epochs: self.peer_epochs.clone(),
} }
@@ -111,37 +113,41 @@ impl RemoteVersionStateFleetProof {
} }
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
pub(crate) struct RemoteVersionStateFleetProofToken { struct FleetCapabilityProofToken {
topology_fingerprint: String, topology_fingerprint: String,
peer_epochs: Arc<BTreeMap<String, Uuid>>, peer_epochs: Arc<BTreeMap<String, Uuid>>,
} }
#[derive(Default)] #[derive(Default)]
struct RemoteVersionStateFleetProofState { struct FleetCapabilityProofState {
proof: Option<RemoteVersionStateFleetProof>, proof: Option<FleetCapabilityProof>,
topology_conflict: bool, topology_conflict: bool,
} }
static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock<std::sync::RwLock<RemoteVersionStateFleetProofState>> = OnceLock::new(); #[derive(Clone, PartialEq, Eq)]
pub(crate) struct RemoteVersionStateFleetProofToken(FleetCapabilityProofToken);
#[derive(Clone, PartialEq, Eq)]
pub struct CrossPoolFenceFleetProofToken(FleetCapabilityProofToken);
static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
static CROSS_POOL_FENCE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new(); static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new();
fn remote_version_state_fleet_proof_slot() -> &'static std::sync::RwLock<RemoteVersionStateFleetProofState> { fn cross_pool_fence_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
REMOTE_VERSION_STATE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(RemoteVersionStateFleetProofState::default())) CROSS_POOL_FENCE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
} }
fn replace_remote_version_state_fleet_proof(proof: Option<RemoteVersionStateFleetProof>) { fn remote_version_state_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
replace_remote_version_state_fleet_proof_in(remote_version_state_fleet_proof_slot(), proof); REMOTE_VERSION_STATE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
} }
fn replace_remote_version_state_fleet_proof_in( fn replace_fleet_capability_proof(slot: &std::sync::RwLock<FleetCapabilityProofState>, proof: Option<FleetCapabilityProof>) {
slot: &std::sync::RwLock<RemoteVersionStateFleetProofState>,
proof: Option<RemoteVersionStateFleetProof>,
) {
slot.write().unwrap_or_else(std::sync::PoisonError::into_inner).proof = proof; slot.write().unwrap_or_else(std::sync::PoisonError::into_inner).proof = proof;
} }
fn publish_remote_version_state_probe_result( fn publish_fleet_capability_probe_result(
slot: &std::sync::RwLock<RemoteVersionStateFleetProofState>, slot: &std::sync::RwLock<FleetCapabilityProofState>,
topology_fingerprint: &str, topology_fingerprint: &str,
result: Result<BTreeMap<String, Uuid>>, result: Result<BTreeMap<String, Uuid>>,
observed_at: Instant, observed_at: Instant,
@@ -155,7 +161,7 @@ fn publish_remote_version_state_probe_result(
.filter(|proof| proof.topology_fingerprint == topology_fingerprint && proof.peer_epochs.as_ref() == &peer_epochs) .filter(|proof| proof.topology_fingerprint == topology_fingerprint && proof.peer_epochs.as_ref() == &peer_epochs)
.map(|proof| Arc::clone(&proof.peer_epochs)) .map(|proof| Arc::clone(&proof.peer_epochs))
.unwrap_or_else(|| Arc::new(peer_epochs)); .unwrap_or_else(|| Arc::new(peer_epochs));
state.proof = Some(RemoteVersionStateFleetProof { state.proof = Some(FleetCapabilityProof {
topology_fingerprint: topology_fingerprint.to_string(), topology_fingerprint: topology_fingerprint.to_string(),
peer_epochs, peer_epochs,
expires_at: observed_at + REMOTE_VERSION_STATE_PROOF_TTL, expires_at: observed_at + REMOTE_VERSION_STATE_PROOF_TTL,
@@ -163,7 +169,7 @@ fn publish_remote_version_state_probe_result(
None None
} }
Err(err) => { Err(err) => {
replace_remote_version_state_fleet_proof_in(slot, None); replace_fleet_capability_proof(slot, None);
Some(err) Some(err)
} }
} }
@@ -174,27 +180,60 @@ pub(crate) fn acquire_remote_version_state_fleet_proof() -> Option<RemoteVersion
let state = remote_version_state_fleet_proof_slot() let state = remote_version_state_fleet_proof_slot()
.read() .read()
.unwrap_or_else(std::sync::PoisonError::into_inner); .unwrap_or_else(std::sync::PoisonError::into_inner);
acquire_remote_version_state_fleet_proof_from(&state, expected_topology, Instant::now()) acquire_fleet_capability_proof_from(&state, expected_topology, Instant::now()).map(RemoteVersionStateFleetProofToken)
} }
fn acquire_remote_version_state_fleet_proof_from( fn acquire_fleet_capability_proof_from(
state: &RemoteVersionStateFleetProofState, state: &FleetCapabilityProofState,
expected_topology: &str, expected_topology: &str,
now: Instant, now: Instant,
) -> Option<RemoteVersionStateFleetProofToken> { ) -> Option<FleetCapabilityProofToken> {
if state.topology_conflict || !remote_version_state_fleet_proof_valid_at(state.proof.as_ref(), expected_topology, now) { if state.topology_conflict || !fleet_capability_proof_valid_at(state.proof.as_ref(), expected_topology, now) {
return None; return None;
} }
state.proof.as_ref().map(RemoteVersionStateFleetProof::token) state.proof.as_ref().map(FleetCapabilityProof::token)
} }
pub(crate) fn remote_version_state_fleet_proof_matches(proof: &RemoteVersionStateFleetProofToken) -> bool { pub(crate) fn remote_version_state_fleet_proof_matches(proof: &RemoteVersionStateFleetProofToken) -> bool {
fleet_capability_proof_matches(remote_version_state_fleet_proof_slot(), &proof.0)
}
pub fn acquire_cross_pool_fence_fleet_proof() -> Option<CrossPoolFenceFleetProofToken> {
let expected_topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get()?;
let state = cross_pool_fence_fleet_proof_slot()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
acquire_fleet_capability_proof_from(&state, expected_topology, Instant::now()).map(CrossPoolFenceFleetProofToken)
}
pub fn cross_pool_fence_fleet_proof_matches(proof: &CrossPoolFenceFleetProofToken) -> bool {
fleet_capability_proof_matches(cross_pool_fence_fleet_proof_slot(), &proof.0)
}
#[cfg(any(test, feature = "test-util"))]
pub fn rotate_cross_pool_fence_fleet_proof_for_test() -> bool {
let mut state = cross_pool_fence_fleet_proof_slot()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(current) = state.proof.as_ref() else {
return false;
};
state.proof = Some(FleetCapabilityProof {
topology_fingerprint: current.topology_fingerprint.clone(),
peer_epochs: Arc::new(current.peer_epochs.as_ref().clone()),
expires_at: current.expires_at,
});
true
}
fn fleet_capability_proof_matches(
slot: &std::sync::RwLock<FleetCapabilityProofState>,
proof: &FleetCapabilityProofToken,
) -> bool {
let Some(expected_topology) = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() else { let Some(expected_topology) = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() else {
return false; return false;
}; };
let state = remote_version_state_fleet_proof_slot() let state = slot.read().unwrap_or_else(std::sync::PoisonError::into_inner);
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.topology_conflict { if state.topology_conflict {
return false; return false;
} }
@@ -206,13 +245,17 @@ pub(crate) fn remote_version_state_fleet_proof_matches(proof: &RemoteVersionStat
}) })
} }
fn fleet_capability_proof_valid_at(proof: Option<&FleetCapabilityProof>, expected_topology: &str, now: Instant) -> bool {
proof.is_some_and(|proof| proof.topology_fingerprint == expected_topology && now < proof.expires_at)
}
#[cfg(test)] #[cfg(test)]
pub(crate) struct RemoteVersionStateFleetProofGuard; pub(crate) struct RemoteVersionStateFleetProofGuard;
#[cfg(test)] #[cfg(test)]
impl Drop for RemoteVersionStateFleetProofGuard { impl Drop for RemoteVersionStateFleetProofGuard {
fn drop(&mut self) { fn drop(&mut self) {
replace_remote_version_state_fleet_proof(None); replace_fleet_capability_proof(remote_version_state_fleet_proof_slot(), None);
} }
} }
@@ -227,7 +270,7 @@ pub(crate) fn install_remote_version_state_fleet_proof_for_test(topology_fingerp
Err(_) => panic!("remote version state test topology is already bound to another fingerprint"), Err(_) => panic!("remote version state test topology is already bound to another fingerprint"),
} }
let peer_epochs = BTreeMap::new(); let peer_epochs = BTreeMap::new();
if let Some(err) = publish_remote_version_state_probe_result( if let Some(err) = publish_fleet_capability_probe_result(
remote_version_state_fleet_proof_slot(), remote_version_state_fleet_proof_slot(),
topology_fingerprint, topology_fingerprint,
Ok(peer_epochs), Ok(peer_epochs),
@@ -238,14 +281,6 @@ pub(crate) fn install_remote_version_state_fleet_proof_for_test(topology_fingerp
RemoteVersionStateFleetProofGuard RemoteVersionStateFleetProofGuard
} }
fn remote_version_state_fleet_proof_valid_at(
proof: Option<&RemoteVersionStateFleetProof>,
expected_topology: &str,
now: Instant,
) -> bool {
proof.is_some_and(|proof| proof.topology_fingerprint == expected_topology && now < proof.expires_at)
}
fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, peer: String, epoch: Uuid) -> Result<()> { fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, peer: String, epoch: Uuid) -> Result<()> {
if epoch.is_nil() || peer_epochs.values().any(|existing| *existing == epoch) || peer_epochs.insert(peer, epoch).is_some() { if epoch.is_nil() || peer_epochs.values().any(|existing| *existing == epoch) || peer_epochs.insert(peer, epoch).is_some() {
return Err(Error::other("remote version state capability peer identity is invalid")); return Err(Error::other("remote version state capability peer identity is invalid"));
@@ -256,11 +291,11 @@ fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, pe
pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) { pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.clone()).is_err() { if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.clone()).is_err() {
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() != Some(&topology_fingerprint) { if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() != Some(&topology_fingerprint) {
let mut state = remote_version_state_fleet_proof_slot() for slot in [remote_version_state_fleet_proof_slot(), cross_pool_fence_fleet_proof_slot()] {
.write() let mut state = slot.write().unwrap_or_else(std::sync::PoisonError::into_inner);
.unwrap_or_else(std::sync::PoisonError::into_inner); state.topology_conflict = true;
state.topology_conflict = true; state.proof = None;
state.proof = None; }
} }
return; return;
} }
@@ -281,13 +316,23 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
} }
None => Err(Error::other("remote version state fleet capability notification system is unavailable")), None => Err(Error::other("remote version state fleet capability notification system is unavailable")),
}; };
let fence_result = match get_global_notification_sys() {
Some(notification_sys) => timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_cross_pool_fence_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("cross-pool fence fleet capability probe timed out"))),
None => Err(Error::other("cross-pool fence fleet capability notification system is unavailable")),
};
let topology_conflict = remote_version_state_fleet_proof_slot() let topology_conflict = remote_version_state_fleet_proof_slot()
.read() .read()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
.topology_conflict; .topology_conflict;
if topology_conflict { if topology_conflict {
replace_remote_version_state_fleet_proof(None); replace_fleet_capability_proof(remote_version_state_fleet_proof_slot(), None);
} else if let Some(err) = publish_remote_version_state_probe_result( replace_fleet_capability_proof(cross_pool_fence_fleet_proof_slot(), None);
} else if let Some(err) = publish_fleet_capability_probe_result(
remote_version_state_fleet_proof_slot(), remote_version_state_fleet_proof_slot(),
&topology_fingerprint, &topology_fingerprint,
result, result,
@@ -295,6 +340,24 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
) { ) {
debug!(error = %err, "remote version state fleet capability probe failed closed"); debug!(error = %err, "remote version state fleet capability probe failed closed");
} }
if !topology_conflict
&& let Some(err) = publish_fleet_capability_probe_result(
cross_pool_fence_fleet_proof_slot(),
&topology_fingerprint,
fence_result,
Instant::now(),
)
{
debug!(
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
capability = "cross_pool_fence_v1",
state = "failed_closed",
error = %err,
"notification capability probe"
);
}
sleep(REMOTE_VERSION_STATE_PROBE_INTERVAL).await; sleep(REMOTE_VERSION_STATE_PROBE_INTERVAL).await;
} }
}); });
@@ -362,6 +425,27 @@ impl NotificationSys {
} }
Ok(peer_epochs) Ok(peer_epochs)
} }
async fn probe_cross_pool_fence_fleet(&self, topology_fingerprint: &str) -> Result<BTreeMap<String, Uuid>> {
if self.peer_clients.len() != self.peer_topology_hosts.len() {
return Err(Error::other("cross-pool fence capability fleet membership is incomplete"));
}
let probes = self.peer_clients.iter().map(|client| async {
let client = client
.as_ref()
.ok_or_else(|| Error::other("cross-pool fence capability peer is unreachable"))?;
client.probe_cross_pool_fence(topology_fingerprint.to_string()).await
});
let mut peer_epochs = BTreeMap::new();
for result in join_all(probes).await {
let (peer, version, epoch) = result?;
if version < CROSS_POOL_FENCE_SUPPORTED_VERSION {
return Err(Error::other("cross-pool fence capability version is unsupported"));
}
insert_remote_version_state_peer(&mut peer_epochs, peer, epoch)?;
}
Ok(peer_epochs)
}
} }
pub struct NotificationPeerErr { pub struct NotificationPeerErr {
@@ -1539,6 +1623,7 @@ impl NotificationSys {
workers.peers.remove(host); workers.peers.remove(host);
} }
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
fn tier_config_reload_worker_active(&self, host: &str) -> bool { fn tier_config_reload_worker_active(&self, host: &str) -> bool {
self.tier_config_reload_workers self.tier_config_reload_workers
.lock() .lock()
@@ -1712,6 +1797,7 @@ where
.map_err(|_| Error::other(format!("scanner activity peer {host} timed out after {timeout_duration:?}")))? .map_err(|_| Error::other(format!("scanner activity peer {host} timed out after {timeout_duration:?}")))?
} }
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
async fn call_peer_with_timeout<F, Fut>( async fn call_peer_with_timeout<F, Fut>(
timeout_dur: Duration, timeout_dur: Duration,
host_label: &str, host_label: &str,
@@ -2177,16 +2263,16 @@ mod tests {
let now = Instant::now(); let now = Instant::now();
let mut peer_epochs = BTreeMap::new(); let mut peer_epochs = BTreeMap::new();
peer_epochs.insert("peer-a".to_string(), Uuid::new_v4()); peer_epochs.insert("peer-a".to_string(), Uuid::new_v4());
let proof = RemoteVersionStateFleetProof { let proof = FleetCapabilityProof {
topology_fingerprint: "topology-a".to_string(), topology_fingerprint: "topology-a".to_string(),
peer_epochs: Arc::new(peer_epochs), peer_epochs: Arc::new(peer_epochs),
expires_at: now + Duration::from_secs(1), expires_at: now + Duration::from_secs(1),
}; };
assert!(remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", now)); assert!(fleet_capability_proof_valid_at(Some(&proof), "topology-a", now));
assert!(!remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-b", now)); assert!(!fleet_capability_proof_valid_at(Some(&proof), "topology-b", now));
assert!(!remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", proof.expires_at)); assert!(!fleet_capability_proof_valid_at(Some(&proof), "topology-a", proof.expires_at));
assert!(!remote_version_state_fleet_proof_valid_at(None, "topology-a", now)); assert!(!fleet_capability_proof_valid_at(None, "topology-a", now));
} }
#[test] #[test]
@@ -2200,25 +2286,25 @@ mod tests {
#[test] #[test]
fn remote_version_state_fleet_proof_accepts_single_node_membership() { fn remote_version_state_fleet_proof_accepts_single_node_membership() {
let now = Instant::now(); let now = Instant::now();
let proof = RemoteVersionStateFleetProof { let proof = FleetCapabilityProof {
topology_fingerprint: "topology-a".to_string(), topology_fingerprint: "topology-a".to_string(),
peer_epochs: Arc::new(BTreeMap::new()), peer_epochs: Arc::new(BTreeMap::new()),
expires_at: now + Duration::from_secs(1), expires_at: now + Duration::from_secs(1),
}; };
assert!(remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", now)); assert!(fleet_capability_proof_valid_at(Some(&proof), "topology-a", now));
} }
#[test] #[test]
fn remote_version_state_fleet_proof_token_changes_with_process_epoch() { fn remote_version_state_fleet_proof_token_changes_with_process_epoch() {
let now = Instant::now(); let now = Instant::now();
let proof = RemoteVersionStateFleetProof { let proof = FleetCapabilityProof {
topology_fingerprint: "topology-a".to_string(), topology_fingerprint: "topology-a".to_string(),
peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])), peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])),
expires_at: now + Duration::from_secs(1), expires_at: now + Duration::from_secs(1),
}; };
let captured = proof.token(); let captured = proof.token();
let restarted = RemoteVersionStateFleetProof { let restarted = FleetCapabilityProof {
topology_fingerprint: proof.topology_fingerprint.clone(), topology_fingerprint: proof.topology_fingerprint.clone(),
peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])), peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])),
expires_at: proof.expires_at, expires_at: proof.expires_at,
@@ -2229,11 +2315,11 @@ mod tests {
#[test] #[test]
fn remote_version_state_fleet_proof_renewal_preserves_only_same_epoch_token() { fn remote_version_state_fleet_proof_renewal_preserves_only_same_epoch_token() {
let slot = std::sync::RwLock::new(RemoteVersionStateFleetProofState::default()); let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
let now = Instant::now(); let now = Instant::now();
let epoch = Uuid::new_v4(); let epoch = Uuid::new_v4();
let peers = BTreeMap::from([("peer-a".to_string(), epoch)]); let peers = BTreeMap::from([("peer-a".to_string(), epoch)]);
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peers.clone()), now).is_none()); assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peers.clone()), now).is_none());
let original = slot let original = slot
.read() .read()
.expect("proof slot should not poison") .expect("proof slot should not poison")
@@ -2242,9 +2328,7 @@ mod tests {
.expect("successful probe should publish proof") .expect("successful probe should publish proof")
.token(); .token();
assert!( assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peers), now + Duration::from_millis(1)).is_none());
publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peers), now + Duration::from_millis(1)).is_none()
);
let renewed = slot let renewed = slot
.read() .read()
.expect("proof slot should not poison") .expect("proof slot should not poison")
@@ -2256,8 +2340,7 @@ mod tests {
let restarted = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]); let restarted = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
assert!( assert!(
publish_remote_version_state_probe_result(&slot, "topology-a", Ok(restarted), now + Duration::from_millis(2)) publish_fleet_capability_probe_result(&slot, "topology-a", Ok(restarted), now + Duration::from_millis(2)).is_none()
.is_none()
); );
let replaced = slot let replaced = slot
.read() .read()
@@ -2272,18 +2355,18 @@ mod tests {
#[test] #[test]
fn remote_version_state_fleet_proof_conflict_revokes_atomic_snapshot() { fn remote_version_state_fleet_proof_conflict_revokes_atomic_snapshot() {
let now = Instant::now(); let now = Instant::now();
let mut state = RemoteVersionStateFleetProofState { let mut state = FleetCapabilityProofState {
proof: Some(RemoteVersionStateFleetProof { proof: Some(FleetCapabilityProof {
topology_fingerprint: "topology-a".to_string(), topology_fingerprint: "topology-a".to_string(),
peer_epochs: Arc::new(BTreeMap::new()), peer_epochs: Arc::new(BTreeMap::new()),
expires_at: now + Duration::from_secs(1), expires_at: now + Duration::from_secs(1),
}), }),
topology_conflict: false, topology_conflict: false,
}; };
assert!(acquire_remote_version_state_fleet_proof_from(&state, "topology-a", now).is_some()); assert!(acquire_fleet_capability_proof_from(&state, "topology-a", now).is_some());
state.topology_conflict = true; state.topology_conflict = true;
assert!(acquire_remote_version_state_fleet_proof_from(&state, "topology-a", now).is_none()); assert!(acquire_fleet_capability_proof_from(&state, "topology-a", now).is_none());
} }
#[test] #[test]
@@ -2299,19 +2382,19 @@ mod tests {
#[test] #[test]
fn remote_version_state_fleet_probe_failure_revokes_previous_proof() { fn remote_version_state_fleet_probe_failure_revokes_previous_proof() {
let slot = std::sync::RwLock::new(RemoteVersionStateFleetProofState::default()); let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
let now = Instant::now(); let now = Instant::now();
let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]); let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]);
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none()); assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
assert!(slot.read().expect("proof slot should not poison").proof.is_some()); assert!(slot.read().expect("proof slot should not poison").proof.is_some());
assert!( assert!(
publish_remote_version_state_probe_result(&slot, "topology-a", Err(Error::other("peer unavailable")), now,).is_some() publish_fleet_capability_probe_result(&slot, "topology-a", Err(Error::other("peer unavailable")), now,).is_some()
); );
assert!(slot.read().expect("proof slot should not poison").proof.is_none()); assert!(slot.read().expect("proof slot should not poison").proof.is_none());
let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]); let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]);
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none()); assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
assert!(slot.read().expect("proof slot should not poison").proof.is_some()); assert!(slot.read().expect("proof slot should not poison").proof.is_some());
} }
@@ -864,6 +864,10 @@ pub(super) fn merge_rebalance_meta(remote: &mut RebalanceMeta, local: &Rebalance
RebalanceMetaMergeOutcome::Merged RebalanceMetaMergeOutcome::Merged
} }
#[allow(
dead_code,
reason = "stop-transition helper retained beside stop_rebalance_meta_snapshot; no caller yet (backlog#1823)"
)]
pub(super) fn mark_started_rebalance_pools_stopped(meta: &mut RebalanceMeta, stop_time: OffsetDateTime) { pub(super) fn mark_started_rebalance_pools_stopped(meta: &mut RebalanceMeta, stop_time: OffsetDateTime) {
for pool_stat in meta.pool_stats.iter_mut() { for pool_stat in meta.pool_stats.iter_mut() {
if pool_stat.info.status == RebalStatus::Started { if pool_stat.info.status == RebalStatus::Started {
@@ -964,6 +968,7 @@ pub(super) fn rollback_rebalance_start_meta_snapshot_for_id(
}) })
} }
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
pub(super) fn stop_rebalance_meta_snapshot(meta: Option<&mut RebalanceMeta>, now: OffsetDateTime) -> Option<RebalanceMeta> { pub(super) fn stop_rebalance_meta_snapshot(meta: Option<&mut RebalanceMeta>, now: OffsetDateTime) -> Option<RebalanceMeta> {
let meta = meta?; let meta = meta?;
stop_rebalance_state(meta, now); stop_rebalance_state(meta, now);
@@ -171,6 +171,7 @@ where
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
pub(super) async fn migrate_entry_version_with_retry_wait<Backend, F, Fut, D, DFut, W, WFut>( pub(super) async fn migrate_entry_version_with_retry_wait<Backend, F, Fut, D, DFut, W, WFut>(
set: &Backend, set: &Backend,
bucket: String, bucket: String,
@@ -1,5 +1,4 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -32,8 +31,6 @@ pub struct RebalanceStats {
pub cleanup_warnings: RebalanceCleanupWarnings, pub cleanup_warnings: RebalanceCleanupWarnings,
} }
pub type RStats = Vec<Arc<RebalanceStats>>;
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub(super) struct RebalanceBucketConfigs { pub(super) struct RebalanceBucketConfigs {
pub(super) bucket_incarnation_id: Option<uuid::Uuid>, pub(super) bucket_incarnation_id: Option<uuid::Uuid>,
-1
View File
@@ -30,6 +30,5 @@ pub mod warm_backend_minio;
pub mod warm_backend_r2; pub mod warm_backend_r2;
pub mod warm_backend_rustfs; pub mod warm_backend_rustfs;
pub mod warm_backend_s3; pub mod warm_backend_s3;
pub mod warm_backend_s3sdk;
pub mod warm_backend_tencent; pub mod warm_backend_tencent;
pub mod warm_backend_wasabi; pub mod warm_backend_wasabi;
+10 -20
View File
@@ -488,6 +488,7 @@ impl TierCandidateMutation {
targets targets
} }
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
fn affected_targets( fn affected_targets(
&self, &self,
manager: &TierConfigMgr, manager: &TierConfigMgr,
@@ -802,6 +803,7 @@ fn tier_persisted_reference_blocks_any_target(
.any(|target| tier_persisted_reference_blocks_target(tier_name, backend_identity, target)) .any(|target| tier_persisted_reference_blocks_target(tier_name, backend_identity, target))
} }
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
fn tier_object_blocks_target_rebind(object: &ObjectInfo, target: &TierMutationIntentTarget) -> io::Result<bool> { fn tier_object_blocks_target_rebind(object: &ObjectInfo, target: &TierMutationIntentTarget) -> io::Result<bool> {
tier_object_blocks_any_target_rebind(object, std::slice::from_ref(target)) tier_object_blocks_any_target_rebind(object, std::slice::from_ref(target))
} }
@@ -2726,14 +2728,6 @@ impl TierConfigMgr {
Self::publish_candidate_owned(handle, candidate, driver_tier.map(str::to_string), update).await Self::publish_candidate_owned(handle, candidate, driver_tier.map(str::to_string), update).await
} }
fn begin_publish_transition(
handle: &Arc<RwLock<Self>>,
manager: &mut Self,
candidate: &Self,
) -> std::result::Result<TierPublishTransition, AdminError> {
Self::begin_publish_transition_with_allowed_mutation_blocks(handle, manager, candidate, None)
}
fn begin_publish_transition_with_allowed_mutation_blocks( fn begin_publish_transition_with_allowed_mutation_blocks(
handle: &Arc<RwLock<Self>>, handle: &Arc<RwLock<Self>>,
manager: &mut Self, manager: &mut Self,
@@ -2819,14 +2813,6 @@ impl TierConfigMgr {
}) })
} }
async fn publish_candidate_inner(
handle: &Arc<RwLock<Self>>,
candidate: Self,
driver_tier: Option<&str>,
) -> std::result::Result<(), AdminError> {
Self::publish_candidate_inner_with_allowed_mutation_blocks(handle, candidate, driver_tier, None).await
}
async fn publish_candidate_inner_with_allowed_mutation_blocks( async fn publish_candidate_inner_with_allowed_mutation_blocks(
handle: &Arc<RwLock<Self>>, handle: &Arc<RwLock<Self>>,
candidate: Self, candidate: Self,
@@ -2939,6 +2925,7 @@ impl TierConfigMgr {
admin_err admin_err
} }
#[allow(dead_code, reason = "reached only through #[cfg(test)] helpers in this file (backlog#1823)")]
async fn publish_candidate_owned( async fn publish_candidate_owned(
handle: &Arc<RwLock<Self>>, handle: &Arc<RwLock<Self>>,
candidate: Self, candidate: Self,
@@ -3541,6 +3528,7 @@ impl TierConfigMgr {
Self::update_candidate_with_config_lock(handle, api, TierCandidateMutation::Remove(tier_name.to_string(), force)).await Self::update_candidate_with_config_lock(handle, api, TierCandidateMutation::Remove(tier_name.to_string(), force)).await
} }
#[allow(dead_code, reason = "reached only through #[cfg(test)] helpers in this file (backlog#1823)")]
async fn remove_and_save_with<S>( async fn remove_and_save_with<S>(
handle: &Arc<RwLock<Self>>, handle: &Arc<RwLock<Self>>,
api: Arc<S>, api: Arc<S>,
@@ -3574,6 +3562,7 @@ impl TierConfigMgr {
Self::update_candidate_with_config_lock(handle, api, TierCandidateMutation::Clear(force)).await Self::update_candidate_with_config_lock(handle, api, TierCandidateMutation::Clear(force)).await
} }
#[allow(dead_code, reason = "reached only through #[cfg(test)] helpers in this file (backlog#1823)")]
async fn clear_and_save_with<S>( async fn clear_and_save_with<S>(
handle: &Arc<RwLock<Self>>, handle: &Arc<RwLock<Self>>,
api: Arc<S>, api: Arc<S>,
@@ -3612,6 +3601,10 @@ impl TierConfigMgr {
} }
#[cfg(test)] #[cfg(test)]
#[allow(
dead_code,
reason = "lease accounting asserted by a bucket_lifecycle_ops test behind `--features test-util` (backlog#1823)"
)]
pub(crate) async fn active_operation_lease_count(handle: &Arc<RwLock<Self>>, tier_name: &str) -> usize { pub(crate) async fn active_operation_lease_count(handle: &Arc<RwLock<Self>>, tier_name: &str) -> usize {
let manager = handle.read().await; let manager = handle.read().await;
let Some(runtime) = registered_tier_driver_runtime(&manager) else { let Some(runtime) = registered_tier_driver_runtime(&manager) else {
@@ -3717,10 +3710,6 @@ impl TierConfigMgr {
Ok(()) Ok(())
} }
fn retire_driver(&mut self, tier_name: &str) {
self.revoke_driver(tier_name);
}
fn revoke_all_drivers(&mut self) { fn revoke_all_drivers(&mut self) {
if let Some(runtime) = registered_tier_driver_runtime(self) { if let Some(runtime) = registered_tier_driver_runtime(self) {
let mut runtime = lock_unpoisoned(&runtime); let mut runtime = lock_unpoisoned(&runtime);
@@ -3884,6 +3873,7 @@ impl TierConfigMgr {
self.save_config(api, &config_file, data).await self.save_config(api, &config_file, data).await
} }
#[allow(dead_code, reason = "reached only through #[cfg(test)] helpers in this file (backlog#1823)")]
async fn save_tiering_config_if_current<S>( async fn save_tiering_config_if_current<S>(
&self, &self,
api: Arc<S>, api: Arc<S>,
@@ -305,6 +305,10 @@ impl TierMutationIntent {
} }
} }
#[allow(
dead_code,
reason = "intent-record persistence asserted by store::init tests (backlog#1823)"
)]
pub(crate) fn tier_mutation_intent_record_object_name(mutation_id: Uuid) -> Result<String> { pub(crate) fn tier_mutation_intent_record_object_name(mutation_id: Uuid) -> Result<String> {
tier_mutation_intent_record_object_name_with_prefix(TIER_MUTATION_INTENT_RECORD_PREFIX, mutation_id) tier_mutation_intent_record_object_name_with_prefix(TIER_MUTATION_INTENT_RECORD_PREFIX, mutation_id)
} }
@@ -317,6 +321,10 @@ fn tier_mutation_intent_record_object_name_with_prefix(prefix: &str, mutation_id
Ok(format!("{}/{}/{}/{}.json", prefix, &mutation_key[..2], &mutation_key[2..4], mutation_key)) Ok(format!("{}/{}/{}/{}.json", prefix, &mutation_key[..2], &mutation_key[2..4], mutation_key))
} }
#[allow(
dead_code,
reason = "intent-record persistence asserted by store::init tests (backlog#1823)"
)]
pub(crate) fn tier_mutation_intent_id_from_record_object_name(object: &str) -> Result<Uuid> { pub(crate) fn tier_mutation_intent_id_from_record_object_name(object: &str) -> Result<Uuid> {
tier_mutation_intent_id_from_record_object_name_with_prefix(TIER_MUTATION_INTENT_RECORD_PREFIX, object) tier_mutation_intent_id_from_record_object_name_with_prefix(TIER_MUTATION_INTENT_RECORD_PREFIX, object)
} }
@@ -355,6 +363,10 @@ fn tier_mutation_intent_id_from_record_object_name_with_prefix(prefix: &str, obj
Uuid::parse_str(mutation_key).map_err(|_| TierMutationIntentError::Corrupt("intent record path has invalid uuid")) Uuid::parse_str(mutation_key).map_err(|_| TierMutationIntentError::Corrupt("intent record path has invalid uuid"))
} }
#[allow(
dead_code,
reason = "intent-record persistence asserted by store::init tests (backlog#1823)"
)]
pub(crate) async fn save_tier_mutation_intent_record<S>(api: Arc<S>, intent: &TierMutationIntent) -> EcstoreResult<()> pub(crate) async fn save_tier_mutation_intent_record<S>(api: Arc<S>, intent: &TierMutationIntent) -> EcstoreResult<()>
where where
S: EcstoreObjectIO, S: EcstoreObjectIO,
@@ -446,6 +458,10 @@ where
Ok((intent, etag)) Ok((intent, etag))
} }
#[allow(
dead_code,
reason = "intent-record persistence asserted by store::init tests (backlog#1823)"
)]
pub(crate) async fn save_tier_mutation_intent_record_if_current<S>( pub(crate) async fn save_tier_mutation_intent_record_if_current<S>(
api: Arc<S>, api: Arc<S>,
intent: &TierMutationIntent, intent: &TierMutationIntent,
@@ -41,10 +41,7 @@ use crate::services::tier::{
}; };
use tracing::warn; use tracing::warn;
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
const MAX_PARTS_COUNT: i64 = 10000;
const _MAX_PART_SIZE: i64 = 1024 * 1024 * 1024 * 5; const _MAX_PART_SIZE: i64 = 1024 * 1024 * 1024 * 5;
const MIN_PART_SIZE: i64 = 1024 * 1024 * 128;
fn parse_generation(remote_version: &str) -> Result<Option<i64>, Error> { fn parse_generation(remote_version: &str) -> Result<Option<i64>, Error> {
if remote_version.is_empty() { if remote_version.is_empty() {
@@ -64,7 +61,6 @@ pub struct WarmBackendGCS {
pub control: Arc<StorageControl>, pub control: Arc<StorageControl>,
pub bucket: String, pub bucket: String,
pub prefix: String, pub prefix: String,
pub storage_class: String,
} }
impl WarmBackendGCS { impl WarmBackendGCS {
@@ -104,7 +100,6 @@ impl WarmBackendGCS {
control, control,
bucket: conf.bucket.clone(), bucket: conf.bucket.clone(),
prefix: conf.prefix.strip_suffix("/").unwrap_or(&conf.prefix).to_owned(), prefix: conf.prefix.strip_suffix("/").unwrap_or(&conf.prefix).to_owned(),
storage_class: "".to_string(),
}) })
} }
@@ -33,8 +33,6 @@ use crate::client::{
transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore}, transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore},
transition_api::{ReadCloser, ReaderImpl}, transition_api::{ReadCloser, ReaderImpl},
}; };
use crate::error::ErrorResponse;
use crate::error::error_resp_to_object_err;
use crate::services::tier::{ use crate::services::tier::{
tier_config::TierS3, tier_config::TierS3,
warm_backend::{ warm_backend::{
@@ -1,200 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(unused_mut)]
#![allow(unused_assignments)]
#![allow(unused_must_use)]
#![allow(clippy::all)]
use std::collections::HashMap;
use std::sync::Arc;
use url::Url;
use aws_config::meta::region::RegionProviderChain;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::primitives::ByteStream;
use crate::client::{
api_get_options::GetObjectOptions,
api_put_object::PutObjectOptions,
api_remove::RemoveObjectOptions,
transition_api::{ReadCloser, ReaderImpl},
};
use crate::error::ErrorResponse;
use crate::error::error_resp_to_object_err;
use crate::services::tier::{
tier_config::TierS3,
warm_backend::{WarmBackend, WarmBackendGetOpts},
};
pub struct WarmBackendS3 {
pub client: Arc<Client>,
pub bucket: String,
pub prefix: String,
pub storage_class: String,
}
impl WarmBackendS3 {
pub async fn new(conf: &TierS3, tier: &str) -> Result<Self, std::io::Error> {
let u = match Url::parse(&conf.endpoint) {
Ok(u) => u,
Err(err) => {
return Err(std::io::Error::other(err.to_string()));
}
};
if conf.aws_role_web_identity_token_file == "" && conf.aws_role_arn != ""
|| conf.aws_role_web_identity_token_file != "" && conf.aws_role_arn == ""
{
return Err(std::io::Error::other("both the token file and the role ARN are required"));
} else if conf.access_key == "" && conf.secret_key != "" || conf.access_key != "" && conf.secret_key == "" {
return Err(std::io::Error::other("both the access and secret keys are required"));
} else if conf.aws_role
&& (conf.aws_role_web_identity_token_file != ""
|| conf.aws_role_arn != ""
|| conf.access_key != ""
|| conf.secret_key != "")
{
return Err(std::io::Error::other(
"AWS Role cannot be activated with static credentials or the web identity token file",
));
} else if conf.bucket == "" {
return Err(std::io::Error::other("no bucket name was provided"));
}
let creds;
if conf.access_key != "" && conf.secret_key != "" {
creds = Credentials::new(
conf.access_key.clone(), // access_key_id
conf.secret_key.clone(), // secret_access_key
None, // session_token (optional)
None,
"Static",
);
} else {
return Err(std::io::Error::other("insufficient parameters for S3 backend authentication"));
}
let region_provider = RegionProviderChain::default_provider().or_else(Region::new(conf.region.clone()));
#[allow(deprecated)]
let config = aws_config::from_env()
.endpoint_url(conf.endpoint.clone())
.region(region_provider)
.credentials_provider(creds)
.load()
.await;
let client = Client::new(&config);
let client = Arc::new(client);
Ok(Self {
client,
bucket: conf.bucket.clone(),
prefix: conf.prefix.clone().trim_matches('/').to_string(),
storage_class: conf.storage_class.clone(),
})
}
pub fn get_dest(&self, object: &str) -> String {
let mut dest_obj = object.to_string();
if self.prefix != "" {
dest_obj = format!("{}/{}", &self.prefix, object);
}
return dest_obj;
}
}
#[async_trait::async_trait]
impl WarmBackend for WarmBackendS3 {
async fn put_with_meta(
&self,
object: &str,
r: ReaderImpl,
length: i64,
meta: HashMap<String, String>,
) -> Result<String, std::io::Error> {
let client = self.client.clone();
let Ok(res) = client
.put_object()
.bucket(&self.bucket)
.key(&self.get_dest(object))
.body(match r {
ReaderImpl::Body(content_body) => ByteStream::from(content_body.to_vec()),
ReaderImpl::ObjectBody(mut content_body) => ByteStream::from(content_body.read_all().await?),
})
.send()
.await
else {
return Err(std::io::Error::other("put_object error"));
};
Ok(res.version_id().unwrap_or("").to_string())
}
async fn put(&self, object: &str, r: ReaderImpl, length: i64) -> Result<String, std::io::Error> {
self.put_with_meta(object, r, length, HashMap::new()).await
}
async fn get(&self, object: &str, rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
let client = self.client.clone();
let mut req = client.get_object().bucket(&self.bucket).key(&self.get_dest(object));
if !rv.is_empty() {
req = req.version_id(rv);
}
if opts.start_offset >= 0 && opts.length > 0 {
let end = opts
.start_offset
.checked_add(opts.length)
.and_then(|v| v.checked_sub(1))
.ok_or_else(|| std::io::Error::other("invalid range: overflow"))?;
req = req.range(format!("bytes={}-{}", opts.start_offset, end));
}
let res = req.send().await.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(ReadCloser::new(std::io::Cursor::new(
res.body.collect().await.map(|data| data.into_bytes().to_vec())?,
)))
}
async fn remove(&self, object: &str, rv: &str) -> Result<(), std::io::Error> {
let client = self.client.clone();
let mut req = client.delete_object().bucket(&self.bucket).key(&self.get_dest(object));
if !rv.is_empty() {
req = req.version_id(rv);
}
req.send().await.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(())
}
async fn in_use(&self) -> Result<bool, std::io::Error> {
let client = self.client.clone();
let Ok(res) = client
.list_objects_v2()
.bucket(&self.bucket)
//.max_keys(10)
//.into_paginator()
.send()
.await
else {
return Err(std::io::Error::other("list_objects_v2 error"));
};
Ok(res.common_prefixes.unwrap_or_default().len() > 0 || res.contents.unwrap_or_default().len() > 0)
}
}
+589 -72
View File
@@ -32,20 +32,28 @@ use crate::diagnostics::get::{
GET_METADATA_CACHE_REASON_NOT_READ_DATA, GET_METADATA_CACHE_REASON_PART_NUMBER, GET_METADATA_CACHE_REASON_NOT_READ_DATA, GET_METADATA_CACHE_REASON_PART_NUMBER,
GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, GET_METADATA_CACHE_REASON_USABLE, GET_METADATA_CACHE_REASON_VERSION_ID, GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, GET_METADATA_CACHE_REASON_USABLE, GET_METADATA_CACHE_REASON_VERSION_ID,
GET_METADATA_CACHE_REASON_VERSION_SUSPENDED, GET_METADATA_CACHE_REASON_VERSIONED, GET_METADATA_CACHE_REASON_VERSION_SUSPENDED, GET_METADATA_CACHE_REASON_VERSIONED,
GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER, GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY,
GET_METADATA_EARLY_STOP_REASON_ERROR, GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY,
GET_METADATA_EARLY_STOP_REASON_NOT_FOUND, GET_METADATA_EARLY_STOP_REASON_UNSAFE_REQUEST, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH,
GET_METADATA_EARLY_STOP_REASON_VALID_QUORUM, GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD,
GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND, GET_METADATA_RESPONSE_CORRUPT, GET_METADATA_RESPONSE_DISK_NOT_FOUND, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE,
GET_METADATA_RESPONSE_ERROR, GET_METADATA_RESPONSE_IGNORED, GET_METADATA_RESPONSE_NOT_FOUND, GET_METADATA_RESPONSE_TIMEOUT, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE,
GET_METADATA_RESPONSE_VALID, GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_CODEC_STREAMING, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED,
GET_OBJECT_PATH_DIRECT_MEMORY, GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_SET_DISK, GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER, GET_METADATA_EARLY_STOP_REASON_ERROR,
GET_STAGE_DECODE, GET_STAGE_METADATA_CACHE_LOOKUP, GET_STAGE_METADATA_RESOLVE, GET_STAGE_RANGE, GET_STAGE_READER_SETUP, GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM, GET_METADATA_EARLY_STOP_REASON_NOT_FOUND,
GET_METADATA_EARLY_STOP_REASON_UNSAFE_REQUEST, GET_METADATA_EARLY_STOP_REASON_VALID_QUORUM,
GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM, GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND,
GET_METADATA_RESPONSE_CORRUPT, GET_METADATA_RESPONSE_DISK_NOT_FOUND, GET_METADATA_RESPONSE_ERROR,
GET_METADATA_RESPONSE_IGNORED, GET_METADATA_RESPONSE_NOT_FOUND, GET_METADATA_RESPONSE_TIMEOUT, GET_METADATA_RESPONSE_VALID,
GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_CODEC_STREAMING, GET_OBJECT_PATH_DIRECT_MEMORY,
GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE,
GET_STAGE_METADATA_CACHE_LOOKUP, GET_STAGE_METADATA_RESOLVE, GET_STAGE_RANGE, GET_STAGE_READER_SETUP,
GET_STAGE_READER_SETUP_DROP_PENDING, GET_STAGE_READER_SETUP_SCHEDULE, GET_STAGE_READER_SETUP_WAIT_QUORUM, GET_STAGE_READER_SETUP_DROP_PENDING, GET_STAGE_READER_SETUP_SCHEDULE, GET_STAGE_READER_SETUP_WAIT_QUORUM,
GET_STAGE_READER_TASK_BITROT_READER_INIT, GET_STAGE_READER_TASK_FILE_OPEN, GET_STAGE_READER_TASK_READER_CONSTRUCTION, GET_STAGE_READER_TASK_BITROT_READER_INIT, GET_STAGE_READER_TASK_FILE_OPEN, GET_STAGE_READER_TASK_READER_CONSTRUCTION,
GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure, GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure,
record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled, record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled,
}; };
use crate::disk::disk_store::DiskStoreRenameDataExt;
use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX; use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX;
use crate::disk::{ use crate::disk::{
DataDirDeleteStatus, OldCurrentSize, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, DataDirDeleteStatus, OldCurrentSize, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK,
@@ -590,7 +598,7 @@ impl MetadataQuorumAccumulator {
}) })
} }
pub(in crate::set_disk) fn default_write_quorum(&self) -> usize { pub(crate) fn default_write_quorum(&self) -> usize {
if self.default_parity_count == 0 || self.default_parity_count >= self.total_disks { if self.default_parity_count == 0 || self.default_parity_count >= self.total_disks {
return self.total_disks; return self.total_disks;
} }
@@ -651,36 +659,49 @@ pub(in crate::set_disk) fn metadata_early_stop_candidate_matches(left: &FileInfo
&& left.erasure.distribution == right.erasure.distribution && left.erasure.distribution == right.erasure.distribution
} }
pub(in crate::set_disk) async fn data_read_early_stop_inline_body_verified( pub(in crate::set_disk) async fn data_read_early_stop_inline_body_miss_reason(
bucket: &str, bucket: &str,
object: &str, object: &str,
candidate: &FileInfo, candidate: &FileInfo,
parts_metadata: &[FileInfo], parts_metadata: &[FileInfo],
disks: &[Option<DiskStore>], disks: &[Option<DiskStore>],
) -> bool { ) -> Option<&'static str> {
if !candidate.inline_data() // `inline_data` excludes remote objects; this diagnostic reports them separately.
|| candidate.is_compressed() if !rustfs_utils::http::contains_key_str(&candidate.metadata, rustfs_utils::http::SUFFIX_INLINE_DATA) {
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE);
}
if candidate.is_compressed()
|| candidate || candidate
.metadata .metadata
.keys() .keys()
.any(|key| rustfs_utils::http::is_object_encryption_marker(key)) .any(|key| rustfs_utils::http::is_object_encryption_marker(key))
|| candidate.is_remote()
|| candidate.deleted
|| candidate.size <= 0
|| candidate.parts.len() != 1
|| !candidate.has_valid_erasure_geometry()
{ {
return false; return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED);
}
if candidate.is_remote() {
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE);
}
if candidate.deleted {
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED);
}
if candidate.size <= 0 {
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE);
}
if candidate.parts.len() != 1 {
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE);
}
if !candidate.has_valid_erasure_geometry() {
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY);
} }
let Ok(object_size) = usize::try_from(candidate.size) else { let Ok(object_size) = usize::try_from(candidate.size) else {
return false; return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE);
}; };
if candidate.parts.first().is_none_or(|part| part.size != object_size) { if candidate.parts.first().is_none_or(|part| part.size != object_size) {
return false; return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE);
} }
if !can_try_inline_data_shards_direct(object_size, candidate.erasure.block_size) { if !can_try_inline_data_shards_direct(object_size, candidate.erasure.block_size) {
return false; return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE);
} }
let Ok(erasure) = coding::Erasure::try_new_with_options( let Ok(erasure) = coding::Erasure::try_new_with_options(
@@ -689,18 +710,18 @@ pub(in crate::set_disk) async fn data_read_early_stop_inline_body_verified(
candidate.erasure.block_size, candidate.erasure.block_size,
candidate.uses_legacy_checksum, candidate.uses_legacy_checksum,
) else { ) else {
return false; return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY);
}; };
let Some(data_files) = let data_files =
collect_inline_data_shard_fileinfos_by_index(parts_metadata, candidate, erasure.data_shards, |index| { match collect_inline_data_shard_fileinfos_by_index_or_reason(parts_metadata, candidate, erasure.data_shards, |index| {
disks.get(index).is_some_and(Option::is_some) disks.get(index).is_some_and(Option::is_some)
}) }) {
else { Ok(data_files) => data_files,
return false; Err(reason) => return Some(reason),
}; };
let Some(part) = candidate.parts.first() else { let Some(part) = candidate.parts.first() else {
return false; return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE);
}; };
let checksum_info = candidate.erasure.get_checksum_info(part.number); let checksum_info = candidate.erasure.get_checksum_info(part.number);
let checksum_algo = if candidate.uses_legacy_checksum && checksum_info.algorithm == HashAlgorithm::HighwayHash256S { let checksum_algo = if candidate.uses_legacy_checksum && checksum_info.algorithm == HashAlgorithm::HighwayHash256S {
@@ -720,12 +741,70 @@ pub(in crate::set_disk) async fn data_read_early_stop_inline_body_verified(
let Ok(mut readers) = let Ok(mut readers) =
build_inline_bitrot_readers_from_refs(&data_files, bucket, object, read_length, shard_size, &checksum_algo, false).await build_inline_bitrot_readers_from_refs(&data_files, bucket, object, read_length, shard_size, &checksum_algo, false).await
else { else {
return false; return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY);
}; };
try_read_inline_data_shards_direct(&mut readers, erasure.data_shards, read_length, object_size) match try_read_inline_data_shards_direct(&mut readers, erasure.data_shards, read_length, object_size).await {
.await Some(body) if body.len() == object_size => None,
.is_some_and(|body| body.len() == object_size) _ => Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY),
}
}
fn data_read_inline_missing_shards_are_pending(
candidate: &FileInfo,
parts_metadata: &[FileInfo],
errors: &[Option<DiskError>],
disks: &[Option<DiskStore>],
fanout_order: &[usize],
scheduled_fanout_len: usize,
) -> bool {
let Ok(erasure) = coding::Erasure::try_new_with_options(
candidate.erasure.data_blocks,
candidate.erasure.parity_blocks,
candidate.erasure.block_size,
candidate.uses_legacy_checksum,
) else {
return false;
};
let distribution = &candidate.erasure.distribution;
let mut data_shards_seen_or_pending = vec![false; erasure.data_shards];
let mut missing_pending_data_shards = 0usize;
for (disk_index, file_info) in parts_metadata.iter().enumerate() {
let Some(&block_index) = distribution.get(disk_index) else {
return false;
};
if block_index == 0 || block_index > erasure.data_shards {
continue;
}
if !disks.get(disk_index).is_some_and(Option::is_some) {
return false;
}
let data_slot = block_index - 1;
if file_info.name.is_empty() {
let scheduled_and_not_failed = fanout_order
.get(..scheduled_fanout_len)
.is_some_and(|scheduled_disks| scheduled_disks.contains(&disk_index))
&& errors.get(disk_index).is_some_and(Option::is_none);
if scheduled_and_not_failed {
data_shards_seen_or_pending[data_slot] = true;
missing_pending_data_shards = missing_pending_data_shards.saturating_add(1);
continue;
}
return false;
}
if file_info.erasure.index != block_index
|| !file_info.has_valid_erasure_geometry()
|| !metadata_early_stop_candidate_matches(file_info, candidate)
|| file_info.data.as_ref().is_none_or(|data| data.is_empty())
{
return false;
}
data_shards_seen_or_pending[data_slot] = true;
}
missing_pending_data_shards > 0 && data_shards_seen_or_pending.into_iter().all(|seen_or_pending| seen_or_pending)
} }
pub(in crate::set_disk) fn classify_metadata_response_error(err: &DiskError) -> &'static str { pub(in crate::set_disk) fn classify_metadata_response_error(err: &DiskError) -> &'static str {
@@ -2468,6 +2547,7 @@ impl SetDisks {
let mut next_fanout_index = 0usize; let mut next_fanout_index = 0usize;
let mut scheduled_count = 0usize; let mut scheduled_count = 0usize;
let mut force_full_wait = false; let mut force_full_wait = false;
let mut final_miss_reason_override = None;
let spawn_read_version = let spawn_read_version =
|join_set: &mut JoinSet<(usize, disk::error::Result<FileInfo>, Duration)>, index: usize, disk: Option<DiskStore>| { |join_set: &mut JoinSet<(usize, disk::error::Result<FileInfo>, Duration)>, index: usize, disk: Option<DiskStore>| {
let task_opts = opts; let task_opts = opts;
@@ -2510,6 +2590,7 @@ impl SetDisks {
} }
while let Some(result) = join_set.join_next().await { while let Some(result) = join_set.join_next().await {
let mut defer_pending_inline_data_shard = false;
match result { match result {
Ok((index, res, elapsed)) => match res { Ok((index, res, elapsed)) => match res {
Ok(file_info) => { Ok(file_info) => {
@@ -2540,17 +2621,43 @@ impl SetDisks {
.or_else(|| accumulator.version_early_stop_decision()) .or_else(|| accumulator.version_early_stop_decision())
{ {
let should_return_early = if read_data { let should_return_early = if read_data {
let allow_data_read_early_stop = match accumulator.candidate.as_ref() { match accumulator.candidate.as_ref() {
Some(candidate) => { Some(candidate) => match data_read_early_stop_inline_body_miss_reason(
data_read_early_stop_inline_body_verified(bucket.as_ref(), object.as_ref(), candidate, &ress, disks) bucket.as_ref(),
.await object.as_ref(),
candidate,
&ress,
disks,
)
.await
{
None => true,
Some(reason) => {
final_miss_reason_override = Some(reason);
if bounded_fanout
&& reason == GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD
&& data_read_inline_missing_shards_are_pending(
candidate,
&ress,
&errors,
disks,
&fanout_order,
next_fanout_index,
)
{
defer_pending_inline_data_shard = true;
} else {
force_full_wait = true;
}
false
}
},
None => {
force_full_wait = true;
final_miss_reason_override = Some(GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM);
false
} }
None => false,
};
if !allow_data_read_early_stop {
force_full_wait = true;
} }
allow_data_read_early_stop
} else { } else {
true true
}; };
@@ -2587,6 +2694,7 @@ impl SetDisks {
let pending_responses = join_set.len(); let pending_responses = join_set.len();
let should_hedge_single_pending_data_read = read_data let should_hedge_single_pending_data_read = read_data
&& !force_full_wait && !force_full_wait
&& !defer_pending_inline_data_shard
&& pending_responses == 1 && pending_responses == 1
&& accumulator.can_still_reach_early_stop_with_pending(pending_responses); && accumulator.can_still_reach_early_stop_with_pending(pending_responses);
if bounded_fanout && force_full_wait { if bounded_fanout && force_full_wait {
@@ -2599,6 +2707,7 @@ impl SetDisks {
next_fanout_index = next_fanout_index.saturating_add(1); next_fanout_index = next_fanout_index.saturating_add(1);
} }
} else if bounded_fanout } else if bounded_fanout
&& !defer_pending_inline_data_shard
&& next_fanout_index < disks.len() && next_fanout_index < disks.len()
&& (!accumulator.can_still_reach_early_stop_with_pending(pending_responses) && (!accumulator.can_still_reach_early_stop_with_pending(pending_responses)
|| should_hedge_single_pending_data_read) || should_hedge_single_pending_data_read)
@@ -2612,7 +2721,12 @@ impl SetDisks {
} }
} }
rustfs_io_metrics::record_get_object_metadata_early_stop_miss(metrics_path, accumulator.final_miss_reason()); let accumulator_miss_reason = accumulator.final_miss_reason();
let final_miss_reason = match (final_miss_reason_override, accumulator_miss_reason) {
(Some(reason), GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM) => reason,
_ => accumulator_miss_reason,
};
rustfs_io_metrics::record_get_object_metadata_early_stop_miss(metrics_path, final_miss_reason);
rustfs_io_metrics::record_get_object_metadata_early_stop_saved_responses(metrics_path, 0); rustfs_io_metrics::record_get_object_metadata_early_stop_saved_responses(metrics_path, 0);
rustfs_io_metrics::record_get_object_metadata_fanout_lifecycle(metrics_path, scheduled_count, scheduled_count, 0); rustfs_io_metrics::record_get_object_metadata_fanout_lifecycle(metrics_path, scheduled_count, scheduled_count, 0);
let diagnostics = MetadataFanoutDiagnostics::new(fanout_start.elapsed(), observations); let diagnostics = MetadataFanoutDiagnostics::new(fanout_start.elapsed(), observations);
@@ -3011,12 +3125,41 @@ impl RenameConvergence {
} }
} }
pub(in crate::set_disk) struct RenameDataCommit {
pub(in crate::set_disk) online_disks: Vec<Option<DiskStore>>,
pub(in crate::set_disk) convergence: RenameConvergence,
pub(in crate::set_disk) data_dir: Option<Uuid>,
pub(in crate::set_disk) cleanup_disks: Vec<Option<DiskStore>>,
pub(in crate::set_disk) old_current_size: Option<OldCurrentSize>,
pub(in crate::set_disk) committed_file_info: FileInfo,
}
type RenameDataLegacyTuple = (
Vec<Option<DiskStore>>,
RenameConvergence,
Option<Uuid>,
Vec<Option<DiskStore>>,
Option<OldCurrentSize>,
);
impl RenameDataCommit {
fn into_legacy_tuple(self) -> RenameDataLegacyTuple {
(
self.online_disks,
self.convergence,
self.data_dir,
self.cleanup_disks,
self.old_current_size,
)
}
}
impl SetDisks { impl SetDisks {
pub(in crate::set_disk) fn default_read_quorum(&self) -> usize { pub(in crate::set_disk) fn default_read_quorum(&self) -> usize {
self.set_drive_count - self.default_parity_count self.set_drive_count - self.default_parity_count
} }
pub(in crate::set_disk) fn default_write_quorum(&self) -> usize { pub(crate) fn default_write_quorum(&self) -> usize {
let mut data_count = self.set_drive_count - self.default_parity_count; let mut data_count = self.set_drive_count - self.default_parity_count;
if data_count == self.default_parity_count { if data_count == self.default_parity_count {
data_count += 1 data_count += 1
@@ -3025,6 +3168,97 @@ impl SetDisks {
data_count data_count
} }
pub(in crate::set_disk) async fn prepare_quota_mutation_fences(
disks: &[Option<DiskStore>],
bucket: &str,
object: &str,
write_quorum: usize,
) -> crate::error::Result<(Vec<Option<DiskStore>>, Vec<Option<SnapshotLeaseToken>>)> {
let fence_path = crate::disk::quota_mutation_fence_path(bucket, object);
let results = join_all(disks.iter().map(|disk| {
let disk = disk.clone();
let fence_path = fence_path.clone();
async move {
let disk = disk?;
match disk.acquire_snapshot_lease(RUSTFS_META_BUCKET, &fence_path).await {
Ok(token) => Some((disk, token)),
Err(_) => None,
}
}
}))
.await;
if results.iter().flatten().count() < write_quorum {
for (disk, token) in results.iter().flatten() {
let _ = disk.release_snapshot_lease(RUSTFS_META_BUCKET, &fence_path, *token).await;
}
return Err(StorageError::ErasureWriteQuorum);
}
let mut fenced_disks = Vec::with_capacity(results.len());
let mut tokens = Vec::with_capacity(results.len());
for result in results {
match result {
Some((disk, token)) => {
fenced_disks.push(Some(disk));
tokens.push(Some(token));
}
None => {
fenced_disks.push(None);
tokens.push(None);
}
}
}
Ok((fenced_disks, tokens))
}
pub(in crate::set_disk) async fn release_quota_mutation_fences(
disks: &[Option<DiskStore>],
tokens: &[Option<SnapshotLeaseToken>],
bucket: &str,
object: &str,
write_quorum: usize,
) -> crate::error::Result<()> {
let fence_path = crate::disk::quota_mutation_fence_path(bucket, object);
let results = join_all(disks.iter().zip(tokens).filter_map(|(disk, token)| {
let disk = disk.as_ref()?.clone();
let token = (*token)?;
let fence_path = fence_path.clone();
Some(async move { disk.release_snapshot_lease(RUSTFS_META_BUCKET, &fence_path, token).await })
}))
.await;
if results.iter().filter(|result| result.is_ok()).count() < write_quorum {
return Err(StorageError::ErasureWriteQuorum);
}
Ok(())
}
pub(in crate::set_disk) fn assign_rename_data_indexes(file_infos: &mut [FileInfo]) {
for (index, file_info) in file_infos.iter_mut().enumerate() {
if file_info.erasure.index == 0 {
file_info.erasure.index = index + 1;
}
}
}
pub(in crate::set_disk) async fn abort_quota_reservation_after_fence(
reservation: crate::bucket::quota::reservation::QuotaReservation,
disks: &[Option<DiskStore>],
tokens: &[Option<SnapshotLeaseToken>],
bucket: &str,
object: &str,
write_quorum: usize,
fenced: bool,
) {
let safe_to_abort = !fenced
|| Self::release_quota_mutation_fences(disks, tokens, bucket, object, write_quorum)
.await
.is_ok();
if safe_to_abort {
reservation.abort().await;
} else {
reservation.defer_after_fence();
}
}
#[tracing::instrument(level = "debug", skip(disks, file_infos))] #[tracing::instrument(level = "debug", skip(disks, file_infos))]
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
pub(in crate::set_disk) async fn rename_data( pub(in crate::set_disk) async fn rename_data(
@@ -3035,13 +3269,22 @@ impl SetDisks {
dst_bucket: &str, dst_bucket: &str,
dst_object: &str, dst_object: &str,
write_quorum: usize, write_quorum: usize,
) -> disk::error::Result<( ) -> disk::error::Result<RenameDataLegacyTuple> {
Vec<Option<DiskStore>>, Self::rename_data_owned(disks, src_bucket, src_object, file_infos.to_vec(), dst_bucket, dst_object, write_quorum)
RenameConvergence, .await
Option<Uuid>, .map(RenameDataCommit::into_legacy_tuple)
Vec<Option<DiskStore>>, }
Option<OldCurrentSize>,
)> { #[tracing::instrument(level = "debug", skip(disks, file_infos))]
pub(in crate::set_disk) async fn rename_data_owned(
disks: &[Option<DiskStore>],
src_bucket: &str,
src_object: &str,
file_infos: Vec<FileInfo>,
dst_bucket: &str,
dst_object: &str,
write_quorum: usize,
) -> disk::error::Result<RenameDataCommit> {
if let Some(file_info) = disks if let Some(file_info) = disks
.iter() .iter()
.zip(file_infos.iter()) .zip(file_infos.iter())
@@ -3066,7 +3309,7 @@ impl SetDisks {
let disk_count = disks.len(); let disk_count = disks.len();
let fanout_disks = disks.to_vec(); let fanout_disks = disks.to_vec();
let fanout_file_infos = file_infos.to_vec(); let fanout_file_infos = file_infos;
let fanout_src_bucket = src_bucket.clone(); let fanout_src_bucket = src_bucket.clone();
let fanout_src_object = src_object.clone(); let fanout_src_object = src_object.clone();
let fanout_dst_bucket = dst_bucket.clone(); let fanout_dst_bucket = dst_bucket.clone();
@@ -3078,9 +3321,9 @@ impl SetDisks {
let fanout = tokio::spawn(async move { let fanout = tokio::spawn(async move {
let futures = fanout_disks let futures = fanout_disks
.into_iter() .into_iter()
.zip(fanout_file_infos) .zip(fanout_file_infos.iter())
.enumerate() .enumerate()
.map(|(i, (disk, mut file_info))| { .map(|(i, (disk, file_info))| {
let src_bucket = fanout_src_bucket.clone(); let src_bucket = fanout_src_bucket.clone();
let src_object = fanout_src_object.clone(); let src_object = fanout_src_object.clone();
let dst_object = fanout_dst_object.clone(); let dst_object = fanout_dst_object.clone();
@@ -3097,11 +3340,15 @@ impl SetDisks {
}; };
let is_delete_marker = file_info.is_canonical_delete_marker(); let is_delete_marker = file_info.is_canonical_delete_marker();
if file_info.erasure.index == 0 { let mut local_file_info;
file_info.erasure.index = i + 1; let file_info = if file_info.erasure.index == 0 {
} local_file_info = file_info.clone();
local_file_info.erasure.index = i + 1;
if !is_delete_marker && !file_info.has_valid_erasure_geometry() { &local_file_info
} else {
file_info
};
if file_info.erasure.index == 0 || (!is_delete_marker && !file_info.has_valid_erasure_geometry()) {
return Err(DiskError::FileCorrupt); return Err(DiskError::FileCorrupt);
} }
@@ -3109,12 +3356,13 @@ impl SetDisks {
// A no-op immediately-ready future in production. // A no-op immediately-ready future in production.
Self::rename_fanout_barrier(&dst_object, i, rename_fanout_barrier_phase::RENAME).await; Self::rename_fanout_barrier(&dst_object, i, rename_fanout_barrier_phase::RENAME).await;
disk.rename_data(&src_bucket, &src_object, file_info, &dst_bucket, &dst_object) disk.rename_data_borrowed(&src_bucket, &src_object, file_info, &dst_bucket, &dst_object)
.await .await
}) })
.catch_unwind() .catch_unwind()
}); });
join_all(futures).await let results = join_all(futures).await;
(results, fanout_file_infos)
}); });
let mut disk_versions = vec![None; disk_count]; let mut disk_versions = vec![None; disk_count];
@@ -3122,7 +3370,7 @@ impl SetDisks {
let mut cleanup_data_dirs = vec![None; disk_count]; let mut cleanup_data_dirs = vec![None; disk_count];
let mut old_current_sizes = vec![None; disk_count]; let mut old_current_sizes = vec![None; disk_count];
let results = fanout.await.map_err(|_| DiskError::Unexpected)?; let (results, mut file_infos) = fanout.await.map_err(|_| DiskError::Unexpected)?;
for (idx, result) in results.iter().enumerate() { for (idx, result) in results.iter().enumerate() {
match result { match result {
@@ -3178,7 +3426,7 @@ impl SetDisks {
} }
if let Some(disk) = disks[i].as_ref() { if let Some(disk) = disks[i].as_ref() {
let fi = file_infos[i].clone(); let fi = std::mem::take(&mut file_infos[i]);
let old_data_dir = data_dirs[i]; let old_data_dir = data_dirs[i];
let disk = disk.clone(); let disk = disk.clone();
let dst_bucket = dst_bucket.clone(); let dst_bucket = dst_bucket.clone();
@@ -3301,6 +3549,8 @@ impl SetDisks {
let convergence = Self::classify_rename_convergence(&disk_versions, &errs); let convergence = Self::classify_rename_convergence(&disk_versions, &errs);
let old_current_size = Self::reduce_common_old_current_size(&old_current_sizes, write_quorum); let old_current_size = Self::reduce_common_old_current_size(&old_current_sizes, write_quorum);
let online_disks = Self::eval_disks(disks, &errs); let online_disks = Self::eval_disks(disks, &errs);
let committed_slot = online_disks.iter().position(Option::is_some).ok_or(DiskError::Unexpected)?;
let committed_file_info = std::mem::take(&mut file_infos[committed_slot]);
let cleanup_disks = if let Some(data_dir) = data_dir { let cleanup_disks = if let Some(data_dir) = data_dir {
disks disks
.iter() .iter()
@@ -3318,7 +3568,14 @@ impl SetDisks {
vec![None; disks.len()] vec![None; disks.len()]
}; };
Ok((online_disks, convergence, data_dir, cleanup_disks, old_current_size)) Ok(RenameDataCommit {
online_disks,
convergence,
data_dir,
cleanup_disks,
old_current_size,
committed_file_info,
})
} }
/// rustfs/backlog#1009: reduce the per-disk observations of the /// rustfs/backlog#1009: reduce the per-disk observations of the
@@ -5608,9 +5865,20 @@ mod tests {
object: &str, object: &str,
payload: &[u8], payload: &[u8],
uses_legacy_checksum: bool, uses_legacy_checksum: bool,
) -> Vec<FileInfo> {
inline_metadata_fanout_fileinfos_with_geometry(bucket, object, payload, uses_legacy_checksum, 2, 2).await
}
async fn inline_metadata_fanout_fileinfos_with_geometry(
bucket: &str,
object: &str,
payload: &[u8],
uses_legacy_checksum: bool,
data_shards: usize,
parity_shards: usize,
) -> Vec<FileInfo> { ) -> Vec<FileInfo> {
let distribution_key = metadata_distribution_key(bucket, object); let distribution_key = metadata_distribution_key(bucket, object);
let mut base = FileInfo::new(&distribution_key, 2, 2); let mut base = FileInfo::new(&distribution_key, data_shards, parity_shards);
base.volume = bucket.to_string(); base.volume = bucket.to_string();
base.name = object.to_string(); base.name = object.to_string();
base.size = i64::try_from(payload.len()).expect("test payload should fit i64"); base.size = i64::try_from(payload.len()).expect("test payload should fit i64");
@@ -5673,6 +5941,21 @@ mod tests {
install_inline_metadata_fanout_files(disks, bucket, object, files).await; install_inline_metadata_fanout_files(disks, bucket, object, files).await;
} }
async fn install_inline_metadata_fanout_fileinfo_with_geometry(
disks: &[Option<DiskStore>],
bucket: &str,
object: &str,
payload: &[u8],
data_shards: usize,
parity_shards: usize,
mutate: impl FnOnce(&mut [FileInfo]),
) {
let mut files =
inline_metadata_fanout_fileinfos_with_geometry(bucket, object, payload, false, data_shards, parity_shards).await;
mutate(&mut files);
install_inline_metadata_fanout_files(disks, bucket, object, files).await;
}
async fn install_inline_metadata_fanout_files(disks: &[Option<DiskStore>], bucket: &str, object: &str, files: Vec<FileInfo>) { async fn install_inline_metadata_fanout_files(disks: &[Option<DiskStore>], bucket: &str, object: &str, files: Vec<FileInfo>) {
let distribution = files let distribution = files
.first() .first()
@@ -5893,6 +6176,118 @@ mod tests {
drop(dirs); drop(dirs);
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn bounded_metadata_early_stop_waits_for_pending_inline_data_shard() {
const DISKS: usize = 6;
const DATA_SHARDS: usize = 4;
const PARITY_SHARDS: usize = 2;
let bucket = "bounded-inline-data-get-pending-shard-bucket";
let object =
object_with_initial_data_shards(bucket, "bounded-inline-data-get-pending-shard-object", DATA_SHARDS, DATA_SHARDS);
let (dirs, disks) = call_counter_local_disks(bucket, DISKS).await;
install_inline_metadata_fanout_fileinfo_with_geometry(
&disks,
bucket,
&object,
b"verified inline payload",
DATA_SHARDS,
PARITY_SHARDS,
|_| {},
)
.await;
temp_env::async_with_vars(
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
let fanout_order = bounded_metadata_fanout_order(bucket, &object, DISKS, PARITY_SHARDS);
let distribution_key = metadata_distribution_key(bucket, &object);
let distribution = FileInfo::new(&distribution_key, DATA_SHARDS, PARITY_SHARDS)
.erasure
.distribution;
let paused_data_disk = *fanout_order
.iter()
.take(DATA_SHARDS)
.find(|disk_index| {
distribution
.get(**disk_index)
.is_some_and(|block_index| (1..=DATA_SHARDS).contains(block_index))
})
.expect("initial fanout should include a data shard to pause");
let hedged_parity_disk = fanout_order[DATA_SHARDS];
let unscheduled_parity_disk = fanout_order[DATA_SHARDS + 1];
let barrier = rename_fanout_barrier::arm(&object, paused_data_disk, rename_fanout_barrier::PHASE_READ_VERSION);
let tracker = rename_fanout_barrier::observe_tasks(&object);
let calls = disk_call_counters::observe(&object);
let disks_for_read = disks.clone();
let object_for_read = object.clone();
let mut read = tokio::spawn(async move {
SetDisks::read_all_fileinfo_observed(
&disks_for_read,
bucket,
bucket,
&object_for_read,
"",
true,
false,
false,
true,
PARITY_SHARDS,
)
.await
});
tokio::time::timeout(BARRIER_PAUSE_GUARD, barrier.wait_until_paused())
.await
.expect("initial data shard should pause before returning");
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
while calls.for_disk(disk_call_counters::KIND_READ_VERSION, hedged_parity_disk) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("bounded fanout should hedge one parity disk while the data shard is pending");
assert!(
tokio::time::timeout(BARRIER_PAUSE_GUARD, &mut read).await.is_err(),
"inline data-read early-stop must wait for a scheduled missing data shard instead of forcing full wait"
);
barrier.release();
let (parts_metadata, errs, diagnostics) = read
.await
.expect("metadata read task should not panic")
.expect("pending data shard should let the inline verifier finish");
assert_eq!(
calls.total(disk_call_counters::KIND_READ_VERSION),
5,
"pending data-shard defer should not schedule the final parity disk"
);
assert_eq!(
calls.for_disk(disk_call_counters::KIND_READ_VERSION, unscheduled_parity_disk),
0,
"the remaining parity disk must stay unissued when pending data verification succeeds"
);
assert_eq!(
tracker.running(),
0,
"early-stop should drain spawned read_version tasks before returning"
);
assert_eq!(diagnostics.total_responses(), 5);
assert_eq!(parts_metadata.iter().filter(|fi| fi.name == object).count(), 5);
assert!(errs.iter().all(Option::is_none));
},
)
.await;
drop(dirs);
}
#[tokio::test] #[tokio::test]
async fn data_read_early_stop_verifies_legacy_inline_checksum_payload() { async fn data_read_early_stop_verifies_legacy_inline_checksum_payload() {
let bucket = "legacy-inline-data-get-fanout-bucket"; let bucket = "legacy-inline-data-get-fanout-bucket";
@@ -5923,11 +6318,133 @@ mod tests {
.clone(); .clone();
assert!( assert!(
data_read_early_stop_inline_body_verified(bucket, object, &candidate, &parts_metadata, &disks).await, data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &parts_metadata, &disks)
.await
.is_none(),
"legacy inline metadata must use the legacy bitrot shard sizing and checksum algorithm" "legacy inline metadata must use the legacy bitrot shard sizing and checksum algorithm"
); );
} }
#[tokio::test]
async fn data_read_early_stop_reports_inline_miss_reasons() {
let bucket = "inline-data-get-miss-reason-bucket";
let object = "inline-data-get-miss-reason-object";
let payload = b"verified inline payload";
let (_dirs, disks) = call_counter_local_disks(bucket, 4).await;
let files = inline_metadata_fanout_fileinfos_with_mode(bucket, object, payload, false).await;
let distribution = files
.first()
.map(|file| file.erasure.distribution.clone())
.expect("fixture should include metadata");
let order = bounded_metadata_fanout_order(bucket, object, 4, 2);
let mut parts_metadata = vec![FileInfo::default(); 4];
for disk_index in order.into_iter().take(3) {
let block_index = distribution
.get(disk_index)
.copied()
.expect("fixture distribution should cover every disk");
parts_metadata[disk_index] = files
.get(block_index.checked_sub(1).expect("erasure block indexes are one-based"))
.expect("fixture should include every distributed shard")
.clone();
}
let candidate = parts_metadata
.iter()
.find(|file| file.name == object)
.expect("fixture should include observed metadata")
.clone();
let data_disk = distribution
.iter()
.position(|block_index| *block_index == 1)
.expect("fixture distribution should include first data shard");
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &parts_metadata, &disks).await,
None
);
let mut not_inline = candidate.clone();
rustfs_utils::http::remove_str(&mut not_inline.metadata, rustfs_utils::http::SUFFIX_INLINE_DATA);
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &not_inline, &parts_metadata, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE)
);
let mut remote = candidate.clone();
remote.transition_status = TRANSITION_COMPLETE.to_string();
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &remote, &parts_metadata, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE)
);
let mut transformed = candidate.clone();
rustfs_utils::http::insert_str(&mut transformed.metadata, rustfs_utils::http::SUFFIX_COMPRESSION, "zstd".to_string());
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &transformed, &parts_metadata, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED)
);
let mut deleted = candidate.clone();
deleted.deleted = true;
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &deleted, &parts_metadata, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED)
);
let mut zero_size = candidate.clone();
zero_size.size = 0;
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &zero_size, &parts_metadata, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE)
);
let mut multipart = candidate.clone();
multipart.parts.push(multipart.parts[0].clone());
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &multipart, &parts_metadata, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE)
);
let mut invalid_geometry = candidate.clone();
invalid_geometry.erasure.data_blocks = 0;
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &invalid_geometry, &parts_metadata, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY)
);
let mut missing_shard = parts_metadata.clone();
missing_shard[data_disk] = FileInfo::default();
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &missing_shard, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD)
);
let mut missing_payload = parts_metadata.clone();
missing_payload[data_disk].data = None;
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &missing_payload, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD)
);
let mut identity_mismatch = parts_metadata.clone();
identity_mismatch[data_disk].version_id = Some(Uuid::new_v4());
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &identity_mismatch, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH)
);
let mut corrupt = parts_metadata.clone();
if let Some(data) = corrupt[data_disk].data.as_mut() {
let mut corrupt_data = data.to_vec();
corrupt_data[0] ^= 0x01;
*data = Bytes::from(corrupt_data);
}
assert_eq!(
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &corrupt, &disks).await,
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY)
);
}
#[test] #[test]
#[serial_test::serial] #[serial_test::serial]
fn metadata_fanout_lifecycle_records_real_early_stop_abort() { fn metadata_fanout_lifecycle_records_real_early_stop_abort() {
@@ -6017,7 +6534,7 @@ mod tests {
&[ &[
("path", GET_OBJECT_PATH_INTERNAL_META), ("path", GET_OBJECT_PATH_INTERNAL_META),
("decision", "miss"), ("decision", "miss"),
("reason", GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM), ("reason", GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY),
], ],
), ),
1, 1,
@@ -6029,7 +6546,7 @@ mod tests {
&[ &[
("path", GET_OBJECT_PATH_LEGACY_DUPLEX), ("path", GET_OBJECT_PATH_LEGACY_DUPLEX),
("decision", "miss"), ("decision", "miss"),
("reason", GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM), ("reason", GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY),
], ],
), ),
0, 0,
@@ -6624,7 +7141,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn bounded_metadata_early_stop_defaults_keep_data_get_full_fanout() { async fn bounded_metadata_early_stop_defaults_keep_non_inline_data_get_full_fanout() {
const DISKS: usize = 4; const DISKS: usize = 4;
let bucket = "bounded-data-get-default-bucket"; let bucket = "bounded-data-get-default-bucket";
let object = "bounded-data-get-default-object"; let object = "bounded-data-get-default-object";
@@ -6647,7 +7164,7 @@ mod tests {
assert_eq!( assert_eq!(
calls.total(disk_call_counters::KIND_READ_VERSION), calls.total(disk_call_counters::KIND_READ_VERSION),
DISKS as u64, DISKS as u64,
"default GET data-read metadata must keep full fanout for read-failure tolerance" "default non-inline GET data-read metadata must keep full fanout for read-failure tolerance"
); );
assert_eq!(diagnostics.total_responses(), DISKS); assert_eq!(diagnostics.total_responses(), DISKS);
assert_eq!(parts_metadata.iter().filter(|fi| fi.name == object).count(), DISKS); assert_eq!(parts_metadata.iter().filter(|fi| fi.name == object).count(), DISKS);
+49 -34
View File
@@ -59,7 +59,10 @@ use crate::client::{object_api_utils::get_raw_etag, transition_api::ReaderImpl};
use crate::cluster::rpc::heal_bucket_local_on_disks; use crate::cluster::rpc::heal_bucket_local_on_disks;
use crate::data_usage::record_compression_total_memory; use crate::data_usage::record_compression_total_memory;
use crate::diagnostics::get::{ use crate::diagnostics::get::{
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART, GET_OBJECT_PATH_BODY_CACHE, GET_OBJECT_PATH_CODEC_STREAMING, GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY,
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH,
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD,
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD, GET_OBJECT_PATH_BODY_CACHE, GET_OBJECT_PATH_CODEC_STREAMING,
GET_OBJECT_PATH_CODEC_STREAMING_LEGACY_ENGINE, GET_OBJECT_PATH_CODEC_STREAMING_RUSTFS_ENGINE, GET_OBJECT_PATH_DIRECT_MEMORY, GET_OBJECT_PATH_CODEC_STREAMING_LEGACY_ENGINE, GET_OBJECT_PATH_CODEC_STREAMING_RUSTFS_ENGINE, GET_OBJECT_PATH_DIRECT_MEMORY,
GET_OBJECT_PATH_EMPTY, GET_OBJECT_PATH_INLINE_DIRECT, GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_EMPTY, GET_OBJECT_PATH_INLINE_DIRECT, GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX,
GET_OBJECT_PATH_REMOTE_TRANSITION, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE, GET_STAGE_EMIT, GET_STAGE_INLINE_PREPARE, GET_OBJECT_PATH_REMOTE_TRANSITION, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE, GET_STAGE_EMIT, GET_STAGE_INLINE_PREPARE,
@@ -689,8 +692,9 @@ const DEFAULT_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY_THRESHOLD: usize = 128 * 102
const ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE: &str = "RUSTFS_GET_METADATA_EARLY_STOP_ENABLE"; const ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE: &str = "RUSTFS_GET_METADATA_EARLY_STOP_ENABLE";
// Enabled by default (backlog#872): the early-stop path only engages for // Enabled by default (backlog#872): the early-stop path only engages for
// requests `should_allow_metadata_early_stop` classifies as safe (latest-version // requests `should_allow_metadata_early_stop` classifies as safe (latest-version
// metadata-only reads by default, without version_id / healing / free-version // reads by default, without version_id / healing / free-version needs) and still
// needs) and still requires a full read-quorum agreement before stopping. Set // requires a full read-quorum agreement before stopping. Data-read requests add
// a separate inline-shard verifier before cancelling the remaining fanout. Set
// the env var to `false` to fall back to full-wait metadata fanout. // the env var to `false` to fall back to full-wait metadata fanout.
const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE: bool = true; const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE: bool = true;
@@ -701,7 +705,7 @@ const ENV_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE: &str = "RUSTFS_GET_META
const DEFAULT_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE: bool = false; const DEFAULT_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE: bool = false;
const ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE: &str = "RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE"; const ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE: &str = "RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE";
const DEFAULT_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE: bool = false; const DEFAULT_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE: bool = true;
const ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT: &str = "RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT"; const ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT: &str = "RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT";
const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT: bool = false; const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT: bool = false;
@@ -719,8 +723,8 @@ pub(crate) use core::io_primitives::disk_call_counters;
mod ctx; mod ctx;
mod metadata; mod metadata;
mod ops; mod ops;
#[cfg(test)] #[cfg(any(test, feature = "test-util"))]
pub(crate) use ops::multipart::{MultipartCommitBarrier, MultipartCommitPause}; pub use ops::multipart::{MultipartCommitBarrier, MultipartCommitPause};
#[cfg(feature = "test-util")] #[cfg(feature = "test-util")]
pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier; pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier;
pub(crate) use ops::object::body_cache_plaintext_len; pub(crate) use ops::object::body_cache_plaintext_len;
@@ -912,12 +916,6 @@ mod prepared_get_object_metadata_tests {
.expect("4-disk test geometry should leave one bounded spare disk") .expect("4-disk test geometry should leave one bounded spare disk")
} }
fn bounded_slow_initial_disk_index(bucket: &str, object: &str) -> usize {
*bounded_metadata_fanout_order(bucket, object, 4, 2)
.get(2)
.expect("4-disk test geometry should include a third initial metadata disk")
}
#[tokio::test] #[tokio::test]
async fn prepared_metadata_is_consumed_exactly_once() { async fn prepared_metadata_is_consumed_exactly_once() {
let snapshot = GetObjectFileInfo::owned(FileInfo::default(), Vec::new(), Vec::new()); let snapshot = GetObjectFileInfo::owned(FileInfo::default(), Vec::new(), Vec::new());
@@ -1036,7 +1034,7 @@ mod prepared_get_object_metadata_tests {
#[test] #[test]
#[serial_test::serial(body_cache_hook)] #[serial_test::serial(body_cache_hook)]
fn inline_data_read_early_stop_reader_returns_exact_body() { fn inline_data_read_early_stop_defaults_return_exact_body() {
let runtime = tokio::runtime::Builder::new_current_thread() let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
@@ -1068,14 +1066,14 @@ mod prepared_get_object_metadata_tests {
temp_env::async_with_vars( temp_env::async_with_vars(
[ [
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")), ("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", None::<&str>),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")), ("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", None::<&str>),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")), ("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", None::<&str>),
], ],
async { async {
let slow_initial_disk = bounded_slow_initial_disk_index(bucket, &object); let slow_parity_disk = bounded_spare_disk_index(bucket, &object);
let barrier = let barrier =
rename_fanout_barrier::arm(&object, slow_initial_disk, rename_fanout_barrier::PHASE_READ_VERSION); rename_fanout_barrier::arm(&object, slow_parity_disk, rename_fanout_barrier::PHASE_READ_VERSION);
let calls = disk_call_counters::observe(&object); let calls = disk_call_counters::observe(&object);
let set_disks_for_read = Arc::clone(&set_disks); let set_disks_for_read = Arc::clone(&set_disks);
let opts_for_read = opts.clone(); let opts_for_read = opts.clone();
@@ -1088,10 +1086,10 @@ mod prepared_get_object_metadata_tests {
tokio::time::timeout(READ_VERSION_BARRIER_GUARD, barrier.wait_until_paused()) tokio::time::timeout(READ_VERSION_BARRIER_GUARD, barrier.wait_until_paused())
.await .await
.expect("bounded inline GET should pause a slow initial metadata read"); .expect("default inline GET should pause a slow parity metadata read");
let mut reader = tokio::time::timeout(READ_VERSION_BARRIER_GUARD, &mut open_reader) let mut reader = tokio::time::timeout(READ_VERSION_BARRIER_GUARD, &mut open_reader)
.await .await
.expect("production inline GET should return before the paused metadata response") .expect("default production inline GET should return before the paused parity metadata response")
.expect("inline GET reader task should not panic") .expect("inline GET reader task should not panic")
.expect("inline GET reader should open"); .expect("inline GET reader should open");
let object_size = reader.object_info.size; let object_size = reader.object_info.size;
@@ -1112,14 +1110,14 @@ mod prepared_get_object_metadata_tests {
assert_eq!(object_size, payload.len() as i64); assert_eq!(object_size, payload.len() as i64);
assert_eq!(restored, payload); assert_eq!(restored, payload);
assert_eq!(calls_total, 4, "bounded production GET should schedule the initial quorum plus one spare"); assert_eq!(calls_total, 4, "default production GET should eagerly schedule the full metadata fanout");
assert_eq!( assert_eq!(
recorder.histogram_values( recorder.histogram_values(
"rustfs_io_get_object_metadata_fanout_scheduled", "rustfs_io_get_object_metadata_fanout_scheduled",
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)] &[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
), ),
vec![4.0], vec![4.0],
"bounded production GET should record all scheduled metadata tasks" "default production GET should record all scheduled metadata tasks"
); );
assert_eq!( assert_eq!(
recorder.histogram_values( recorder.histogram_values(
@@ -1127,7 +1125,7 @@ mod prepared_get_object_metadata_tests {
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)] &[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
), ),
vec![3.0], vec![3.0],
"bounded production GET should record only observed metadata responses as completed" "default production GET should record only observed metadata responses as completed"
); );
assert_eq!( assert_eq!(
recorder.histogram_values( recorder.histogram_values(
@@ -1135,7 +1133,7 @@ mod prepared_get_object_metadata_tests {
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)] &[("path", GET_OBJECT_PATH_LEGACY_DUPLEX)]
), ),
vec![1.0], vec![1.0],
"bounded production GET should record the aborted slow metadata task" "default production GET should record the aborted slow parity metadata task"
); );
} }
@@ -1282,9 +1280,9 @@ mod prepared_get_object_metadata_tests {
temp_env::async_with_vars( temp_env::async_with_vars(
[ [
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")), ("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", None::<&str>),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")), ("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", None::<&str>),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")), ("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", None::<&str>),
], ],
async { async {
let calls = disk_call_counters::observe(&object); let calls = disk_call_counters::observe(&object);
@@ -3866,8 +3864,17 @@ fn collect_inline_data_shard_fileinfos_by_index<'a>(
parts_metadata: &'a [FileInfo], parts_metadata: &'a [FileInfo],
fi: &FileInfo, fi: &FileInfo,
data_shards: usize, data_shards: usize,
mut disk_is_online: impl FnMut(usize) -> bool, disk_is_online: impl FnMut(usize) -> bool,
) -> Option<Vec<&'a FileInfo>> { ) -> Option<Vec<&'a FileInfo>> {
collect_inline_data_shard_fileinfos_by_index_or_reason(parts_metadata, fi, data_shards, disk_is_online).ok()
}
fn collect_inline_data_shard_fileinfos_by_index_or_reason<'a>(
parts_metadata: &'a [FileInfo],
fi: &FileInfo,
data_shards: usize,
mut disk_is_online: impl FnMut(usize) -> bool,
) -> std::result::Result<Vec<&'a FileInfo>, &'static str> {
let distribution = &fi.erasure.distribution; let distribution = &fi.erasure.distribution;
let mut data_files = vec![None; data_shards]; let mut data_files = vec![None; data_shards];
@@ -3875,27 +3882,35 @@ fn collect_inline_data_shard_fileinfos_by_index<'a>(
if !disk_is_online(disk_index) { if !disk_is_online(disk_index) {
continue; continue;
} }
let block_index = *distribution.get(disk_index)?; let Some(&block_index) = distribution.get(disk_index) else {
return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY);
};
if block_index == 0 || block_index > data_shards { if block_index == 0 || block_index > data_shards {
continue; continue;
} }
if file_info.name.is_empty() {
return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD);
}
if file_info.erasure.index != block_index { if file_info.erasure.index != block_index {
continue; return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH);
} }
if !file_info.has_valid_erasure_geometry() { if !file_info.has_valid_erasure_geometry() {
continue; return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY);
} }
if !core::io_primitives::metadata_early_stop_candidate_matches(file_info, fi) { if !core::io_primitives::metadata_early_stop_candidate_matches(file_info, fi) {
continue; return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH);
} }
if file_info.data.as_ref().is_none_or(|data| data.is_empty()) { if file_info.data.as_ref().is_none_or(|data| data.is_empty()) {
continue; return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD);
} }
data_files[block_index - 1] = Some(file_info); data_files[block_index - 1] = Some(file_info);
} }
data_files.into_iter().collect() data_files
.into_iter()
.collect::<Option<Vec<_>>>()
.ok_or(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD)
} }
impl SetDisks { impl SetDisks {
+3 -2
View File
@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
use super::super::*; use super::super::*;
use crate::disk::disk_store::DiskStoreRenameDataExt;
use crate::io_support::bitrot::object_mmap_read_enabled; use crate::io_support::bitrot::object_mmap_read_enabled;
use crate::storage_api_contracts::namespace::NamespaceLocking as _; use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use tracing::trace; use tracing::trace;
@@ -1164,10 +1165,10 @@ impl SetDisks {
let rename_result = if should_fail_heal_rename(bucket, object, index) { let rename_result = if should_fail_heal_rename(bucket, object, index) {
Err(DiskError::Unexpected) Err(DiskError::Unexpected)
} else { } else {
disk.rename_data( disk.rename_data_borrowed(
RUSTFS_META_TMP_BUCKET, RUSTFS_META_TMP_BUCKET,
&tmp_id, &tmp_id,
parts_metadata[index].clone(), &parts_metadata[index],
bucket, bucket,
object, object,
) )
+546 -114
View File
@@ -27,11 +27,12 @@ use super::object::{
object_transaction_fencing_requested, old_data_cleanup_receipt_path, read_object_transaction_epoch_fence, object_transaction_fencing_requested, old_data_cleanup_receipt_path, read_object_transaction_epoch_fence,
verify_object_transaction_epoch_fence, verify_object_transaction_epoch_fence,
}; };
use crate::bucket::quota::reservation;
use crate::crash_inject::{self, CrashPoint}; use crate::crash_inject::{self, CrashPoint};
use crate::multipart_listing::paginate_multipart_listing; use crate::multipart_listing::paginate_multipart_listing;
use futures::{StreamExt, stream}; use futures::{StreamExt, stream};
use std::future::Future; use std::future::Future;
#[cfg(test)] #[cfg(any(test, feature = "test-util"))]
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration; use std::time::Duration;
use tokio::task::JoinSet; use tokio::task::JoinSet;
@@ -61,20 +62,21 @@ impl StaleMultipartCleanupGuard {
} }
} }
#[cfg(test)] #[cfg(any(test, feature = "test-util"))]
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MultipartCommitPause { pub enum MultipartCommitPause {
PutPartBeforeLockAcquire, PutPartBeforeLockAcquire,
PutPartBeforeLockLost, PutPartBeforeLockLost,
PutPartAfterRename, PutPartAfterRename,
BeforeLockLost, BeforeLockLost,
BeforeQuotaRename,
BeforeTransactionEpochVerify, BeforeTransactionEpochVerify,
BeforeObjectPublication, BeforeObjectPublication,
AfterObjectPublication, AfterObjectPublication,
AfterRename, AfterRename,
} }
#[cfg(test)] #[cfg(any(test, feature = "test-util"))]
struct MultipartCommitBarrierState { struct MultipartCommitBarrierState {
bucket: String, bucket: String,
object: String, object: String,
@@ -85,27 +87,22 @@ struct MultipartCommitBarrierState {
release: tokio::sync::Semaphore, release: tokio::sync::Semaphore,
} }
#[cfg(test)] #[cfg(any(test, feature = "test-util"))]
pub(crate) struct MultipartCommitBarrier { pub struct MultipartCommitBarrier {
state: Arc<MultipartCommitBarrierState>, state: Arc<MultipartCommitBarrierState>,
} }
#[cfg(test)] #[cfg(any(test, feature = "test-util"))]
static MULTIPART_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<MultipartCommitBarrierState>>>> = static MULTIPART_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<MultipartCommitBarrierState>>>> =
std::sync::OnceLock::new(); std::sync::OnceLock::new();
#[cfg(test)] #[cfg(any(test, feature = "test-util"))]
impl MultipartCommitBarrier { impl MultipartCommitBarrier {
pub(crate) fn install(bucket: &str, object: &str, pause: MultipartCommitPause) -> Self { pub fn install(bucket: &str, object: &str, pause: MultipartCommitPause) -> Self {
Self::install_for_arrivals(bucket, object, pause, 1) Self::install_for_arrivals(bucket, object, pause, 1)
} }
pub(crate) fn install_for_arrivals( pub fn install_for_arrivals(bucket: &str, object: &str, pause: MultipartCommitPause, expected_arrivals: usize) -> Self {
bucket: &str,
object: &str,
pause: MultipartCommitPause,
expected_arrivals: usize,
) -> Self {
assert!(expected_arrivals > 0, "multipart commit barrier must wait for at least one arrival"); assert!(expected_arrivals > 0, "multipart commit barrier must wait for at least one arrival");
let state = Arc::new(MultipartCommitBarrierState { let state = Arc::new(MultipartCommitBarrierState {
bucket: bucket.to_string(), bucket: bucket.to_string(),
@@ -126,7 +123,7 @@ impl MultipartCommitBarrier {
Self { state } Self { state }
} }
pub(crate) async fn wait_until_paused(&self) { pub async fn wait_until_paused(&self) {
tokio::time::timeout(Duration::from_secs(30), async { tokio::time::timeout(Duration::from_secs(30), async {
loop { loop {
let arrived = self.state.arrived.notified(); let arrived = self.state.arrived.notified();
@@ -140,12 +137,12 @@ impl MultipartCommitBarrier {
.expect("multipart completion should reach the deterministic commit barrier"); .expect("multipart completion should reach the deterministic commit barrier");
} }
pub(crate) fn release(&self) { pub fn release(&self) {
self.state.release.add_permits(self.state.expected_arrivals); self.state.release.add_permits(self.state.expected_arrivals);
} }
} }
#[cfg(test)] #[cfg(any(test, feature = "test-util"))]
impl Drop for MultipartCommitBarrier { impl Drop for MultipartCommitBarrier {
fn drop(&mut self) { fn drop(&mut self) {
self.release(); self.release();
@@ -159,7 +156,7 @@ impl Drop for MultipartCommitBarrier {
} }
} }
#[cfg(test)] #[cfg(any(test, feature = "test-util"))]
async fn pause_multipart_commit(bucket: &str, object: &str, pause: MultipartCommitPause) { async fn pause_multipart_commit(bucket: &str, object: &str, pause: MultipartCommitPause) {
let barrier = { let barrier = {
let mut slot = MULTIPART_COMMIT_BARRIER let mut slot = MULTIPART_COMMIT_BARRIER
@@ -1892,6 +1889,41 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
fi.parts = Vec::with_capacity(uploaded_parts.len()); fi.parts = Vec::with_capacity(uploaded_parts.len());
let quota_context = reservation::begin(
&self.ctx,
bucket,
object,
opts.quota_admission,
opts.data_movement,
self.pool_index,
self.set_index,
)
.await?;
let quota_mutation_fence = quota_context.is_enforced() || opts.quota_admission.is_some();
let preserve_replication_ciphertext = opts.replication_request
&& contains_key_str(&fi.metadata, rustfs_utils::http::SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT);
if quota_context.is_enforced() && preserve_replication_ciphertext {
return Err(Error::PartMissingOrCorrupt);
}
let transformed_object = fi.is_compressed() || should_persist_encryption_original_size(&fi.metadata);
let data_movement_actual_size = if opts.data_movement {
rustfs_utils::http::get_consistent_str(&opts.user_defined, SUFFIX_ACTUAL_SIZE)
.map(|value| {
value
.parse::<i64>()
.ok()
.filter(|value| *value >= 0)
.ok_or(Error::PartMissingOrCorrupt)
})
.transpose()?
} else {
None
};
let data_movement_actual_size_u64 = data_movement_actual_size
.map(u64::try_from)
.transpose()
.map_err(|_| Error::PartMissingOrCorrupt)?;
let mut object_size: usize = 0; let mut object_size: usize = 0;
let mut object_actual_size: i64 = 0; let mut object_actual_size: i64 = 0;
@@ -2018,17 +2050,29 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
checksum_combined.extend_from_slice(cs.raw.as_slice()); checksum_combined.extend_from_slice(cs.raw.as_slice());
} }
object_size += ext_part.size; object_size = object_size.checked_add(ext_part.size).ok_or(Error::PartMissingOrCorrupt)?;
if opts.quota_admission.is_some() && ext_part.actual_size < 0 { let unknown_actual_size_allowed = opts.data_movement && transformed_object && data_movement_actual_size.is_some()
|| opts.replication_request && !quota_context.is_enforced();
if ext_part.actual_size < 0 && !unknown_actual_size_allowed {
return Err(Error::PartMissingOrCorrupt); return Err(Error::PartMissingOrCorrupt);
} }
let normalized_actual_size = if ext_part.actual_size >= 0 && !transformed_object {
ext_part
.actual_size
.max(i64::try_from(ext_part.size).map_err(|_| Error::PartMissingOrCorrupt)?)
} else {
ext_part.actual_size
};
object_actual_size = object_actual_size object_actual_size = object_actual_size
.checked_add(ext_part.actual_size) .checked_add(normalized_actual_size)
.ok_or(Error::PartMissingOrCorrupt)?; .ok_or(Error::PartMissingOrCorrupt)?;
let mut completed_part = completed_multipart_object_part(p.part_num, ext_part);
fi.parts.push(completed_multipart_object_part(p.part_num, ext_part)); completed_part.actual_size = normalized_actual_size;
fi.parts.push(completed_part);
}
if !transformed_object && data_movement_actual_size.is_some_and(|actual_size| actual_size < object_actual_size) {
return Err(Error::PartMissingOrCorrupt);
} }
if let Some(wtcs) = opts.want_checksum.as_ref() { if let Some(wtcs) = opts.want_checksum.as_ref() {
if checksum_type.full_object_requested() { if checksum_type.full_object_requested() {
if wtcs.encoded != checksum.encoded { if wtcs.encoded != checksum.encoded {
@@ -2053,15 +2097,35 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
} }
} }
if let Some(admission) = opts.quota_admission { let declared_replication_actual_size = opts
let quota_operation_size = u64::try_from(object_actual_size).map_err(|_| Error::PartMissingOrCorrupt)?; .replication_request
if quota_operation_size > admission.remaining() { .then(|| get_str(&opts.user_defined, SUFFIX_ACTUAL_OBJECT_SIZE_CAP))
return Err(Error::QuotaExceeded { .flatten();
current: admission.current_usage(), let replication_actual_size = if opts.replication_request && quota_context.is_enforced() {
limit: admission.quota_limit(), let observed_size = u64::try_from(object_actual_size).map_err(|_| Error::PartMissingOrCorrupt)?;
}); let declared_cap = declared_replication_actual_size
} .as_deref()
} .map(|value| value.parse::<u64>().map_err(|_| Error::PartMissingOrCorrupt))
.transpose()?
.unwrap_or(0);
let declared_encryption_size = rustfs_utils::http::get_object_encryption_original_size(&fi.metadata)
.map_err(Error::other)?
.map(u64::try_from)
.transpose()
.map_err(|_| Error::PartMissingOrCorrupt)?
.unwrap_or(0);
Some(observed_size.max(declared_cap).max(declared_encryption_size))
} else {
None
};
let data_movement_quota_size = data_movement_actual_size_u64.filter(|_| quota_context.is_enforced());
let quota_new_size = match data_movement_quota_size.or(replication_actual_size) {
Some(size) => size.max(u64::try_from(object_size).map_err(|_| Error::PartMissingOrCorrupt)?),
None if quota_context.is_enforced() => u64::try_from(object_actual_size)
.map_err(|_| Error::PartMissingOrCorrupt)?
.max(u64::try_from(object_size).map_err(|_| Error::PartMissingOrCorrupt)?),
None => 0,
};
if let Some(rc_crc) = get_header_map(&opts.user_defined, SUFFIX_REPLICATION_SSEC_CRC) { if let Some(rc_crc) = get_header_map(&opts.user_defined, SUFFIX_REPLICATION_SSEC_CRC) {
if let Ok(rc_crc_bytes) = base64_simd::STANDARD.decode_to_vec(&rc_crc) { if let Ok(rc_crc_bytes) = base64_simd::STANDARD.decode_to_vec(&rc_crc) {
fi.checksum = Some(Bytes::from(rc_crc_bytes)); fi.checksum = Some(Bytes::from(rc_crc_bytes));
@@ -2113,20 +2177,6 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
); );
} }
let data_movement_actual_size = if opts.data_movement {
rustfs_utils::http::get_consistent_str(&opts.user_defined, SUFFIX_ACTUAL_SIZE)
.map(|value| {
value
.parse::<i64>()
.ok()
.filter(|value| *value >= 0)
.ok_or_else(|| Error::other("data movement actual size metadata is invalid"))
})
.transpose()?
} else {
None
};
if let Some(actual_size) = data_movement_actual_size { if let Some(actual_size) = data_movement_actual_size {
insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, actual_size.to_string()); insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, actual_size.to_string());
if persist_encryption_original_size { if persist_encryption_original_size {
@@ -2134,7 +2184,13 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
.insert("x-rustfs-encryption-original-size".to_string(), actual_size.to_string()); .insert("x-rustfs-encryption-original-size".to_string(), actual_size.to_string());
} }
} else if opts.replication_request { } else if opts.replication_request {
if let Some(actual_size) = get_str(&opts.user_defined, SUFFIX_ACTUAL_OBJECT_SIZE_CAP) { if let Some(actual_size) = replication_actual_size {
insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, actual_size.to_string());
if persist_encryption_original_size {
fi.metadata
.insert("x-rustfs-encryption-original-size".to_string(), actual_size.to_string());
}
} else if let Some(actual_size) = declared_replication_actual_size {
insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, actual_size.clone()); insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, actual_size.clone());
if persist_encryption_original_size { if persist_encryption_original_size {
fi.metadata fi.metadata
@@ -2324,8 +2380,41 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
} else { } else {
None None
}; };
let quota_old_size = if quota_context.is_enforced() {
if opts.data_movement {
quota_new_size
} else {
reservation::replaced_logical_size(&self, bucket, object, opts).await?
}
} else {
0
};
let quota_reservation = quota_context.reserve(quota_old_size, quota_new_size).await?;
let (commit_disks, quota_fence_tokens) = if quota_mutation_fence {
match Self::prepare_quota_mutation_fences(&shuffle_disks, bucket, object, write_quorum).await {
Ok((disks, tokens)) => {
for (metadata, token) in parts_metadatas.iter_mut().zip(tokens.iter().copied()) {
if let Some(token) = token {
insert_str(
&mut metadata.metadata,
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
token.as_uuid().to_string(),
);
}
}
(disks, tokens)
}
Err(err) => {
quota_reservation.abort().await;
return Err(err);
}
}
} else {
(shuffle_disks.clone(), vec![None; shuffle_disks.len()])
};
let transaction_epoch = let transaction_epoch =
transaction_epoch_fence.map(|_| assign_object_transaction_epoch(&shuffle_disks, &mut parts_metadatas)); transaction_epoch_fence.map(|_| assign_object_transaction_epoch(&commit_disks, &mut parts_metadatas));
let commit_set = self.clone(); let commit_set = self.clone();
let commit_bucket = bucket.to_owned(); let commit_bucket = bucket.to_owned();
@@ -2333,49 +2422,152 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
let commit_upload_id = upload_id.to_owned(); let commit_upload_id = upload_id.to_owned();
let commit_upload_id_path = upload_id_path.clone(); let commit_upload_id_path = upload_id_path.clone();
let commit_version_suspended = opts.version_suspended; let commit_version_suspended = opts.version_suspended;
let commit_versioned = opts.versioned;
let commit_version_id = opts.version_id.clone();
let commit_namespace_lock_fence = opts.namespace_lock_fence.clone();
let commit_bucket_lifecycle_lock_fence = opts.bucket_lifecycle_lock_fence.clone();
let commit_is_versioned = opts.versioned || opts.version_suspended; let commit_is_versioned = opts.versioned || opts.version_suspended;
let commit_capacity_scope_token = opts.capacity_scope_token; let commit_capacity_scope_token = opts.capacity_scope_token;
let commit_object_lock_guard = object_lock_guard.take(); let commit_object_lock_guard = object_lock_guard.take();
let detach_commit_owner = commit_object_lock_guard.is_some() || upload_guard.is_some(); let detach_commit_owner = commit_object_lock_guard.is_some() || upload_guard.is_some() || quota_mutation_fence;
let commit = async move { let commit = async move {
let _object_lock_guard = commit_object_lock_guard; let _object_lock_guard = commit_object_lock_guard;
let _upload_guard = upload_guard; let _upload_guard = upload_guard;
let mut quota_reservation = quota_reservation;
let complete_tail_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now); let complete_tail_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
// Crash-consistency injection: hard power loss after the upload is fully let pre_rename_result: Result<()> = async {
// staged and locked but before the authoritative rename_data commit. No // Crash-consistency injection: hard power loss after the upload is fully
// disk has moved the staged data, so a crash here must leave any prior // staged and locked but before the authoritative rename_data commit. No
// committed version byte-for-byte intact (rustfs/backlog#864) and the // disk has moved the staged data, so a crash here must leave any prior
// upload fully retryable. Compiles to a no-op outside `#[cfg(test)]`. // committed version byte-for-byte intact (rustfs/backlog#864) and the
if crash_inject::should_crash_at(CrashPoint::MultipartBeforeCommitRename, &commit_object) { // upload fully retryable. Compiles to a no-op outside `#[cfg(test)]`.
return Err(StorageError::Unexpected); if crash_inject::should_crash_at(CrashPoint::MultipartBeforeCommitRename, &commit_object) {
return Err(StorageError::Unexpected);
}
quota_reservation.mark_commit_started().await?;
#[cfg(any(test, feature = "test-util"))]
pause_multipart_commit(&commit_bucket, &commit_object, MultipartCommitPause::BeforeQuotaRename).await;
if quota_reservation.is_lock_lost()
|| !quota_reservation.capability_proof_matches()
|| _object_lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| _upload_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| commit_namespace_lock_fence
.as_ref()
.is_some_and(NamespaceLockFence::is_lock_lost)
|| commit_bucket_lifecycle_lock_fence
.as_ref()
.is_some_and(NamespaceLockFence::is_lock_lost)
{
return Err(StorageError::NamespaceLockQuorumUnavailable {
mode: "quota_reservation",
bucket: commit_bucket.clone(),
object: commit_object.clone(),
required: 1,
achieved: 0,
});
}
let restore_opts = ObjectOptions {
version_id: commit_version_id.clone(),
versioned: commit_versioned,
version_suspended: commit_version_suspended,
no_lock: true,
..Default::default()
};
commit_set
.require_current_restore_operation_id(
&commit_bucket,
&commit_object,
&restore_opts,
expected_restore_operation_id,
"complete_multipart_upload_quota_reservation",
)
.await?;
if let Some(proof) = transaction_fencing_proof.as_ref()
&& !object_transaction_fencing_fleet_proof_matches(proof)
{
return Err(Error::other(
"object transaction fencing fleet capability changed during complete_multipart_upload",
));
}
if let Some(expected) = transaction_epoch_fence {
#[cfg(test)]
pause_multipart_commit(&commit_bucket, &commit_object, MultipartCommitPause::BeforeTransactionEpochVerify)
.await;
verify_object_transaction_epoch_fence(&commit_set, &commit_bucket, &commit_object, expected).await?;
}
if quota_reservation.is_lock_lost()
|| !quota_reservation.capability_proof_matches()
|| _object_lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| _upload_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| commit_namespace_lock_fence
.as_ref()
.is_some_and(NamespaceLockFence::is_lock_lost)
|| commit_bucket_lifecycle_lock_fence
.as_ref()
.is_some_and(NamespaceLockFence::is_lock_lost)
{
return Err(StorageError::NamespaceLockQuorumUnavailable {
mode: "quota_reservation",
bucket: commit_bucket.clone(),
object: commit_object.clone(),
required: 1,
achieved: 0,
});
}
Ok(())
}
.await;
if let Err(err) = pre_rename_result {
SetDisks::abort_quota_reservation_after_fence(
quota_reservation,
&commit_disks,
&quota_fence_tokens,
&commit_bucket,
&commit_object,
write_quorum,
quota_mutation_fence,
)
.await;
return Err(err);
} }
// The trailing `_` drops the rename_data old-size backfill // The trailing `_` drops the rename_data old-size backfill
// (rustfs/backlog#1009): CompleteMultipartUpload keeps its pre-commit // (rustfs/backlog#1009): CompleteMultipartUpload keeps its pre-commit
// `get_object_info` lookup, so the backfill has no consumer here yet. // `get_object_info` lookup, so the backfill has no consumer here yet.
if let Some(proof) = transaction_fencing_proof.as_ref() Self::assign_rename_data_indexes(&mut parts_metadatas);
&& !object_transaction_fencing_fleet_proof_matches(proof) let rename_result = SetDisks::rename_data_owned(
{ &commit_disks,
return Err(Error::other(
"object transaction fencing fleet capability changed during complete_multipart_upload",
));
}
if let Some(expected) = transaction_epoch_fence {
#[cfg(test)]
pause_multipart_commit(&commit_bucket, &commit_object, MultipartCommitPause::BeforeTransactionEpochVerify).await;
verify_object_transaction_epoch_fence(&commit_set, &commit_bucket, &commit_object, expected).await?;
}
let (online_disks, convergence, op_old_dir, cleanup_disks, _) = SetDisks::rename_data(
&shuffle_disks,
RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET,
&commit_upload_id_path, &commit_upload_id_path,
&parts_metadatas, parts_metadatas,
&commit_bucket, &commit_bucket,
&commit_object, &commit_object,
write_quorum, write_quorum,
) )
.await?; .await;
if quota_mutation_fence {
let _ = SetDisks::release_quota_mutation_fences(
&commit_disks,
&quota_fence_tokens,
&commit_bucket,
&commit_object,
write_quorum,
)
.await;
}
if rename_result.is_ok() {
quota_reservation.commit().await;
}
let rename_commit = match rename_result {
Ok(result) => result,
Err(err) => return Err(err.into()),
};
let online_disks = rename_commit.online_disks;
let convergence = rename_commit.convergence;
let op_old_dir = rename_commit.data_dir;
let cleanup_disks = rename_commit.cleanup_disks;
let committed_file_info = rename_commit.committed_file_info;
// Detach admission before any post-commit await: client cancellation // Detach admission before any post-commit await: client cancellation
// must not couple durable convergence repair to cleanup work. // must not couple durable convergence repair to cleanup work.
@@ -2420,9 +2612,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
return Err(StorageError::Unexpected); return Err(StorageError::Unexpected);
} }
if let Some(committed_slot) = online_disks.iter().position(Option::is_some) { fi = committed_file_info;
fi = parts_metadatas[committed_slot].clone();
}
let committed_dir = fi.data_dir.unwrap_or_default().to_string(); let committed_dir = fi.data_dir.unwrap_or_default().to_string();
commit_set.record_capacity_scope_if_needed(commit_capacity_scope_token, &online_disks); commit_set.record_capacity_scope_if_needed(commit_capacity_scope_token, &online_disks);
@@ -2927,12 +3117,34 @@ mod tests {
content: &[u8], content: &[u8],
actual_size: i64, actual_size: i64,
) -> CompletePart { ) -> CompletePart {
put_test_part_with_opts(
set_disks,
bucket,
object,
upload_id,
part_number,
(content, actual_size),
&ObjectOptions::default(),
)
.await
}
async fn put_test_part_with_opts(
set_disks: &Arc<SetDisks>,
bucket: &str,
object: &str,
upload_id: &str,
part_number: usize,
part: (&[u8], i64),
opts: &ObjectOptions,
) -> CompletePart {
let (content, actual_size) = part;
let mut reader = PutObjReader::new( let mut reader = PutObjReader::new(
HashReader::from_stream(Cursor::new(content.to_vec()), content.len() as i64, actual_size, None, None, false) HashReader::from_stream(Cursor::new(content.to_vec()), content.len() as i64, actual_size, None, None, false)
.expect("hash reader should be constructed"), .expect("hash reader should be constructed"),
); );
let part = set_disks let part = set_disks
.put_object_part(bucket, object, upload_id, part_number, &mut reader, &ObjectOptions::default()) .put_object_part(bucket, object, upload_id, part_number, &mut reader, opts)
.await .await
.expect("uploading the part should succeed"); .expect("uploading the part should succeed");
CompletePart { CompletePart {
@@ -3162,7 +3374,7 @@ mod tests {
let (upload_id, parts) = let (upload_id, parts) =
stage_upload_with_create_opts(&set_disks, bucket, object, &payload, &ObjectOptions::default()).await; stage_upload_with_create_opts(&set_disks, bucket, object, &payload, &ObjectOptions::default()).await;
let mut denied_opts = ObjectOptions::default(); let mut denied_opts = ObjectOptions::default();
assert!(denied_opts.set_quota_admission(100, 4195)); assert!(denied_opts.set_quota_admission(100, 4180));
let err = set_disks let err = set_disks
.clone() .clone()
@@ -3173,7 +3385,7 @@ mod tests {
err, err,
StorageError::QuotaExceeded { StorageError::QuotaExceeded {
current: 100, current: 100,
limit: 4195 limit: 4180
} }
)); ));
@@ -3191,7 +3403,7 @@ mod tests {
); );
let mut allowed_opts = ObjectOptions::default(); let mut allowed_opts = ObjectOptions::default();
assert!(allowed_opts.set_quota_admission(100, 4196)); assert!(allowed_opts.set_quota_admission(100, 4181));
let completed = set_disks let completed = set_disks
.clone() .clone()
.complete_multipart_upload(bucket, object, &upload_id, parts, &allowed_opts) .complete_multipart_upload(bucket, object, &upload_id, parts, &allowed_opts)
@@ -3232,6 +3444,120 @@ mod tests {
); );
} }
#[tokio::test]
async fn replication_quota_uses_server_observed_part_size_as_lower_bound() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-replication-quota-bucket";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await;
let mut create_opts = ObjectOptions::default();
insert_str(&mut create_opts.user_defined, SUFFIX_COMPRESSION, "S2".to_string());
let upload = set_disks
.new_multipart_upload(bucket, object, &create_opts)
.await
.expect("replication multipart upload should be created");
let part = put_test_part(&set_disks, bucket, object, &upload.upload_id, 1, &[0x72; 4096], 1).await;
let mut complete_opts = ObjectOptions {
replication_request: true,
..Default::default()
};
assert!(complete_opts.set_quota_admission(0, 4095));
let err = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part.clone()], &complete_opts)
.await
.expect_err("a forged tiny replication logical size must not reduce quota admission");
assert!(matches!(err, StorageError::QuotaExceeded { current: 0, limit: 4095 }));
assert!(
set_disks
.check_upload_id_exists(bucket, object, &upload.upload_id, false)
.await
.is_ok(),
"quota rejection must leave replicated multipart parts retryable"
);
assert!(complete_opts.set_quota_admission(0, 4096));
let completed = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part], &complete_opts)
.await
.expect("the physical safety boundary should admit the transformed replica");
assert_eq!(completed.get_actual_size().expect("replica logical size should parse"), 1);
}
#[tokio::test]
async fn direct_multipart_quota_uses_server_observed_part_size_as_lower_bound() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-direct-quota-bucket";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await;
let upload = set_disks
.new_multipart_upload(bucket, object, &ObjectOptions::default())
.await
.expect("multipart upload should be created");
let part = put_test_part(&set_disks, bucket, object, &upload.upload_id, 1, &[0x73; 4096], 1).await;
let mut complete_opts = ObjectOptions::default();
assert!(complete_opts.set_quota_admission(0, 4095));
let err = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part], &complete_opts)
.await
.expect_err("a forged tiny direct logical size must not reduce quota admission");
assert!(matches!(err, StorageError::QuotaExceeded { current: 0, limit: 4095 }));
assert!(
set_disks
.check_upload_id_exists(bucket, object, &upload.upload_id, false)
.await
.is_ok(),
"quota rejection must leave direct multipart parts retryable"
);
}
#[tokio::test]
async fn quota_rejects_ciphertext_replication_without_a_server_observed_logical_size() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-compressed-ciphertext-quota-bucket";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await;
let mut create_opts = ObjectOptions::default();
insert_str(&mut create_opts.user_defined, SUFFIX_COMPRESSION, "S2".to_string());
insert_str(
&mut create_opts.user_defined,
rustfs_utils::http::SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT,
"true".to_string(),
);
let upload = set_disks
.new_multipart_upload(bucket, object, &create_opts)
.await
.expect("ciphertext multipart upload should be created");
let payload = vec![0x74; 4096];
let part = put_test_part(&set_disks, bucket, object, &upload.upload_id, 1, &payload, 1).await;
let mut complete_opts = ObjectOptions {
replication_request: true,
..Default::default()
};
assert!(complete_opts.set_quota_admission(0, u64::MAX));
let err = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part], &complete_opts)
.await
.expect_err("ciphertext replication has no server-observed logical quota size");
assert!(matches!(err, StorageError::PartMissingOrCorrupt));
assert!(
set_disks
.check_upload_id_exists(bucket, object, &upload.upload_id, false)
.await
.is_ok(),
"rejection must leave ciphertext multipart parts retryable"
);
}
#[tokio::test] #[tokio::test]
async fn complete_multipart_quota_rejects_invalid_logical_sizes() { async fn complete_multipart_quota_rejects_invalid_logical_sizes() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await; let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
@@ -3554,7 +3880,6 @@ mod tests {
async fn data_movement_complete_accepts_unknown_compressed_part_actual_size() { async fn data_movement_complete_accepts_unknown_compressed_part_actual_size() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await; let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "data-movement-unknown-actual-size-bucket"; let bucket = "data-movement-unknown-actual-size-bucket";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await; make_bucket_on_all(&disk_stores, bucket).await;
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
@@ -3568,6 +3893,63 @@ mod tests {
rustfs_utils::http::SUFFIX_DATA_MOVEMENT_UPLOAD, rustfs_utils::http::SUFFIX_DATA_MOVEMENT_UPLOAD,
"source-generation".to_string(), "source-generation".to_string(),
); );
for (object, quota_limit) in [("without-quota", None), ("with-quota", Some(u64::MAX))] {
let create_opts = ObjectOptions {
data_movement: true,
user_defined: metadata.clone(),
..Default::default()
};
let upload = set_disks
.new_multipart_upload(bucket, object, &create_opts)
.await
.expect("data movement upload should be created");
let mut completed_parts = Vec::new();
for (number, actual_size) in [(1, -1), (2, 1)] {
let mut reader = PutObjReader::new(
HashReader::from_stream(Cursor::new(vec![number as u8]), 1, actual_size, None, None, false)
.expect("part reader should be constructed"),
);
let part = set_disks
.put_object_part(bucket, object, &upload.upload_id, number, &mut reader, &create_opts)
.await
.expect("data movement part should be written");
completed_parts.push(CompletePart {
part_num: number,
etag: part.etag,
..Default::default()
});
}
let missing_size_err = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, completed_parts.clone(), &create_opts)
.await
.expect_err("data movement completion must require an authoritative total size");
assert!(matches!(missing_size_err, StorageError::PartMissingOrCorrupt));
let mut complete_opts = create_opts.clone();
rustfs_utils::http::insert_str(
&mut complete_opts.user_defined,
rustfs_utils::http::SUFFIX_ACTUAL_SIZE,
"2".to_string(),
);
if let Some(quota_limit) = quota_limit {
assert!(complete_opts.set_quota_admission(0, quota_limit));
}
let completion = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, completed_parts, &complete_opts)
.await;
let completed = completion.expect("data movement completion should accept the persisted unknown-size sentinel");
assert_eq!(completed.parts[0].actual_size, -1);
assert_eq!(completed.get_actual_size().expect("completed object actual size"), 2);
}
let object = "all-unknown-with-quota";
let create_opts = ObjectOptions { let create_opts = ObjectOptions {
data_movement: true, data_movement: true,
user_defined: metadata.clone(), user_defined: metadata.clone(),
@@ -3577,43 +3959,93 @@ mod tests {
.new_multipart_upload(bucket, object, &create_opts) .new_multipart_upload(bucket, object, &create_opts)
.await .await
.expect("data movement upload should be created"); .expect("data movement upload should be created");
let part = put_test_part_with_opts(&set_disks, bucket, object, &upload.upload_id, 1, (&[0x40], -1), &create_opts).await;
let mut completed_parts = Vec::new(); let mut complete_opts = create_opts;
for (number, actual_size) in [(1, -1), (2, 1)] { rustfs_utils::http::insert_str(&mut complete_opts.user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "2".to_string());
let mut reader = PutObjReader::new( assert!(complete_opts.set_quota_admission(0, u64::MAX));
HashReader::from_stream(Cursor::new(vec![number as u8]), 1, actual_size, None, None, false)
.expect("part reader should be constructed"),
);
let part = set_disks
.put_object_part(bucket, object, &upload.upload_id, number, &mut reader, &create_opts)
.await
.expect("data movement part should be written");
completed_parts.push(CompletePart {
part_num: number,
etag: part.etag,
..Default::default()
});
}
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "2".to_string());
let completed = set_disks let completed = set_disks
.clone() .clone()
.complete_multipart_upload( .complete_multipart_upload(bucket, object, &upload.upload_id, vec![part], &complete_opts)
bucket,
object,
&upload.upload_id,
completed_parts,
&ObjectOptions {
data_movement: true,
user_defined: metadata,
..Default::default()
},
)
.await .await
.expect("data movement completion should accept the persisted unknown-size sentinel"); .expect("quota must accept an all-unknown data movement upload with an authoritative total");
assert_eq!(completed.parts[0].actual_size, -1); assert_eq!(completed.parts[0].actual_size, -1);
assert_eq!(completed.get_actual_size().expect("completed object actual size"), 2); assert_eq!(completed.get_actual_size().expect("completed object actual size"), 2);
let object = "legacy-zero-fallback";
let create_opts = ObjectOptions {
data_movement: true,
user_defined: metadata.clone(),
..Default::default()
};
let upload = set_disks
.new_multipart_upload(bucket, object, &create_opts)
.await
.expect("data movement upload should be created");
let part =
put_test_part_with_opts(&set_disks, bucket, object, &upload.upload_id, 1, (&[0x41; 128], 128), &create_opts).await;
let mut complete_opts = create_opts;
rustfs_utils::http::insert_str(
&mut complete_opts.user_defined,
rustfs_utils::http::SUFFIX_ACTUAL_SIZE,
"100".to_string(),
);
assert!(complete_opts.set_quota_admission(0, u64::MAX));
let completed = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part], &complete_opts)
.await
.expect("legacy zero-to-physical part fallback must remain migratable");
assert_eq!(completed.parts[0].actual_size, 128);
assert_eq!(completed.get_actual_size().expect("completed object actual size"), 100);
let object = "inconsistent-untransformed-total";
let mut untransformed_metadata = metadata.clone();
rustfs_utils::http::remove_str(&mut untransformed_metadata, rustfs_utils::http::SUFFIX_COMPRESSION);
let create_opts = ObjectOptions {
data_movement: true,
user_defined: untransformed_metadata,
..Default::default()
};
let upload = set_disks
.new_multipart_upload(bucket, object, &create_opts)
.await
.expect("data movement upload should be created");
let part = put_test_part_with_opts(&set_disks, bucket, object, &upload.upload_id, 1, (&[0x42; 2], 2), &create_opts).await;
let mut complete_opts = create_opts;
rustfs_utils::http::insert_str(&mut complete_opts.user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "1".to_string());
let err = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part], &complete_opts)
.await
.expect_err("authoritative total below known logical part sizes must fail closed");
assert!(matches!(err, StorageError::PartMissingOrCorrupt));
for (object, declared_size) in [("invalid-total", "invalid"), ("negative-total", "-1")] {
let create_opts = ObjectOptions {
data_movement: true,
user_defined: metadata.clone(),
..Default::default()
};
let upload = set_disks
.new_multipart_upload(bucket, object, &create_opts)
.await
.expect("data movement upload should be created");
let part =
put_test_part_with_opts(&set_disks, bucket, object, &upload.upload_id, 1, (&[0x41], 1), &create_opts).await;
let mut complete_opts = create_opts.clone();
rustfs_utils::http::insert_str(
&mut complete_opts.user_defined,
rustfs_utils::http::SUFFIX_ACTUAL_SIZE,
declared_size.to_string(),
);
let err = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part], &complete_opts)
.await
.expect_err("invalid authoritative total size must fail closed");
assert!(matches!(err, StorageError::PartMissingOrCorrupt));
}
} }
async fn assert_complete_first_linearizes(bucket: &'static str, object: &'static str, create_opts: ObjectOptions) { async fn assert_complete_first_linearizes(bucket: &'static str, object: &'static str, create_opts: ObjectOptions) {
File diff suppressed because it is too large Load Diff
+33 -3
View File
@@ -3886,13 +3886,15 @@ mod tests {
assert!(metadata_early_stop_permitted(true, true, false, "", false, false)); assert!(metadata_early_stop_permitted(true, true, false, "", false, false));
// observe=false (non-observed fanout) also disables early-stop. // observe=false (non-observed fanout) also disables early-stop.
assert!(!metadata_early_stop_permitted(true, false, false, "", false, false)); assert!(!metadata_early_stop_permitted(true, false, false, "", false, false));
assert!(!metadata_early_stop_permitted(true, true, true, "", false, false)); // Whole/latest data-read metadata is now allowed by default;
// the inline verifier still decides whether it can stop early.
assert!(metadata_early_stop_permitted(true, true, true, "", false, false));
}, },
); );
} }
#[test] #[test]
fn metadata_early_stop_keeps_data_reads_opt_in_by_default() { fn metadata_early_stop_allows_safe_data_reads_by_default() {
temp_env::with_vars( temp_env::with_vars(
[ [
(ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE, Some("true")), (ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE, Some("true")),
@@ -3900,7 +3902,7 @@ mod tests {
(ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE, None), (ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE, None),
], ],
|| { || {
assert!(!should_allow_metadata_early_stop(true, "", false, false)); assert!(should_allow_metadata_early_stop(true, "", false, false));
assert!(!should_allow_metadata_early_stop(true, "version-id", false, false)); assert!(!should_allow_metadata_early_stop(true, "version-id", false, false));
assert!(should_allow_metadata_early_stop(false, "", false, false)); assert!(should_allow_metadata_early_stop(false, "", false, false));
assert!(!should_allow_metadata_early_stop(false, "version-id", false, false)); assert!(!should_allow_metadata_early_stop(false, "version-id", false, false));
@@ -3932,6 +3934,34 @@ mod tests {
); );
} }
#[test]
fn metadata_early_stop_bounded_fanout_defaults_to_disabled() {
temp_env::with_vars(
[
(ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE, Some("true")),
(ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE, None),
(ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT, None),
],
|| {
assert!(is_get_metadata_data_read_early_stop_enabled());
assert!(!is_get_metadata_early_stop_bounded_fanout_enabled());
},
);
temp_env::with_vars([(ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT, Some("true"))], || {
assert!(is_get_metadata_early_stop_bounded_fanout_enabled());
});
temp_env::with_vars(
[
(ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE, Some("false")),
(ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT, Some("false")),
],
|| {
assert!(!is_get_metadata_data_read_early_stop_enabled());
assert!(!is_get_metadata_early_stop_bounded_fanout_enabled());
},
);
}
#[test] #[test]
fn metadata_early_stop_rejects_healing_and_free_version_requests() { fn metadata_early_stop_rejects_healing_and_free_version_requests() {
temp_env::with_vars( temp_env::with_vars(
+26
View File
@@ -1174,6 +1174,32 @@ mod tests {
} }
} }
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn quota_object_fence_ignores_an_unrelated_offline_pool() {
let temp_dir = tempfile::tempdir().expect("create quota fence store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "quota-object-fence", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("quota-object-fence-{}", uuid::Uuid::new_v4());
let object = "object.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create quota fence bucket");
store.pools[1].disk_set[0].disks.write().await.fill(None);
crate::bucket::quota::reservation::fence_namespace_mutations_for_test(&store, &bucket, object, Some((0, 0)))
.await
.expect("the selected pool fence should ignore an unrelated offline pool");
let err = crate::bucket::quota::reservation::fence_namespace_mutations_for_test(&store, &bucket, object, None)
.await
.expect_err("legacy reservations must conservatively fence every pool");
assert!(matches!(err, StorageError::ErasureWriteQuorum));
shutdown.cancel();
}
#[tokio::test] #[tokio::test]
#[serial_test::serial(storage_class_env)] #[serial_test::serial(storage_class_env)]
async fn tag_updates_skip_active_rebalance_source_pool() { async fn tag_updates_skip_active_rebalance_source_pool() {
-81
View File
@@ -1,81 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
impl ECStore {
#[instrument(level = "trace", skip(self))]
#[allow(clippy::too_many_arguments)]
pub(super) async fn handle_list_objects_v2(
self: Arc<Self>,
bucket: &str,
prefix: &str,
continuation_token: Option<String>,
delimiter: Option<String>,
max_keys: i32,
fetch_owner: bool,
start_after: Option<String>,
incl_deleted: bool,
) -> Result<ListObjectsV2Info> {
self.inner_list_objects_v2(
bucket,
prefix,
continuation_token,
delimiter,
max_keys,
fetch_owner,
start_after,
incl_deleted,
)
.await
}
#[instrument(skip(self))]
pub(super) async fn handle_list_object_versions(
self: Arc<Self>,
bucket: &str,
prefix: &str,
marker: Option<String>,
version_marker: Option<String>,
delimiter: Option<String>,
max_keys: i32,
) -> Result<ListObjectVersionsInfo> {
self.inner_list_object_versions(bucket, prefix, marker, version_marker, delimiter, max_keys)
.await
}
pub(crate) async fn list_object_versions_for_lifecycle(
self: Arc<Self>,
bucket: &str,
prefix: &str,
marker: Option<String>,
version_marker: Option<String>,
delimiter: Option<String>,
max_keys: i32,
) -> Result<ListObjectVersionsInfo> {
self.inner_list_object_versions_for_lifecycle(bucket, prefix, marker, version_marker, delimiter, max_keys)
.await
}
pub(super) async fn handle_walk(
self: Arc<Self>,
rx: CancellationToken,
bucket: &str,
prefix: &str,
result: tokio::sync::mpsc::Sender<ObjectInfoOrErr>,
opts: WalkOptions,
) -> Result<()> {
self.walk_internal(rx, bucket, prefix, result, opts).await
}
}
+2 -2
View File
@@ -641,7 +641,7 @@ pub(crate) fn observe_scanner_namespace_mutations(bucket: &str, delta: u64) {
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| Some(current.saturating_add(delta))); .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| Some(current.saturating_add(delta)));
} }
pub(super) async fn observe_list_objects_mutation(store: &ECStore, bucket: &str) -> u64 { pub(crate) async fn observe_list_objects_mutation(store: &ECStore, bucket: &str) -> u64 {
observe_list_objects_mutations(store, bucket, 1).await.unwrap_or_default() observe_list_objects_mutations(store, bucket, 1).await.unwrap_or_default()
} }
@@ -3845,7 +3845,7 @@ impl ECStore {
.await .await
} }
pub(crate) async fn inner_list_object_versions_for_lifecycle( pub(crate) async fn list_object_versions_for_lifecycle(
self: Arc<Self>, self: Arc<Self>,
bucket: &str, bucket: &str,
prefix: &str, prefix: &str,
+3 -4
View File
@@ -148,7 +148,6 @@ mod heal_walk;
pub use heal_walk::HealWalkVersion; pub use heal_walk::HealWalkVersion;
mod init; mod init;
pub(crate) mod init_format; pub(crate) mod init_format;
mod list;
pub(crate) mod list_objects; pub(crate) mod list_objects;
mod multipart; mod multipart;
mod object; mod object;
@@ -601,7 +600,7 @@ impl crate::storage_api_contracts::list::ListOperations for ECStore {
start_after: Option<String>, start_after: Option<String>,
incl_deleted: bool, incl_deleted: bool,
) -> Result<ListObjectsV2Info> { ) -> Result<ListObjectsV2Info> {
self.handle_list_objects_v2( self.inner_list_objects_v2(
bucket, bucket,
prefix, prefix,
continuation_token, continuation_token,
@@ -624,7 +623,7 @@ impl crate::storage_api_contracts::list::ListOperations for ECStore {
delimiter: Option<String>, delimiter: Option<String>,
max_keys: i32, max_keys: i32,
) -> Result<ListObjectVersionsInfo> { ) -> Result<ListObjectVersionsInfo> {
self.handle_list_object_versions(bucket, prefix, marker, version_marker, delimiter, max_keys) self.inner_list_object_versions(bucket, prefix, marker, version_marker, delimiter, max_keys)
.await .await
} }
@@ -636,7 +635,7 @@ impl crate::storage_api_contracts::list::ListOperations for ECStore {
result: tokio::sync::mpsc::Sender<ObjectInfoOrErr>, result: tokio::sync::mpsc::Sender<ObjectInfoOrErr>,
opts: WalkOptions, opts: WalkOptions,
) -> Result<()> { ) -> Result<()> {
self.handle_walk(rx, bucket, prefix, result, opts).await self.walk_internal(rx, bucket, prefix, result, opts).await
} }
} }
+4 -3
View File
@@ -4741,9 +4741,10 @@ mod tests {
#[tokio::test] #[tokio::test]
#[serial_test::serial(body_cache_hook)] #[serial_test::serial(body_cache_hook)]
async fn select_snapshot_rejects_latest_versioned_delete_marker_during_prepare() { async fn select_snapshot_rejects_latest_versioned_delete_marker_during_prepare() {
let (_first_dirs, first_set) = make_local_set_disks(4, 2).await; let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
let (_second_dirs, second_set) = make_local_set_disks(4, 2).await; let (_first_dirs, first_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
let store = new_prepared_reader_test_store(&[Arc::clone(&first_set), Arc::clone(&second_set)]).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 bucket = "select-snapshot-latest-delete-marker"; let bucket = "select-snapshot-latest-delete-marker";
let object = "versioned-object.bin"; let object = "versioned-object.bin";
let versioned_opts = ObjectOptions { let versioned_opts = ObjectOptions {
@@ -157,6 +157,18 @@ fn ecstore_implements_storage_list_operations_contract() {
assert!(storage_list_operations_type_name::<ECStore>().ends_with("::ECStore")); assert!(storage_list_operations_type_name::<ECStore>().ends_with("::ECStore"));
} }
#[test]
fn ecstore_pools_expose_storage_list_operations_contract() {
fn assert_contract(store: &ECStore) {
let future = store.pools[0]
.clone()
.list_objects_v2("bucket", "", None, None, 1, false, None, false);
drop(future);
}
let _ = assert_contract;
}
#[test] #[test]
fn ecstore_implements_storage_multipart_operations_contract() { fn ecstore_implements_storage_multipart_operations_contract() {
assert!(storage_multipart_operations_type_name::<ECStore>().ends_with("::ECStore")); assert!(storage_multipart_operations_type_name::<ECStore>().ends_with("::ECStore"));
+1 -4
View File
@@ -135,10 +135,7 @@ impl FileMeta {
let i = buf.len() as u64; let i = buf.len() as u64;
// check version, buf = buf[8..] // check version, buf = buf[8..]
let (buf, _, _) = Self::check_xl2_v1(buf).map_err(|e| { let (buf, _, _) = Self::check_xl2_v1(buf)?;
error!("failed to check XL2 v1 format: {}", e);
e
})?;
if buf.len() < 5 { if buf.len() < 5 {
error!( error!(
+7 -2
View File
@@ -82,8 +82,8 @@ impl Error {
/// Whether a heal operation can be retried without changing its inputs. /// Whether a heal operation can be retried without changing its inputs.
pub(crate) fn is_recoverable_heal(&self) -> bool { pub(crate) fn is_recoverable_heal(&self) -> bool {
match self { match self {
Error::TaskCancelled => false, Error::TaskCancelled | Error::TaskTimeout => false,
Error::TaskTimeout | Error::TransientSkip { .. } => true, Error::TransientSkip { .. } => true,
Error::Storage(err) => { Error::Storage(err) => {
err.is_quorum_error() err.is_quorum_error()
|| matches!( || matches!(
@@ -165,4 +165,9 @@ mod tests {
assert!(Error::Storage(EcstoreError::DiskNotFound).is_recoverable_heal()); assert!(Error::Storage(EcstoreError::DiskNotFound).is_recoverable_heal());
assert!(Error::Storage(EcstoreError::VolumeNotFound).is_recoverable_heal()); assert!(Error::Storage(EcstoreError::VolumeNotFound).is_recoverable_heal());
} }
#[test]
fn task_timeout_is_terminal() {
assert!(!Error::TaskTimeout.is_recoverable_heal());
}
} }
+30 -20
View File
@@ -673,6 +673,12 @@ fn retry_request_for_result(task: &HealTask, result: &Result<()>) -> Option<(Hea
Some((request, delay, error)) Some((request, delay, error))
} }
async fn retry_request_for_result_with_budget(task: &HealTask, result: &Result<()>) -> Option<(HealRequest, Duration, String)> {
let (_, delay, error) = retry_request_for_result(task, result)?;
let request = task.retry_request_with_remaining_timeout().await.ok()?;
Some((request, delay, error))
}
fn recoverable_heal_retry_delay(retry_attempt: u32) -> Duration { fn recoverable_heal_retry_delay(retry_attempt: u32) -> Duration {
let retry_attempt = retry_attempt.clamp(1, 5); let retry_attempt = retry_attempt.clamp(1, 5);
let delay = Duration::from_secs(2_u64.saturating_pow(retry_attempt)); let delay = Duration::from_secs(2_u64.saturating_pow(retry_attempt));
@@ -690,7 +696,7 @@ pub struct HealConfig {
pub max_concurrent_heals: usize, pub max_concurrent_heals: usize,
/// Maximum concurrent heal tasks allowed for a single erasure set /// Maximum concurrent heal tasks allowed for a single erasure set
pub max_concurrent_per_set: usize, pub max_concurrent_per_set: usize,
/// Task timeout /// Aggregate task execution timeout across recoverable retries
pub task_timeout: Duration, pub task_timeout: Duration,
/// Queue size /// Queue size
pub queue_size: usize, pub queue_size: usize,
@@ -3106,7 +3112,7 @@ impl HealManager {
"Heal scheduler task started" "Heal scheduler task started"
); );
let result = task.execute().await; let result = task.execute().await;
let retry_request = retry_request_for_result(task.as_ref(), &result); let retry_request = retry_request_for_result_with_budget(task.as_ref(), &result).await;
match &result { match &result {
Ok(_) => { Ok(_) => {
debug!( debug!(
@@ -4539,6 +4545,25 @@ mod tests {
assert!(retry_error.contains("Lock acquisition timeout")); assert!(retry_error.contains("Lock acquisition timeout"));
} }
#[tokio::test]
async fn retry_request_for_result_preserves_remaining_timeout_budget() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let mut request = HealRequest::object("retry-transition".to_string(), "object".to_string(), None);
request.options.timeout = Some(Duration::from_secs(60));
let task = HealTask::from_request(request, storage);
let result = task.execute().await;
let (retry_request, _, _) = retry_request_for_result_with_budget(&task, &result)
.await
.expect("read quorum failure should retain the unused timeout budget");
let remaining = retry_request
.options
.timeout
.expect("configured timeout should remain present");
assert!(remaining < Duration::from_secs(60));
assert!(remaining > Duration::from_secs(59));
}
#[test] #[test]
fn test_retry_request_for_incomplete_heal_rename() { fn test_retry_request_for_incomplete_heal_rename() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage); let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
@@ -6054,7 +6079,7 @@ mod tests {
process_manager_queue_once(&manager).await; process_manager_queue_once(&manager).await;
let defaulted_status = tokio::time::timeout(Duration::from_secs(1), async { let defaulted_status = tokio::time::timeout(Duration::from_secs(1), async {
loop { loop {
if let Ok(status @ HealTaskStatus::Retrying { .. }) = manager.get_task_status(&defaulted_id).await { if let Ok(status @ HealTaskStatus::Timeout) = manager.get_task_status(&defaulted_id).await {
break status; break status;
} }
tokio::task::yield_now().await; tokio::task::yield_now().await;
@@ -6062,23 +6087,8 @@ mod tests {
}) })
.await .await
.expect("configured timeout should finish the task"); .expect("configured timeout should finish the task");
assert!(matches!(defaulted_status, HealTaskStatus::Retrying { .. })); assert_eq!(defaulted_status, HealTaskStatus::Timeout);
assert_eq!( assert!(manager.retrying_heals.lock().await.get(&defaulted_id).is_none());
manager
.retrying_heals
.lock()
.await
.get(&defaulted_id)
.expect("timed out task should retain its retry request")
.request
.options
.timeout,
Some(Duration::ZERO)
);
manager
.cancel_task(&defaulted_id)
.await
.expect("retrying timeout task should be cancelled");
let mut explicit = bucket_request("explicit-timeout", HealPriority::Normal, HealRequestSource::Admin); let mut explicit = bucket_request("explicit-timeout", HealPriority::Normal, HealRequestSource::Admin);
explicit.options.timeout = Some(Duration::from_secs(60)); explicit.options.timeout = Some(Duration::from_secs(60));
+39 -1
View File
@@ -196,7 +196,7 @@ pub struct HealOptions {
/// Whether to skip namespace locking /// Whether to skip namespace locking
#[serde(default)] #[serde(default)]
pub no_lock: bool, pub no_lock: bool,
/// Timeout /// Aggregate execution timeout across recoverable manager retries
pub timeout: Option<Duration>, pub timeout: Option<Duration>,
/// pool index /// pool index
pub pool_index: Option<usize>, pub pool_index: Option<usize>,
@@ -442,6 +442,14 @@ impl HealTask {
} }
} }
pub(crate) async fn retry_request_with_remaining_timeout(&self) -> Result<HealRequest> {
let mut request = self.retry_request();
if self.options.timeout.is_some() {
request.options.timeout = self.remaining_timeout().await?;
}
Ok(request)
}
pub(crate) fn from_replacement_recovery_request( pub(crate) fn from_replacement_recovery_request(
request: HealRequest, request: HealRequest,
storage: Arc<dyn HealStorageAPI>, storage: Arc<dyn HealStorageAPI>,
@@ -2657,6 +2665,36 @@ mod tests {
use super::super::storage_api::status::BucketInfo; use super::super::storage_api::status::BucketInfo;
#[tokio::test]
async fn retry_request_carries_remaining_timeout_budget() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage::default());
let mut request = HealRequest::bucket("bucket".to_string());
request.options.timeout = Some(Duration::from_secs(100));
let task = HealTask::from_request(request, storage.clone());
*task.task_start_instant.write().await = Some(Instant::now() - Duration::from_secs(40));
let retry = task
.retry_request_with_remaining_timeout()
.await
.expect("first retry should retain the unused timeout budget");
let first_remaining = retry.options.timeout.expect("configured timeout should remain present");
assert!(first_remaining <= Duration::from_secs(60));
assert!(first_remaining > Duration::from_secs(59));
let retry_task = HealTask::from_request(retry, storage);
*retry_task.task_start_instant.write().await = Some(Instant::now() - Duration::from_secs(20));
let second_retry = retry_task
.retry_request_with_remaining_timeout()
.await
.expect("second retry should retain only the unused aggregate budget");
let second_remaining = second_retry
.options
.timeout
.expect("configured timeout should remain present");
assert!(second_remaining <= Duration::from_secs(40));
assert!(second_remaining > Duration::from_secs(39));
}
#[test] #[test]
fn format_result_requires_every_requested_target_to_be_ok() { fn format_result_requires_every_requested_target_to_be_ok() {
let result = HealResultItem { let result = HealResultItem {
-2
View File
@@ -225,8 +225,6 @@ async fn nothing_readable_leaves_the_bundle_unwrapped() {
"artifact {} carries the raw on-disk record", "artifact {} carries the raw on-disk record",
artifact.path artifact.path
); );
// A cheap structural check too: an encrypted payload is not JSON.
assert_ne!(payload.first(), Some(&b'{'), "artifact {} looks like plaintext JSON", artifact.path);
} }
// The manifest itself is not encrypted, so assert directly that it carries // The manifest itself is not encrypted, so assert directly that it carries
+1 -5
View File
@@ -103,11 +103,7 @@ pub(super) fn rules() -> Vec<Rule> {
P2Degraded, P2Degraded,
"heal", "heal",
"heal 任务调度/执行失败", "heal 任务调度/执行失败",
any([ any([prefix("Heal task timeout"), prefix("Heal task execution failed")]),
prefix("Heal task timeout"),
prefix("Heal task execution failed"),
contains("Heal manager is not running"),
]),
"heal 任务调度/执行层故障。", "heal 任务调度/执行层故障。",
"检查 heal 后台服务状态与资源压力。", "检查 heal 后台服务状态与资源压力。",
) )
+24 -18
View File
@@ -748,9 +748,10 @@ async fn handle_authenticated_request(
} }
for (key, value) in info.user_defined.iter() { for (key, value) in info.user_defined.iter() {
if key != "content-type" { if key != "content-type"
let header_name = format!("x-object-meta-{}", key); && let Some(key) = object::swift_response_user_metadata_key(key)
response = response.header(header_name, value.as_str()); {
response = response.header(format!("x-object-meta-{key}"), value.as_str());
} }
} }
@@ -817,9 +818,10 @@ async fn handle_authenticated_request(
// Add custom metadata headers (X-Object-Meta-*) // Add custom metadata headers (X-Object-Meta-*)
for (key, value) in info.user_defined.iter() { for (key, value) in info.user_defined.iter() {
if key != "content-type" { if key != "content-type"
let header_name = format!("x-object-meta-{}", key); && let Some(key) = object::swift_response_user_metadata_key(key)
response = response.header(header_name, value.as_str()); {
response = response.header(format!("x-object-meta-{key}"), value.as_str());
} }
} }
@@ -856,9 +858,10 @@ async fn handle_authenticated_request(
// Add custom metadata headers (X-Object-Meta-*) // Add custom metadata headers (X-Object-Meta-*)
for (key, value) in info.user_defined.iter() { for (key, value) in info.user_defined.iter() {
if key != "content-type" { if key != "content-type"
let header_name = format!("x-object-meta-{}", key); && let Some(key) = object::swift_response_user_metadata_key(key)
response = response.header(header_name, value.as_str()); {
response = response.header(format!("x-object-meta-{key}"), value.as_str());
} }
} }
@@ -1168,9 +1171,10 @@ async fn handle_object_get(
} }
for (key, value) in info.user_defined.iter() { for (key, value) in info.user_defined.iter() {
if key != "content-type" { if key != "content-type"
let header_name = format!("x-object-meta-{}", key); && let Some(key) = object::swift_response_user_metadata_key(key)
response = response.header(header_name, value.as_str()); {
response = response.header(format!("x-object-meta-{key}"), value.as_str());
} }
} }
@@ -1237,9 +1241,10 @@ async fn handle_object_get(
if key == "x-delete-at" { if key == "x-delete-at" {
// Add X-Delete-At header directly (not as X-Object-Meta-*) // Add X-Delete-At header directly (not as X-Object-Meta-*)
response = response.header("x-delete-at", value.as_str()); response = response.header("x-delete-at", value.as_str());
} else if key != "content-type" { } else if key != "content-type"
let header_name = format!("x-object-meta-{}", key); && let Some(key) = object::swift_response_user_metadata_key(key)
response = response.header(header_name, value.as_str()); {
response = response.header(format!("x-object-meta-{key}"), value.as_str());
} }
} }
@@ -1293,9 +1298,10 @@ async fn handle_object_head(
if key == "x-delete-at" { if key == "x-delete-at" {
// Add X-Delete-At header directly (not as X-Object-Meta-*) // Add X-Delete-At header directly (not as X-Object-Meta-*)
response = response.header("x-delete-at", value.as_str()); response = response.header("x-delete-at", value.as_str());
} else if key != "content-type" { } else if key != "content-type"
let header_name = format!("x-object-meta-{}", key); && let Some(key) = object::swift_response_user_metadata_key(key)
response = response.header(header_name, value.as_str()); {
response = response.header(format!("x-object-meta-{key}"), value.as_str());
} }
} }
+81 -31
View File
@@ -67,10 +67,57 @@ const LOG_COMPONENT_PROTOCOLS: &str = "protocols";
const LOG_SUBSYSTEM_SWIFT_OBJECT: &str = "swift_object"; const LOG_SUBSYSTEM_SWIFT_OBJECT: &str = "swift_object";
const EVENT_SWIFT_OBJECT_STORAGE_STATE: &str = "swift_object_storage_state"; const EVENT_SWIFT_OBJECT_STORAGE_STATE: &str = "swift_object_storage_state";
const SWIFT_DELETE_AT_METADATA: &str = "x-delete-at"; const SWIFT_DELETE_AT_METADATA: &str = "x-delete-at";
const USER_METADATA_PREFIX: &str = "x-amz-meta-";
/// Maximum object size in bytes (5GB - Swift default) /// Maximum object size in bytes (5GB - Swift default)
const MAX_OBJECT_SIZE: i64 = 5 * 1024 * 1024 * 1024; const MAX_OBJECT_SIZE: i64 = 5 * 1024 * 1024 * 1024;
fn stored_swift_user_metadata_key(key: &str) -> String {
if rustfs_utils::http::is_internal_key(key)
|| rustfs_utils::http::starts_with_ignore_ascii_case(key, "x-amz-")
|| rustfs_utils::http::starts_with_ignore_ascii_case(key, "x-rustfs-encryption-")
|| rustfs_utils::http::starts_with_ignore_ascii_case(key, "x-minio-encryption-")
{
format!("{USER_METADATA_PREFIX}{key}")
} else {
key.to_string()
}
}
pub(super) fn swift_response_user_metadata_key(key: &str) -> Option<&str> {
if rustfs_utils::http::is_internal_key(key)
|| rustfs_utils::http::starts_with_ignore_ascii_case(key, "x-rustfs-encryption-")
|| rustfs_utils::http::starts_with_ignore_ascii_case(key, "x-minio-encryption-")
{
return None;
}
if let Some(unescaped) = key.strip_prefix(USER_METADATA_PREFIX)
&& (rustfs_utils::http::is_internal_key(unescaped)
|| rustfs_utils::http::starts_with_ignore_ascii_case(unescaped, "x-amz-")
|| rustfs_utils::http::starts_with_ignore_ascii_case(unescaped, "x-rustfs-encryption-")
|| rustfs_utils::http::starts_with_ignore_ascii_case(unescaped, "x-minio-encryption-"))
{
return Some(unescaped);
}
Some(key)
}
fn swift_user_metadata(headers: &HeaderMap) -> Option<HashMap<String, String>> {
let mut metadata = HashMap::new();
let mut present = false;
for (header_name, header_value) in headers.iter() {
let header_name = header_name.as_str().to_lowercase();
let Some(key) = header_name.strip_prefix("x-object-meta-") else {
continue;
};
present = true;
if let Ok(value) = header_value.to_str() {
metadata.insert(stored_swift_user_metadata_key(key), value.to_string());
}
}
present.then_some(metadata)
}
/// Object key translator for Swift object names /// Object key translator for Swift object names
/// ///
/// Handles URL encoding/decoding and path normalization for Swift object keys. /// Handles URL encoding/decoding and path normalization for Swift object keys.
@@ -303,15 +350,7 @@ where
let bucket = mapper.swift_to_s3_bucket(container, &project_id); let bucket = mapper.swift_to_s3_bucket(container, &project_id);
// 5. Extract Swift metadata from X-Object-Meta-* headers // 5. Extract Swift metadata from X-Object-Meta-* headers
let mut user_metadata = HashMap::new(); let mut user_metadata = swift_user_metadata(headers).unwrap_or_default();
for (header_name, header_value) in headers.iter() {
let header_str = header_name.as_str().to_lowercase();
if let Some(meta_key) = header_str.strip_prefix("x-object-meta-")
&& let Ok(value_str) = header_value.to_str()
{
user_metadata.insert(meta_key.to_string(), value_str.to_string());
}
}
// 6. Extract Content-Type if provided // 6. Extract Content-Type if provided
if let Some(content_type) = headers.get("content-type") if let Some(content_type) = headers.get("content-type")
@@ -739,15 +778,7 @@ pub async fn update_object_metadata(
} }
// 8. Extract new metadata from X-Object-Meta-* headers // 8. Extract new metadata from X-Object-Meta-* headers
let mut new_metadata = HashMap::new(); let mut new_metadata = swift_user_metadata(headers).unwrap_or_default();
for (header_name, header_value) in headers.iter() {
let header_str = header_name.as_str().to_lowercase();
if let Some(meta_key) = header_str.strip_prefix("x-object-meta-")
&& let Ok(value_str) = header_value.to_str()
{
new_metadata.insert(meta_key.to_string(), value_str.to_string());
}
}
// 9. Also update Content-Type if provided // 9. Also update Content-Type if provided
if let Some(content_type) = headers.get("content-type") if let Some(content_type) = headers.get("content-type")
@@ -889,19 +920,8 @@ pub async fn copy_object(
let mut new_metadata = (*src_info.user_defined).clone(); let mut new_metadata = (*src_info.user_defined).clone();
// 11. If custom metadata headers provided, use those instead (Swift behavior) // 11. If custom metadata headers provided, use those instead (Swift behavior)
let mut has_custom_meta = false; if let Some(custom_metadata) = swift_user_metadata(headers) {
for (header_name, header_value) in headers.iter() { new_metadata = custom_metadata;
let header_str = header_name.as_str().to_lowercase();
if let Some(meta_key) = header_str.strip_prefix("x-object-meta-") {
if !has_custom_meta {
// First custom meta header - clear source metadata
new_metadata.clear();
has_custom_meta = true;
}
if let Ok(value_str) = header_value.to_str() {
new_metadata.insert(meta_key.to_string(), value_str.to_string());
}
}
} }
// 12. Also check for Content-Type override // 12. Also check for Content-Type override
@@ -1123,6 +1143,36 @@ mod tests {
assert!(ObjectKeyMapper::validate_object_name("unicode-文件.txt").is_ok()); assert!(ObjectKeyMapper::validate_object_name("unicode-文件.txt").is_ok());
} }
#[test]
fn swift_user_metadata_cannot_materialize_internal_storage_keys() {
let mut headers = HeaderMap::new();
headers.insert("x-object-meta-x-rustfs-internal-actual-size", "1".parse().expect("valid metadata value"));
headers.insert("x-object-meta-description", "safe".parse().expect("valid metadata value"));
let metadata = swift_user_metadata(&headers).expect("custom metadata should be detected");
assert_eq!(metadata.get("x-amz-meta-x-rustfs-internal-actual-size").map(String::as_str), Some("1"));
assert_eq!(metadata.get("description").map(String::as_str), Some("safe"));
assert!(!metadata.contains_key("x-rustfs-internal-actual-size"));
assert_eq!(
stored_swift_user_metadata_key("x-minio-encryption-original-size"),
"x-amz-meta-x-minio-encryption-original-size"
);
assert_eq!(stored_swift_user_metadata_key("description"), "description");
}
#[test]
fn swift_user_metadata_response_mapping_is_reversible_and_filters_internal_keys() {
assert_eq!(
swift_response_user_metadata_key("x-amz-meta-x-rustfs-internal-actual-size"),
Some("x-rustfs-internal-actual-size")
);
assert_eq!(swift_response_user_metadata_key("x-amz-meta-x-amz-checksum"), Some("x-amz-checksum"));
assert_eq!(swift_response_user_metadata_key("x-amz-meta-description"), Some("x-amz-meta-description"));
assert_eq!(swift_response_user_metadata_key("description"), Some("description"));
assert_eq!(swift_response_user_metadata_key("x-rustfs-internal-actual-size"), None);
assert_eq!(swift_response_user_metadata_key("x-minio-internal-actual-size"), None);
}
#[test] #[test]
fn test_validate_object_name_empty() { fn test_validate_object_name_empty() {
let result = ObjectKeyMapper::validate_object_name(""); let result = ObjectKeyMapper::validate_object_name("");
+32 -1
View File
@@ -520,6 +520,14 @@ struct FailureSample {
pub struct FailStats { pub struct FailStats {
pub count: i64, pub count: i64,
pub size: i64, pub size: i64,
/// Rolling-window snapshots refreshed at collection time
/// ([`Self::refresh_windows`]). The raw samples (`recent`) are process
/// local (serde-skipped), so these fields are what survives the peer-RPC
/// wire and [`Self::merge`]-based cluster aggregation.
#[serde(default)]
pub last_minute: FailedMetric,
#[serde(default)]
pub last_hour: FailedMetric,
#[serde(skip)] #[serde(skip)]
recent: VecDeque<FailureSample>, recent: VecDeque<FailureSample>,
} }
@@ -537,6 +545,17 @@ impl FailStats {
self.prune(observed_at); self.prune(observed_at);
} }
/// Recompute the serializable rolling-window snapshots from the local
/// samples. Called at the collection point (per-node stats snapshot),
/// never on the failure hot path — the two deque scans are O(window) and
/// `add_size` runs under the bucket-stats write lock. Only meaningful on
/// the live per-node struct: a deserialized or merged struct has no
/// samples, and refreshing it would wipe the aggregated windows.
pub fn refresh_windows(&mut self) {
self.last_minute = self.recent_since(Duration::from_secs(60));
self.last_hour = self.recent_since(Duration::from_secs(3600));
}
fn prune(&mut self, observed_at: Instant) { fn prune(&mut self, observed_at: Instant) {
while self while self
.recent .recent
@@ -565,6 +584,16 @@ impl FailStats {
Self { Self {
count: self.count.saturating_add(other.count), count: self.count.saturating_add(other.count),
size: self.size.saturating_add(other.size), size: self.size.saturating_add(other.size),
// The window snapshots sum across nodes; the raw samples do not
// travel and stay empty on aggregated structs.
last_minute: FailedMetric {
count: self.last_minute.count.saturating_add(other.last_minute.count),
size: self.last_minute.size.saturating_add(other.last_minute.size),
},
last_hour: FailedMetric {
count: self.last_hour.count.saturating_add(other.last_hour.count),
size: self.last_hour.size.saturating_add(other.last_hour.size),
},
recent: VecDeque::new(), recent: VecDeque::new(),
} }
} }
@@ -636,7 +665,9 @@ impl BucketReplicationStat {
} }
pub fn update_xfer_rate(&mut self, size: i64, duration: Duration) { pub fn update_xfer_rate(&mut self, size: i64, duration: Duration) {
if size > 1024 * 1024 { // Same boundary as the worker-pool split and minio-go's
// Large/Small transfer-summary labels: >= 128 MiB is "large".
if size >= crate::runtime::MIN_LARGE_OBJ_SIZE {
self.xfer_rate_lrg.add_size(size, duration); self.xfer_rate_lrg.add_size(size, duration);
} else { } else {
self.xfer_rate_sml.add_size(size, duration); self.xfer_rate_sml.add_size(size, duration);
+361 -51
View File
@@ -71,6 +71,7 @@ where
/// Optional: allow users to customize block_size /// Optional: allow users to customize block_size
pub fn with_block_size(inner: R, block_size: usize, compression_algorithm: CompressionAlgorithm) -> Self { pub fn with_block_size(inner: R, block_size: usize, compression_algorithm: CompressionAlgorithm) -> Self {
debug_assert!(block_size > 0, "CompressReader block_size must be non-zero");
Self { Self {
inner, inner,
buffer: Vec::new(), buffer: Vec::new(),
@@ -183,11 +184,21 @@ pin_project! {
buffer: Vec<u8>, buffer: Vec<u8>,
buffer_pos: usize, buffer_pos: usize,
finished: bool, finished: bool,
// A previously surfaced stream error is sticky: without this, a caller
// that polls again after an error would restart at the header phase and
// read a truncated tail as a clean EOF, converting the error into a
// silently short body.
poisoned: bool,
// Fields for saving header read progress across polls // Fields for saving header read progress across polls
header_buf: [u8; 8], header_buf: [u8; 8],
header_read: usize, header_read: usize,
header_done: bool, // Fields for saving compressed block read progress across polls.
// Fields for saving compressed block read progress across polls // `compressed_len > 0` means a block payload is in flight: the header has
// been fully parsed and `compressed_read` bytes of the payload are already
// consumed from the inner stream. The header phase must not run again (and
// must not reset `compressed_read`) until this block completes, or a
// `Poll::Pending` in the middle of a payload would silently drop the bytes
// read so far and desynchronize the block framing.
compressed_buf: Vec<u8>, compressed_buf: Vec<u8>,
compressed_read: usize, compressed_read: usize,
compressed_len: usize, compressed_len: usize,
@@ -205,9 +216,9 @@ where
buffer: Vec::new(), buffer: Vec::new(),
buffer_pos: 0, buffer_pos: 0,
finished: false, finished: false,
poisoned: false,
header_buf: [0u8; 8], header_buf: [0u8; 8],
header_read: 0, header_read: 0,
header_done: false,
compressed_buf: Vec::new(), compressed_buf: Vec::new(),
compressed_read: 0, compressed_read: 0,
compressed_len: 0, compressed_len: 0,
@@ -236,54 +247,74 @@ where
if *this.finished { if *this.finished {
return Poll::Ready(Ok(())); return Poll::Ready(Ok(()));
} }
// Read header if *this.poisoned {
while !*this.header_done && *this.header_read < HEADER_LEN { return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "decompress reader previously failed")));
let mut temp = [0u8; HEADER_LEN]; }
let mut temp_buf = ReadBuf::new(&mut temp[0..HEADER_LEN - *this.header_read]);
match this.inner.as_mut().poll_read(cx, &mut temp_buf) { if *this.compressed_len == 0 {
Poll::Pending => return Poll::Pending, // Read the 8-byte block header, resuming across polls via `header_read`.
Poll::Ready(Ok(())) => { while *this.header_read < HEADER_LEN {
let n = temp_buf.filled().len(); let mut temp = [0u8; HEADER_LEN];
if n == 0 { let mut temp_buf = ReadBuf::new(&mut temp[0..HEADER_LEN - *this.header_read]);
break; match this.inner.as_mut().poll_read(cx, &mut temp_buf) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(())) => {
let n = temp_buf.filled().len();
if n == 0 {
if *this.header_read == 0 {
// Clean EOF on a block boundary.
*this.finished = true;
return Poll::Ready(Ok(()));
}
*this.poisoned = true;
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"unexpected EOF while reading compressed block header",
)));
}
this.header_buf[*this.header_read..*this.header_read + n].copy_from_slice(&temp_buf.filled()[..n]);
*this.header_read += n;
}
Poll::Ready(Err(e)) => {
// error!("DecompressReader poll_read: read header error: {e}");
*this.poisoned = true;
return Poll::Ready(Err(e));
} }
this.header_buf[*this.header_read..*this.header_read + n].copy_from_slice(&temp_buf.filled()[..n]);
*this.header_read += n;
}
Poll::Ready(Err(e)) => {
// error!("DecompressReader poll_read: read header error: {e}");
return Poll::Ready(Err(e));
} }
} }
if *this.header_read < HEADER_LEN {
return Poll::Pending;
}
}
if !*this.header_done && *this.header_read == 0 {
return Poll::Ready(Ok(()));
}
let typ = this.header_buf[0];
let len = (this.header_buf[1] as usize) | ((this.header_buf[2] as usize) << 8) | ((this.header_buf[3] as usize) << 16);
let crc = (this.header_buf[4] as u32)
| ((this.header_buf[5] as u32) << 8)
| ((this.header_buf[6] as u32) << 16)
| ((this.header_buf[7] as u32) << 24);
*this.header_read = 0;
*this.header_done = true;
if typ == COMPRESS_TYPE_END { let typ = this.header_buf[0];
let len =
(this.header_buf[1] as usize) | ((this.header_buf[2] as usize) << 8) | ((this.header_buf[3] as usize) << 16);
*this.header_read = 0;
// `CompressReader` never emits an end block — a stream terminates on
// inner EOF, which is what lets concatenated per-part streams decode as
// one. This branch is kept for streams that do carry the marker.
if typ == COMPRESS_TYPE_END {
*this.compressed_read = 0;
*this.compressed_len = 0;
*this.finished = true;
return Poll::Ready(Ok(()));
}
if typ != COMPRESS_TYPE_COMPRESSED && typ != COMPRESS_TYPE_UNCOMPRESSED {
// error!("DecompressReader unknown compression type: {typ}");
*this.poisoned = true;
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Unknown compression type")));
}
if len == 0 {
*this.poisoned = true;
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid compressed block length")));
}
if this.compressed_buf.len() < len {
this.compressed_buf.resize(len, 0);
}
*this.compressed_len = len;
*this.compressed_read = 0; *this.compressed_read = 0;
*this.compressed_len = 0;
*this.finished = true;
return Poll::Ready(Ok(()));
} }
if this.compressed_buf.len() < len { // Fill the in-flight block payload, resuming across polls via `compressed_read`.
this.compressed_buf.resize(len, 0);
}
*this.compressed_len = len;
*this.compressed_read = 0;
while *this.compressed_read < *this.compressed_len { while *this.compressed_read < *this.compressed_len {
let mut temp_buf = ReadBuf::new(&mut this.compressed_buf[*this.compressed_read..*this.compressed_len]); let mut temp_buf = ReadBuf::new(&mut this.compressed_buf[*this.compressed_read..*this.compressed_len]);
match this.inner.as_mut().poll_read(cx, &mut temp_buf) { match this.inner.as_mut().poll_read(cx, &mut temp_buf) {
@@ -291,7 +322,13 @@ where
Poll::Ready(Ok(())) => { Poll::Ready(Ok(())) => {
let n = temp_buf.filled().len(); let n = temp_buf.filled().len();
if n == 0 { if n == 0 {
break; *this.compressed_read = 0;
*this.compressed_len = 0;
*this.poisoned = true;
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"unexpected EOF while reading compressed block payload",
)));
} }
*this.compressed_read += n; *this.compressed_read += n;
} }
@@ -299,10 +336,17 @@ where
// error!("DecompressReader poll_read: read compressed block error: {e}"); // error!("DecompressReader poll_read: read compressed block error: {e}");
*this.compressed_read = 0; *this.compressed_read = 0;
*this.compressed_len = 0; *this.compressed_len = 0;
*this.poisoned = true;
return Poll::Ready(Err(e)); return Poll::Ready(Err(e));
} }
} }
} }
let typ = this.header_buf[0];
let crc = (this.header_buf[4] as u32)
| ((this.header_buf[5] as u32) << 8)
| ((this.header_buf[6] as u32) << 16)
| ((this.header_buf[7] as u32) << 24);
let compressed_buf = &this.compressed_buf[..*this.compressed_len]; let compressed_buf = &this.compressed_buf[..*this.compressed_len];
// `compressed_buf`'s length comes from the untrusted 24-bit header length field, so it // `compressed_buf`'s length comes from the untrusted 24-bit header length field, so it
// can be shorter than 16 bytes. `uvarint` is safe on any slice length (reads at most 10 // can be shorter than 16 bytes. `uvarint` is safe on any slice length (reads at most 10
@@ -316,6 +360,7 @@ where
if uvarint <= 0 || uvarint as usize > compressed_buf.len() { if uvarint <= 0 || uvarint as usize > compressed_buf.len() {
*this.compressed_read = 0; *this.compressed_read = 0;
*this.compressed_len = 0; *this.compressed_len = 0;
*this.poisoned = true;
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid compressed block length prefix"))); return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid compressed block length prefix")));
} }
let compressed_data = &compressed_buf[uvarint as usize..]; let compressed_data = &compressed_buf[uvarint as usize..];
@@ -326,21 +371,29 @@ where
// error!("DecompressReader decompress_block error: {e}"); // error!("DecompressReader decompress_block error: {e}");
*this.compressed_read = 0; *this.compressed_read = 0;
*this.compressed_len = 0; *this.compressed_len = 0;
*this.poisoned = true;
return Poll::Ready(Err(e)); return Poll::Ready(Err(e));
} }
} }
} else if typ == COMPRESS_TYPE_UNCOMPRESSED {
compressed_data.to_vec()
} else { } else {
// error!("DecompressReader unknown compression type: {typ}"); // The header phase already rejected every type other than
// COMPRESS_TYPE_COMPRESSED / COMPRESS_TYPE_UNCOMPRESSED.
compressed_data.to_vec()
};
if decompressed.is_empty() {
// The writer never emits zero-length plaintext blocks; an empty
// decode surfacing as Ready(Ok) with no bytes would read as EOF and
// silently truncate the stream.
*this.poisoned = true;
*this.compressed_read = 0; *this.compressed_read = 0;
*this.compressed_len = 0; *this.compressed_len = 0;
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Unknown compression type"))); return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Empty compressed block")));
}; }
if decompressed.len() != uncompress_len as usize { if decompressed.len() != uncompress_len as usize {
// error!("DecompressReader decompressed length mismatch: {} != {}", decompressed.len(), uncompress_len); // error!("DecompressReader decompressed length mismatch: {} != {}", decompressed.len(), uncompress_len);
*this.compressed_read = 0; *this.compressed_read = 0;
*this.compressed_len = 0; *this.compressed_len = 0;
*this.poisoned = true;
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Decompressed length mismatch"))); return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Decompressed length mismatch")));
} }
let actual_crc = { let actual_crc = {
@@ -352,13 +405,13 @@ where
// error!("DecompressReader CRC32 mismatch: actual {actual_crc} != expected {crc}"); // error!("DecompressReader CRC32 mismatch: actual {actual_crc} != expected {crc}");
*this.compressed_read = 0; *this.compressed_read = 0;
*this.compressed_len = 0; *this.compressed_len = 0;
*this.poisoned = true;
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "CRC32 mismatch"))); return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "CRC32 mismatch")));
} }
*this.buffer = decompressed; *this.buffer = decompressed;
*this.buffer_pos = 0; *this.buffer_pos = 0;
*this.compressed_read = 0; *this.compressed_read = 0;
*this.compressed_len = 0; *this.compressed_len = 0;
*this.header_done = false;
let to_copy = min(buf.remaining(), this.buffer.len()); let to_copy = min(buf.remaining(), this.buffer.len());
buf.put_slice(&this.buffer[..to_copy]); buf.put_slice(&this.buffer[..to_copy]);
*this.buffer_pos += to_copy; *this.buffer_pos += to_copy;
@@ -493,6 +546,184 @@ mod tests {
assert_eq!(&decompressed, &data); assert_eq!(&decompressed, &data);
} }
/// Wraps a reader so every other poll returns `Poll::Pending` and every
/// `Ready` poll serves at most `chunk` bytes. This is the shape a duplex
/// pipe produces when the erasure writer is slower than the decoder, which
/// is exactly what desynchronized the block framing before the resumable
/// payload state was added (rustfs/rustfs#5957 multipart GET truncation).
struct PendingChunkReader<R> {
inner: R,
chunk: usize,
pending_next: bool,
}
impl<R> PendingChunkReader<R> {
fn new(inner: R, chunk: usize) -> Self {
Self {
inner,
chunk,
pending_next: true,
}
}
}
impl<R: AsyncRead + Unpin> AsyncRead for PendingChunkReader<R> {
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<()>> {
if self.pending_next {
self.pending_next = false;
cx.waker().wake_by_ref();
return std::task::Poll::Pending;
}
self.pending_next = true;
let cap = self.chunk.min(buf.remaining());
let mut scratch = vec![0u8; cap];
let mut inner_buf = tokio::io::ReadBuf::new(&mut scratch);
match std::pin::Pin::new(&mut self.inner).poll_read(cx, &mut inner_buf) {
std::task::Poll::Ready(Ok(())) => {
buf.put_slice(inner_buf.filled());
std::task::Poll::Ready(Ok(()))
}
other => other,
}
}
}
fn patterned_payload(size: usize, seed: u8) -> Vec<u8> {
(0..size)
.map(|i| ((i as u64).wrapping_mul(2_654_435_761).wrapping_add(seed as u64) >> 3) as u8)
.collect()
}
/// Root-cause regression for the multipart compressed GET truncation: a
/// `Poll::Pending` in the middle of a block payload must not drop the bytes
/// already consumed. Before the resumable payload state, the decoder reset
/// `compressed_read` on every re-poll and surfaced
/// `LZ4 error: ERROR_frameType_unknown` mid-stream.
#[tokio::test]
async fn test_decompress_reader_survives_pending_mid_payload() {
let data = patterned_payload(100 * 1024, 7);
let mut compress_reader =
CompressReader::with_block_size(Cursor::new(data.clone()), 8192, CompressionAlgorithm::default());
let mut compressed = Vec::new();
compress_reader.read_to_end(&mut compressed).await.unwrap();
for chunk in [1usize, 3, 7, 8, 17, 1000, 8192] {
let inner = PendingChunkReader::new(Cursor::new(compressed.clone()), chunk);
let mut decompress_reader = DecompressReader::new(inner, CompressionAlgorithm::default());
let mut decompressed = Vec::new();
decompress_reader.read_to_end(&mut decompressed).await.unwrap();
assert_eq!(decompressed, data, "pending-chunked decode must be byte-exact for chunk={chunk}");
}
}
/// Two independently compressed streams concatenated back to back — the
/// on-disk shape of a compressed multipart object — must decode across the
/// stream boundary even when every poll can suspend mid-block.
#[tokio::test]
async fn test_decompress_reader_survives_pending_across_concatenated_streams() {
let part1 = patterned_payload(64 * 1024, 7);
let part2 = patterned_payload(24 * 1024, 61);
let mut stored = Vec::new();
for part in [&part1, &part2] {
let mut compress_reader =
CompressReader::with_block_size(Cursor::new(part.clone()), 8192, CompressionAlgorithm::default());
let mut compressed = Vec::new();
compress_reader.read_to_end(&mut compressed).await.unwrap();
stored.extend_from_slice(&compressed);
}
let mut expected = part1;
expected.extend_from_slice(&part2);
for chunk in [1usize, 5, 8, 13, 4096] {
let inner = PendingChunkReader::new(Cursor::new(stored.clone()), chunk);
let mut decompress_reader = DecompressReader::new(inner, CompressionAlgorithm::default());
let mut decompressed = Vec::new();
decompress_reader.read_to_end(&mut decompressed).await.unwrap();
assert_eq!(
decompressed, expected,
"concatenated part streams must decode byte-exact for chunk={chunk}"
);
}
}
/// After the first stream error, every further poll must keep failing.
/// Without the sticky poison a retrying caller would restart at the header
/// phase and read the truncated tail as a clean EOF — converting a hard
/// error into a silently short body.
#[tokio::test]
async fn test_decompress_reader_error_is_sticky() {
let data = patterned_payload(32 * 1024, 7);
let mut compress_reader = CompressReader::with_block_size(Cursor::new(data), 8192, CompressionAlgorithm::default());
let mut compressed = Vec::new();
compress_reader.read_to_end(&mut compressed).await.unwrap();
compressed.truncate(compressed.len() - 3);
let mut decompress_reader = DecompressReader::new(Cursor::new(compressed), CompressionAlgorithm::default());
let mut out = Vec::new();
let first = decompress_reader
.read_to_end(&mut out)
.await
.expect_err("truncated payload must error");
assert_eq!(first.kind(), std::io::ErrorKind::UnexpectedEof);
let mut retry = Vec::new();
let second = decompress_reader
.read_to_end(&mut retry)
.await
.expect_err("a poll after the first error must not turn into a clean EOF");
assert_eq!(second.kind(), std::io::ErrorKind::InvalidData);
assert!(retry.is_empty(), "no bytes may be produced after the stream failed");
}
/// A stream cut off in the middle of a block payload must fail with a clean
/// UnexpectedEof instead of decoding a short buffer.
#[tokio::test]
async fn test_decompress_reader_truncated_payload_is_unexpected_eof() {
let data = patterned_payload(32 * 1024, 7);
let mut compress_reader = CompressReader::with_block_size(Cursor::new(data), 8192, CompressionAlgorithm::default());
let mut compressed = Vec::new();
compress_reader.read_to_end(&mut compressed).await.unwrap();
compressed.truncate(compressed.len() - 3);
let mut decompress_reader = DecompressReader::new(Cursor::new(compressed), CompressionAlgorithm::default());
let mut out = Vec::new();
let err = decompress_reader
.read_to_end(&mut out)
.await
.expect_err("truncated payload must error");
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
/// A stream cut off in the middle of a block header must fail with a clean
/// UnexpectedEof instead of parsing a garbage header.
#[tokio::test]
async fn test_decompress_reader_truncated_header_is_unexpected_eof() {
let data = patterned_payload(12 * 1024, 7);
let mut compress_reader = CompressReader::with_block_size(Cursor::new(data), 8192, CompressionAlgorithm::default());
let mut compressed = Vec::new();
compress_reader.read_to_end(&mut compressed).await.unwrap();
// Keep the first full block plus 3 bytes of the next header.
let ln = (compressed[1] as usize) | ((compressed[2] as usize) << 8) | ((compressed[3] as usize) << 16);
let first_block_end = 8 + ln;
assert!(compressed.len() > first_block_end, "fixture must contain more than one block");
compressed.truncate(first_block_end + 3);
let mut decompress_reader = DecompressReader::new(Cursor::new(compressed), CompressionAlgorithm::default());
let mut out = Vec::new();
let err = decompress_reader
.read_to_end(&mut out)
.await
.expect_err("truncated header must error");
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
// Regression: a corrupted block whose 24-bit length field is < 16 must not panic. // Regression: a corrupted block whose 24-bit length field is < 16 must not panic.
// Header layout (HEADER_LEN = 8): [type, len_lo, len_mid, len_hi, crc0..crc3], then `len` // Header layout (HEADER_LEN = 8): [type, len_lo, len_mid, len_hi, crc0..crc3], then `len`
// bytes of block body. Pre-fix, poll_read sliced `compressed_buf[0..16]` unconditionally, // bytes of block body. Pre-fix, poll_read sliced `compressed_buf[0..16]` unconditionally,
@@ -518,6 +749,85 @@ mod tests {
assert_eq!(res.unwrap_err().kind(), std::io::ErrorKind::InvalidData); assert_eq!(res.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
} }
// Header-level fail-closed matrix, built by hand so the decoder is exercised against bytes no
// encoder in this crate can produce. Header layout (HEADER_LEN = 8):
// [type, len_lo, len_mid, len_hi, crc0..crc3], then `len` body bytes = uvarint(plain_len) + data.
#[tokio::test]
async fn test_decompress_reader_header_validation_matrix() {
// Build a block whose body is `uvarint(plain.len()) + plain` (i.e. the
// COMPRESS_TYPE_UNCOMPRESSED shape), with the header CRC taken over the plaintext exactly
// like the production writer does.
fn build_raw_block(typ: u8, plain: &[u8], len_override: Option<usize>) -> Vec<u8> {
let crc = {
let mut hasher = crc_fast::Digest::new(crc_fast::CrcAlgorithm::Crc32IsoHdlc);
hasher.update(plain);
hasher.finalize() as u32
};
let mut uvarint_buf = [0u8; 10];
let int_len = put_uvarint(&mut uvarint_buf[..], plain.len() as u64);
let body_len = int_len + plain.len();
let len = len_override.unwrap_or(body_len);
let mut out = Vec::with_capacity(HEADER_LEN + body_len);
out.push(typ);
out.push((len & 0xFF) as u8);
out.push(((len >> 8) & 0xFF) as u8);
out.push(((len >> 16) & 0xFF) as u8);
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&uvarint_buf[..int_len]);
out.extend_from_slice(plain);
out
}
let plain = b"uncompressed passthrough payload";
// (a) A well-formed uncompressed block decodes to the plaintext verbatim.
let mut out = Vec::new();
DecompressReader::new(
Cursor::new(build_raw_block(COMPRESS_TYPE_UNCOMPRESSED, plain, None)),
CompressionAlgorithm::default(),
)
.read_to_end(&mut out)
.await
.expect("a well-formed uncompressed block must decode");
assert_eq!(out.as_slice(), plain.as_slice());
// (b) An unknown block type must be rejected instead of being treated as passthrough.
let mut out = Vec::new();
let err = DecompressReader::new(Cursor::new(build_raw_block(0x7E, plain, None)), CompressionAlgorithm::default())
.read_to_end(&mut out)
.await
.expect_err("unknown compression type must error");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("Unknown compression type"), "got: {err}");
// (c) A zero-length block would stall the decoder, so it must be rejected up front.
let mut out = Vec::new();
let err = DecompressReader::new(
Cursor::new(build_raw_block(COMPRESS_TYPE_UNCOMPRESSED, plain, Some(0))),
CompressionAlgorithm::default(),
)
.read_to_end(&mut out)
.await
.expect_err("zero-length block must error");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("Invalid compressed block length"), "got: {err}");
// (d) A block that decodes to zero plaintext bytes must be rejected: the
// writer never emits empty blocks, and an empty decode surfacing as
// Ready(Ok) with no bytes would read as EOF and silently truncate.
let mut out = Vec::new();
let err = DecompressReader::new(
Cursor::new(build_raw_block(COMPRESS_TYPE_UNCOMPRESSED, b"", None)),
CompressionAlgorithm::default(),
)
.read_to_end(&mut out)
.await
.expect_err("empty block must error");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("Empty compressed block"), "got: {err}");
}
// Directly exercises the length-prefix guard: an unterminated varint (all continuation bytes) // Directly exercises the length-prefix guard: an unterminated varint (all continuation bytes)
// makes `uvarint` return 0, which must be rejected as an invalid length prefix. // makes `uvarint` return 0, which must be rejected as an invalid length prefix.
#[tokio::test] #[tokio::test]
+81 -28
View File
@@ -24,12 +24,17 @@ pin_project! {
#[pin] #[pin]
pub inner: R, pub inner: R,
remaining: i64, remaining: i64,
scratch: Vec<u8>,
} }
} }
impl<R> HardLimitReader<R> { impl<R> HardLimitReader<R> {
pub fn new(inner: R, limit: i64) -> Self { pub fn new(inner: R, limit: i64) -> Self {
HardLimitReader { inner, remaining: limit } HardLimitReader {
inner,
remaining: limit,
scratch: Vec::new(),
}
} }
} }
@@ -37,19 +42,21 @@ impl<R> AsyncRead for HardLimitReader<R>
where where
R: AsyncRead, R: AsyncRead,
{ {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<Result<()>> { fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<Result<()>> {
if self.remaining < 0 { let mut this = self.project();
if *this.remaining < 0 {
return Poll::Ready(Err(Error::other("input provided more bytes than specified"))); return Poll::Ready(Err(Error::other("input provided more bytes than specified")));
} }
let original_filled = buf.filled().len(); if buf.remaining() == 0 {
if self.remaining == 0 { return Poll::Ready(Ok(()));
}
if *this.remaining == 0 {
let mut discard = [0u8; 8192]; let mut discard = [0u8; 8192];
let mut discard_buf = ReadBuf::new(&mut discard); let mut discard_buf = ReadBuf::new(&mut discard);
return match self.as_mut().project().inner.poll_read(cx, &mut discard_buf) { return match this.inner.as_mut().poll_read(cx, &mut discard_buf) {
Poll::Pending => Poll::Pending, Poll::Pending => Poll::Pending,
Poll::Ready(Ok(())) => { Poll::Ready(Ok(())) => {
if discard_buf.filled().is_empty() { if discard_buf.filled().is_empty() {
debug_assert_eq!(buf.filled().len(), original_filled);
Poll::Ready(Ok(())) Poll::Ready(Ok(()))
} else { } else {
Poll::Ready(Err(Error::other("input provided more bytes than specified"))) Poll::Ready(Err(Error::other("input provided more bytes than specified")))
@@ -58,30 +65,46 @@ where
Poll::Ready(Err(err)) => Poll::Ready(Err(err)), Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
}; };
} }
// Save the initial length
let before = original_filled;
// Poll the inner reader let remaining = match usize::try_from(*this.remaining) {
let this = self.as_mut().project(); Ok(remaining) => remaining,
let poll = this.inner.poll_read(cx, buf); Err(_) => usize::MAX,
};
if let Poll::Ready(Ok(())) = &poll { let allowed = remaining.min(buf.remaining());
let after = buf.filled().len(); let read = if allowed == buf.remaining() {
let read = (after - before) as i64; let before = buf.filled().len();
if read == 0 && *this.remaining > 0 { match this.inner.as_mut().poll_read(cx, buf) {
return Poll::Ready(Err(Error::new( Poll::Pending => return Poll::Pending,
std::io::ErrorKind::UnexpectedEof, Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
IncompleteBody { Poll::Ready(Ok(())) => buf.filled().len() - before,
remaining: *this.remaining,
},
)));
} }
*this.remaining -= read; } else {
if *this.remaining < 0 { this.scratch.resize(allowed, 0);
return Poll::Ready(Err(Error::other("input provided more bytes than specified"))); let mut scratch_buf = ReadBuf::new(&mut this.scratch[..allowed]);
match this.inner.as_mut().poll_read(cx, &mut scratch_buf) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Ready(Ok(())) => {
let read = scratch_buf.filled().len();
buf.put_slice(scratch_buf.filled());
read
}
} }
};
if read == 0 {
return Poll::Ready(Err(Error::new(
std::io::ErrorKind::UnexpectedEof,
IncompleteBody {
remaining: *this.remaining,
},
)));
} }
poll let read = match i64::try_from(read) {
Ok(read) => read,
Err(_) => return Poll::Ready(Err(Error::other("read count exceeds i64::MAX"))),
};
*this.remaining -= read;
Poll::Ready(Ok(()))
} }
} }
@@ -140,7 +163,12 @@ mod tests {
assert!(err.is_some()); assert!(err.is_some());
let err = err.unwrap(); let err = err.unwrap();
assert_eq!(err.kind(), std::io::ErrorKind::Other); assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
assert!(
err.get_ref()
.and_then(|source| source.downcast_ref::<std::io::Error>())
.is_some_and(|source| source.to_string().contains("more bytes than specified"))
);
} }
#[tokio::test] #[tokio::test]
@@ -155,6 +183,17 @@ mod tests {
assert_eq!(&buf, data); assert_eq!(&buf, data);
} }
#[tokio::test]
async fn test_hardlimit_reader_zero_capacity_read_does_not_consume_input() {
let mut reader = HardLimitReader::new(BufReader::new(&b"abc"[..]), 3);
let mut empty = [];
assert_eq!(reader.read(&mut empty).await.expect("zero-capacity read should succeed"), 0);
let mut out = Vec::new();
reader.read_to_end(&mut out).await.expect("input should remain readable");
assert_eq!(out, b"abc");
}
#[tokio::test] #[tokio::test]
async fn test_hardlimit_reader_short_input_returns_unexpected_eof() { async fn test_hardlimit_reader_short_input_returns_unexpected_eof() {
let data = b"abc"; let data = b"abc";
@@ -195,4 +234,18 @@ mod tests {
assert_eq!(err.kind(), std::io::ErrorKind::Other); assert_eq!(err.kind(), std::io::ErrorKind::Other);
assert!(err.to_string().contains("more bytes than specified")); assert!(err.to_string().contains("more bytes than specified"));
} }
#[tokio::test]
async fn test_hardlimit_reader_caps_each_read_before_reporting_extra_bytes() {
let mut reader = HardLimitReader::new(BufReader::new(&b"abcdef"[..]), 3);
let mut out = Vec::new();
let err = reader
.read_to_end(&mut out)
.await
.expect_err("bytes beyond the declared limit must be rejected");
assert_eq!(out, b"abc");
assert!(err.to_string().contains("more bytes than specified"));
}
} }
+3 -59
View File
@@ -715,13 +715,6 @@ async fn get_http_client(url: &str) -> io::Result<Client> {
Ok(cached.client_for(disable_proxy)) Ok(cached.client_for(disable_proxy))
} }
async fn get_fresh_http_client(url: &str) -> io::Result<Client> {
let tuning = internode_http_client_tuning();
let disable_proxy = should_disable_proxy_for_url(url, tuning);
let outbound_tls = crate::http_runtime_sources::outbound_tls_state().await;
build_http_client(disable_proxy, tuning, &outbound_tls).await
}
fn internode_request_context(method: &Method, url: &str, operation: Option<&'static str>) -> InternodeHttpRequestContext { fn internode_request_context(method: &Method, url: &str, operation: Option<&'static str>) -> InternodeHttpRequestContext {
let target = reqwest::Url::parse(url) let target = reqwest::Url::parse(url)
.ok() .ok()
@@ -969,28 +962,6 @@ impl HttpReader {
Self::with_capacity_and_stall_timeout(url, method, headers, body, 0, stall_timeout).await Self::with_capacity_and_stall_timeout(url, method, headers, body, 0, stall_timeout).await
} }
pub async fn new_fresh_connection_with_stall_timeout(
url: String,
method: Method,
headers: HeaderMap,
body: Option<Vec<u8>>,
stall_timeout: Option<Duration>,
) -> io::Result<Self> {
let init = Self::open(&url, &method, &headers, body, stall_timeout, true).await?;
Ok(Self {
inner: StreamReader::new(init.stream),
url,
method,
headers,
track_internode_metrics: init.track_internode_metrics,
internode_operation: init.internode_operation,
stall_timer: None,
stall_timeout: init.stall_timeout,
request_started: init.request_started,
duration_recorded: false,
})
}
/// Create a new HttpReader from a URL. The request is performed immediately. /// Create a new HttpReader from a URL. The request is performed immediately.
pub async fn with_capacity( pub async fn with_capacity(
url: String, url: String,
@@ -1010,7 +981,7 @@ impl HttpReader {
_read_buf_size: usize, _read_buf_size: usize,
stall_timeout: Option<Duration>, stall_timeout: Option<Duration>,
) -> io::Result<Self> { ) -> io::Result<Self> {
let init = Self::open(&url, &method, &headers, body, stall_timeout, false).await?; let init = Self::open(&url, &method, &headers, body, stall_timeout).await?;
Ok(Self { Ok(Self {
inner: StreamReader::new(init.stream), inner: StreamReader::new(init.stream),
url, url,
@@ -1031,16 +1002,10 @@ impl HttpReader {
headers: &HeaderMap, headers: &HeaderMap,
body: Option<Vec<u8>>, body: Option<Vec<u8>>,
stall_timeout: Option<Duration>, stall_timeout: Option<Duration>,
force_fresh_connection: bool,
) -> io::Result<HttpReaderInit> { ) -> io::Result<HttpReaderInit> {
let track_internode_metrics = is_internode_rpc_url(url); let track_internode_metrics = is_internode_rpc_url(url);
let internode_operation = internode_rpc_operation(url); let internode_operation = internode_rpc_operation(url);
let client = if force_fresh_connection { let client = get_http_client(url).await.inspect_err(|_| {
get_fresh_http_client(url).await
} else {
get_http_client(url).await
}
.inspect_err(|_| {
record_internode_error(track_internode_metrics, internode_operation); record_internode_error(track_internode_metrics, internode_operation);
})?; })?;
let mut request: RequestBuilder = client.request(method.clone(), url).headers(headers.clone()); let mut request: RequestBuilder = client.request(method.clone(), url).headers(headers.clone());
@@ -1156,28 +1121,7 @@ impl HttpChunkReader {
body: Option<Vec<u8>>, body: Option<Vec<u8>>,
stall_timeout: Option<Duration>, stall_timeout: Option<Duration>,
) -> io::Result<Self> { ) -> io::Result<Self> {
let init = HttpReader::open(&url, &method, &headers, body, stall_timeout, false).await?; let init = HttpReader::open(&url, &method, &headers, body, stall_timeout).await?;
Ok(Self {
inner: init.stream,
current: None,
track_internode_metrics: init.track_internode_metrics,
internode_operation: init.internode_operation,
stall_timer: None,
stall_timeout: init.stall_timeout,
request_started: init.request_started,
duration_recorded: false,
consecutive_empty_chunks: 0,
})
}
pub async fn new_fresh_connection_with_stall_timeout(
url: String,
method: Method,
headers: HeaderMap,
body: Option<Vec<u8>>,
stall_timeout: Option<Duration>,
) -> io::Result<Self> {
let init = HttpReader::open(&url, &method, &headers, body, stall_timeout, true).await?;
Ok(Self { Ok(Self {
inner: init.stream, inner: init.stream,
current: None, current: None,
+1 -1
View File
@@ -102,7 +102,7 @@ bytes.workspace = true
hex-simd.workspace = true hex-simd.workspace = true
[dev-dependencies] [dev-dependencies]
tracing-subscriber = { workspace = true, features = ["env-filter", "time"] } tracing-subscriber = { workspace = true, features = ["json", "env-filter", "time"] }
serial_test = { workspace = true } serial_test = { workspace = true }
temp-env = { workspace = true } temp-env = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
+111 -12
View File
@@ -65,6 +65,7 @@ const LOG_SUBSYSTEM_FOLDER: &str = "folder";
const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle"; const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
const LOG_SUBSYSTEM_HEAL: &str = "heal"; const LOG_SUBSYSTEM_HEAL: &str = "heal";
const EVENT_SCANNER_FOLDER_STATE: &str = "scanner_folder_state"; const EVENT_SCANNER_FOLDER_STATE: &str = "scanner_folder_state";
const EVENT_SCANNER_METADATA_CORRUPT: &str = "scanner_metadata_corrupt";
const EVENT_SCANNER_LIFECYCLE_ACTION: &str = "scanner_lifecycle_action"; const EVENT_SCANNER_LIFECYCLE_ACTION: &str = "scanner_lifecycle_action";
const EVENT_SCANNER_HEAL_ADMISSION: &str = "scanner_heal_admission"; const EVENT_SCANNER_HEAL_ADMISSION: &str = "scanner_heal_admission";
const EVENT_SCANNER_ALERT_STATE: &str = "scanner_alert_state"; const EVENT_SCANNER_ALERT_STATE: &str = "scanner_alert_state";
@@ -2154,17 +2155,34 @@ impl FolderScanner {
self.record_failed(&item.path); self.record_failed(&item.path);
if should_log_failed_object(into.failed_objects) { if should_log_failed_object(into.failed_objects) {
warn!( if let GetSizeFailureAction::HealMetadata { object } = &failure_action {
target: "rustfs::scanner::folder", error!(
event = EVENT_SCANNER_FOLDER_STATE, target: "rustfs::scanner::folder",
component = LOG_COMPONENT_SCANNER, event = EVENT_SCANNER_METADATA_CORRUPT,
subsystem = LOG_SUBSYSTEM_FOLDER, component = LOG_COMPONENT_SCANNER,
path = %item.path, subsystem = LOG_SUBSYSTEM_FOLDER,
failed_objects = into.failed_objects, drive = %self.local_disk.path().display(),
state = "get_size_failed", bucket = %item.bucket,
error = %e, object = %object,
"Scanner folder failed to get object size" metadata_path = %item.path,
); failed_objects = into.failed_objects,
state = "metadata_corrupt",
error = %e,
"Scanner detected corrupt object metadata"
);
} else {
warn!(
target: "rustfs::scanner::folder",
event = EVENT_SCANNER_FOLDER_STATE,
component = LOG_COMPONENT_SCANNER,
subsystem = LOG_SUBSYSTEM_FOLDER,
path = %item.path,
failed_objects = into.failed_objects,
state = "get_size_failed",
error = %e,
"Scanner folder failed to get object size"
);
}
} }
} }
@@ -3054,12 +3072,59 @@ mod tests {
use crate::{DiskOption, Endpoint, STORAGE_FORMAT_FILE, TierStats, new_disk, storageclass}; use crate::{DiskOption, Endpoint, STORAGE_FORMAT_FILE, TierStats, new_disk, storageclass};
use rustfs_filemeta::{FileInfo, FileMeta}; use rustfs_filemeta::{FileInfo, FileMeta};
use serial_test::serial; use serial_test::serial;
use std::io::Write;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::fs::{PermissionsExt, symlink}; use std::os::unix::fs::{PermissionsExt, symlink};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use temp_env::{with_var, with_var_unset}; use temp_env::{with_var, with_var_unset};
use tracing_subscriber::fmt::MakeWriter;
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone, Default)]
struct CapturedLogs {
buffer: Arc<Mutex<Vec<u8>>>,
}
struct CapturedLogWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl CapturedLogs {
fn contents(&self) -> String {
let buffer = self
.buffer
.lock()
.expect("captured logs mutex should not be poisoned")
.clone();
String::from_utf8(buffer).expect("captured logs should be valid UTF-8")
}
}
impl Write for CapturedLogWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.buffer
.lock()
.expect("captured logs mutex should not be poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for CapturedLogs {
type Writer = CapturedLogWriter;
fn make_writer(&'a self) -> Self::Writer {
CapturedLogWriter {
buffer: Arc::clone(&self.buffer),
}
}
}
#[test] #[test]
fn scanner_size_summary_application_saturates_usage_counters() { fn scanner_size_summary_application_saturates_usage_counters() {
let target = "arn:minio:replication::target".to_string(); let target = "arn:minio:replication::target".to_string();
@@ -4542,9 +4607,19 @@ mod tests {
assert!(budget.entries_visited() >= 1); assert!(budget.entries_visited() >= 1);
} }
#[tokio::test] #[tokio::test(flavor = "current_thread")]
#[serial] #[serial]
async fn test_scan_folder_corrupt_xl_meta_stops_erasure_data_dir_descent() { async fn test_scan_folder_corrupt_xl_meta_stops_erasure_data_dir_descent() {
let logs = CapturedLogs::default();
let subscriber = tracing_subscriber::fmt()
.json()
.with_max_level(tracing::Level::ERROR)
.with_writer(logs.clone())
.with_ansi(false)
.without_time()
.finish();
let _subscriber_guard = tracing::subscriber::set_default(subscriber);
let (mut scanner, temp_dir) = build_test_scanner().await; let (mut scanner, temp_dir) = build_test_scanner().await;
let _guard = TestGuard::new(60, 100, &mut scanner, temp_dir.clone()); let _guard = TestGuard::new(60, 100, &mut scanner, temp_dir.clone());
@@ -4596,6 +4671,30 @@ mod tests {
assert!(!budget.budget_elapsed()); assert!(!budget.budget_elapsed());
assert_eq!(budget.reason(), None); assert_eq!(budget.reason(), None);
let captured = logs.contents();
assert!(
!captured.contains("failed to check XL2 v1 format"),
"the context-free filemeta parser error must not be emitted"
);
let events = captured
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("captured scanner log should be valid JSON"))
.filter(|line| line["fields"]["event"] == EVENT_SCANNER_METADATA_CORRUPT)
.collect::<Vec<_>>();
assert_eq!(
events.len(),
1,
"one corrupt metadata observation must emit one scanner-owned diagnostic event"
);
let fields = &events[0]["fields"];
assert_eq!(fields["component"], LOG_COMPONENT_SCANNER);
assert_eq!(fields["subsystem"], LOG_SUBSYSTEM_FOLDER);
assert_eq!(fields["drive"], temp_dir.to_string_lossy().as_ref());
assert_eq!(fields["bucket"], "bucket");
assert_eq!(fields["object"], "object");
assert_eq!(fields["metadata_path"], metadata_path.to_string_lossy().as_ref());
assert_eq!(fields["state"], "metadata_corrupt");
let retry_budget = ScannerCycleBudget::new_with_progress_tracking( let retry_budget = ScannerCycleBudget::new_with_progress_tracking(
&parent, &parent,
crate::scanner_budget::ScannerCycleBudgetConfig { crate::scanner_budget::ScannerCycleBudgetConfig {
+9 -15
View File
@@ -3849,17 +3849,6 @@ impl ScannerIODisk for Disk {
let fivs = match meta.get_file_info_versions(item.bucket.as_str(), item.object_path().as_str(), false) { let fivs = match meta.get_file_info_versions(item.bucket.as_str(), item.object_path().as_str(), false) {
Ok(versions) => versions, Ok(versions) => versions,
Err(e) => { Err(e) => {
error!(
target: "rustfs::scanner::io",
event = EVENT_SCANNER_DISK_BUCKET_STATE,
component = LOG_COMPONENT_SCANNER,
subsystem = LOG_SUBSYSTEM_IO,
bucket = %item.bucket,
object = %item.object_path(),
state = "file_info_versions_failed",
error = %e,
"Scanner disk bucket failed to resolve file info versions"
);
return Err(scanner_metadata_corrupt_error( return Err(scanner_metadata_corrupt_error(
format!("failed to resolve file info versions: {e}"), format!("failed to resolve file info versions: {e}"),
&item.bucket, &item.bucket,
@@ -4023,7 +4012,7 @@ mod tests {
use crate::scanner_budget::ScannerCycleBudgetConfig; use crate::scanner_budget::ScannerCycleBudgetConfig;
use crate::scanner_folder::ScannerItem; use crate::scanner_folder::ScannerItem;
use crate::storage_api::owner::{EcstoreRebalStatus, EcstoreRebalanceInfo, EcstoreRebalanceMeta, EcstoreRebalanceStats}; use crate::storage_api::owner::{EcstoreRebalStatus, EcstoreRebalanceInfo, EcstoreRebalanceMeta, EcstoreRebalanceStats};
use crate::storage_api::scan::{BucketOperations as _, MakeBucketOptions, ObjectIO as _}; use crate::storage_api::scan::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions, ObjectIO as _};
use crate::{ use crate::{
DiskOption, ECStore, Endpoint, EndpointServerPools, Endpoints, InstanceContext, PoolEndpoints, ScannerObjectOptions, DiskOption, ECStore, Endpoint, EndpointServerPools, Endpoints, InstanceContext, PoolEndpoints, ScannerObjectOptions,
ScannerPutObjReader, init_bucket_metadata_sys_for_scanner_tests, init_ecstore_config_for_scanner_tests, ScannerPutObjReader, init_bucket_metadata_sys_for_scanner_tests, init_ecstore_config_for_scanner_tests,
@@ -4248,16 +4237,21 @@ mod tests {
async fn multi_pool_scanner_cycle_zero_fills_bucket_absent_from_first_pool() { async fn multi_pool_scanner_cycle_zero_fills_bucket_absent_from_first_pool() {
let (_temp_dir, store) = setup_two_pool_scanner_store().await; let (_temp_dir, store) = setup_two_pool_scanner_store().await;
let bucket = format!("scanner-second-pool-{}", Uuid::new_v4().simple()); let bucket = format!("scanner-second-pool-{}", Uuid::new_v4().simple());
store.pools[1].disk_set[0] store
.make_bucket(&bucket, &MakeBucketOptions::default()) .make_bucket(&bucket, &MakeBucketOptions::default())
.await .await
.expect("bucket should be created only in the second pool"); .expect("bucket and its authoritative metadata should be created");
let body = b"second-only"; let body = b"second-only";
let mut reader = ScannerPutObjReader::from_vec(body.to_vec()); let mut reader = ScannerPutObjReader::from_vec(body.to_vec());
store.pools[1].disk_set[0] store.pools[1]
.put_object(&bucket, "pool-b", &mut reader, &ScannerObjectOptions::default()) .put_object(&bucket, "pool-b", &mut reader, &ScannerObjectOptions::default())
.await .await
.expect("object should be written only to the second pool"); .expect("object should be written only to the second pool");
store.pools[0]
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("bucket should be removed from the first pool only");
init_bucket_metadata_sys_for_scanner_tests(store.clone()).await;
let ctx = CancellationToken::new(); let ctx = CancellationToken::new();
let budget = ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default()); let budget = ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default());
+1 -1
View File
@@ -278,7 +278,7 @@ pub(crate) mod scan {
SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION,
}; };
#[cfg(test)] #[cfg(test)]
pub(crate) use super::storage_contracts::{MakeBucketOptions, ObjectIO}; pub(crate) use super::storage_contracts::{DeleteBucketOptions, MakeBucketOptions, ObjectIO};
} }
pub(crate) mod scanner_io { pub(crate) mod scanner_io {
+284 -279
View File
@@ -1069,309 +1069,314 @@ mod serial_tests {
#[serial] #[serial]
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"] #[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
async fn test_transition_and_restore_flows() { async fn test_transition_and_restore_flows() {
let (disk_paths, ecstore) = setup_test_env().await; async move {
let (disk_paths, ecstore) = setup_test_env().await;
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase(); let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&tier_name).await; let backend = register_mock_tier(&tier_name).await;
let put_bucket = format!("test-immediate-put-{}", &Uuid::new_v4().simple().to_string()[..8]); let put_bucket = format!("test-immediate-put-{}", &Uuid::new_v4().simple().to_string()[..8]);
let put_object = "test/object.txt"; let put_object = "test/object.txt";
let put_payload = b"Hello, immediate transition!"; let put_payload = b"Hello, immediate transition!";
create_test_bucket(&ecstore, put_bucket.as_str()).await; create_test_bucket(&ecstore, put_bucket.as_str()).await;
set_bucket_lifecycle_transition_with_tier(put_bucket.as_str(), &tier_name) set_bucket_lifecycle_transition_with_tier(put_bucket.as_str(), &tier_name)
.await
.expect("Failed to set lifecycle configuration");
let mut reader = PutObjReader::from_vec(put_payload.to_vec());
let mut metadata = HashMap::new();
metadata.insert("content-type".to_string(), "text/plain".to_string());
ecstore
.put_object(
put_bucket.as_str(),
put_object,
&mut reader,
&ObjectOptions {
user_defined: metadata,
..Default::default()
},
)
.await
.expect("Failed to upload transition metadata test object");
enqueue_transition_for_existing_objects(ecstore.clone(), put_bucket.as_str())
.await
.expect("Failed to enqueue transitioned put object");
let put_info = wait_for_transition(&ecstore, put_bucket.as_str(), put_object, TRANSITION_WAIT_TIMEOUT)
.await
.expect("object should transition after enqueueing existing objects");
assert_eq!(put_info.transitioned_object.status, "complete");
assert_eq!(put_info.transitioned_object.tier, tier_name);
assert!(backend.contains(&put_info.transitioned_object.name).await);
{
let transitioned = backend
.stored(&put_info.transitioned_object.name)
.await .await
.expect("transitioned object should be present in mock backend"); .expect("Failed to set lifecycle configuration");
assert_eq!(transitioned.metadata.get("content-type"), Some(&"text/plain".to_string()));
assert!(
!transitioned.metadata.contains_key("x-amz-replication-status"),
"transitioned objects must not inherit replication status defaults"
);
assert!(
!transitioned.metadata.contains_key("x-amz-object-lock-legal-hold"),
"transitioned objects must not invent object lock headers"
);
}
// Cross-shard xl.meta transition assertion helper (rustfs/backlog#1148 ilm-6): let mut reader = PutObjReader::from_vec(put_payload.to_vec());
// every disk must agree on the transition tuple for the object. let mut metadata = HashMap::new();
let put_meta = assert_transition_meta_consistent(&disk_paths, put_bucket.as_str(), put_object).await; metadata.insert("content-type".to_string(), "text/plain".to_string());
assert_eq!(put_meta.status, "complete"); ecstore
assert_eq!(put_meta.tier, tier_name); .put_object(
put_bucket.as_str(),
put_object,
&mut reader,
&ObjectOptions {
user_defined: metadata,
..Default::default()
},
)
.await
.expect("Failed to upload transition metadata test object");
let multipart_bucket = format!("test-immediate-mpu-{}", &Uuid::new_v4().simple().to_string()[..8]); enqueue_transition_for_existing_objects(ecstore.clone(), put_bucket.as_str())
let multipart_object = "test/multipart.txt"; .await
.expect("Failed to enqueue transitioned put object");
create_test_bucket(&ecstore, multipart_bucket.as_str()).await; let put_info = wait_for_transition(&ecstore, put_bucket.as_str(), put_object, TRANSITION_WAIT_TIMEOUT)
set_bucket_lifecycle_transition_with_tier(multipart_bucket.as_str(), &tier_name) .await
.await .expect("object should transition after enqueueing existing objects");
.expect("Failed to set lifecycle configuration");
let upload = ecstore assert_eq!(put_info.transitioned_object.status, "complete");
.new_multipart_upload(multipart_bucket.as_str(), multipart_object, &ObjectOptions::default()) assert_eq!(put_info.transitioned_object.tier, tier_name);
.await assert!(backend.contains(&put_info.transitioned_object.name).await);
.expect("Failed to create multipart upload"); {
let transitioned = backend
.stored(&put_info.transitioned_object.name)
.await
.expect("transitioned object should be present in mock backend");
assert_eq!(transitioned.metadata.get("content-type"), Some(&"text/plain".to_string()));
assert!(
!transitioned.metadata.contains_key("x-amz-replication-status"),
"transitioned objects must not inherit replication status defaults"
);
assert!(
!transitioned.metadata.contains_key("x-amz-object-lock-legal-hold"),
"transitioned objects must not invent object lock headers"
);
}
let part_data = b"multipart immediate transition"; // Cross-shard xl.meta transition assertion helper (rustfs/backlog#1148 ilm-6):
let mut reader = PutObjReader::from_vec(part_data.to_vec()); // every disk must agree on the transition tuple for the object.
let part = ecstore let put_meta = assert_transition_meta_consistent(&disk_paths, put_bucket.as_str(), put_object).await;
.put_object_part( assert_eq!(put_meta.status, "complete");
multipart_bucket.as_str(), assert_eq!(put_meta.tier, tier_name);
multipart_object,
&upload.upload_id,
1,
&mut reader,
&ObjectOptions::default(),
)
.await
.expect("Failed to upload multipart part");
ecstore let multipart_bucket = format!("test-immediate-mpu-{}", &Uuid::new_v4().simple().to_string()[..8]);
.clone() let multipart_object = "test/multipart.txt";
.complete_multipart_upload(
multipart_bucket.as_str(),
multipart_object,
&upload.upload_id,
vec![CompletePart {
part_num: 1,
etag: part.etag.clone(),
..Default::default()
}],
&ObjectOptions::default(),
)
.await
.expect("Failed to complete multipart upload");
enqueue_transition_for_existing_objects(ecstore.clone(), multipart_bucket.as_str()) create_test_bucket(&ecstore, multipart_bucket.as_str()).await;
.await set_bucket_lifecycle_transition_with_tier(multipart_bucket.as_str(), &tier_name)
.expect("Failed to enqueue transitioned multipart object"); .await
.expect("Failed to set lifecycle configuration");
let multipart_info = wait_for_transition(&ecstore, multipart_bucket.as_str(), multipart_object, TRANSITION_WAIT_TIMEOUT) let upload = ecstore
.await .new_multipart_upload(multipart_bucket.as_str(), multipart_object, &ObjectOptions::default())
.expect("object should transition after enqueueing existing objects"); .await
.expect("Failed to create multipart upload");
assert_eq!(multipart_info.transitioned_object.status, "complete"); let part_data = b"multipart immediate transition";
assert_eq!(multipart_info.transitioned_object.tier, tier_name); let mut reader = PutObjReader::from_vec(part_data.to_vec());
assert!(backend.contains(&multipart_info.transitioned_object.name).await); let part = ecstore
.put_object_part(
multipart_bucket.as_str(),
multipart_object,
&upload.upload_id,
1,
&mut reader,
&ObjectOptions::default(),
)
.await
.expect("Failed to upload multipart part");
let src_bucket = format!("test-immediate-copy-src-{}", &Uuid::new_v4().simple().to_string()[..8]); ecstore
let dst_bucket = format!("test-immediate-copy-dst-{}", &Uuid::new_v4().simple().to_string()[..8]); .clone()
let src_object = "test/source.txt"; .complete_multipart_upload(
let dst_object = "test/copied.txt"; multipart_bucket.as_str(),
let payload = b"copy object immediate transition"; multipart_object,
&upload.upload_id,
create_test_bucket(&ecstore, src_bucket.as_str()).await; vec![CompletePart {
create_test_bucket(&ecstore, dst_bucket.as_str()).await;
set_bucket_lifecycle_transition_with_tier(dst_bucket.as_str(), &tier_name)
.await
.expect("Failed to set destination lifecycle configuration");
upload_test_object(&ecstore, src_bucket.as_str(), src_object, payload).await;
let mut src_info = ecstore
.get_object_info(src_bucket.as_str(), src_object, &ObjectOptions::default())
.await
.expect("Failed to load source object info");
src_info.put_object_reader = Some(PutObjReader::from_vec(payload.to_vec()));
ecstore
.copy_object(
src_bucket.as_str(),
src_object,
dst_bucket.as_str(),
dst_object,
&mut src_info,
&ObjectOptions::default(),
&ObjectOptions::default(),
)
.await
.expect("Failed to copy object");
enqueue_transition_for_existing_objects(ecstore.clone(), dst_bucket.as_str())
.await
.expect("Failed to enqueue transitioned copied object");
let copy_info = wait_for_transition(&ecstore, dst_bucket.as_str(), dst_object, TRANSITION_WAIT_TIMEOUT)
.await
.expect("copied object should transition after enqueueing existing objects");
assert_eq!(copy_info.transitioned_object.status, "complete");
assert_eq!(copy_info.transitioned_object.tier, tier_name);
assert!(backend.contains(&copy_info.transitioned_object.name).await);
let bucket_name = format!("test-lifecycle-update-{}", &Uuid::new_v4().simple().to_string()[..8]);
let object_name = "test/existing.txt";
let payload = b"existing object before lifecycle";
create_test_bucket(&ecstore, bucket_name.as_str()).await;
upload_test_object(&ecstore, bucket_name.as_str(), object_name, payload).await;
set_bucket_lifecycle_transition_with_tier(bucket_name.as_str(), &tier_name)
.await
.expect("Failed to set lifecycle configuration");
enqueue_transition_for_existing_objects(ecstore.clone(), bucket_name.as_str())
.await
.expect("Failed to enqueue transition for existing objects");
let info = wait_for_transition(&ecstore, bucket_name.as_str(), object_name, TRANSITION_WAIT_TIMEOUT)
.await
.expect("existing object should transition after lifecycle update");
assert_eq!(info.transitioned_object.status, "complete");
assert_eq!(info.transitioned_object.tier, tier_name);
assert!(backend.contains(&info.transitioned_object.name).await);
let bucket_name = format!("test-restore-mpu-{}", &Uuid::new_v4().simple().to_string()[..8]);
let object_name = "test/restore.txt";
let part1 = vec![b'a'; 5 * 1024 * 1024];
let part2 = b"restored-tail".to_vec();
let expected = [part1.clone(), part2.clone()].concat();
create_test_bucket(&ecstore, bucket_name.as_str()).await;
set_bucket_lifecycle_transition_with_tier(bucket_name.as_str(), &tier_name)
.await
.expect("Failed to set lifecycle configuration");
let upload = ecstore
.new_multipart_upload(bucket_name.as_str(), object_name, &ObjectOptions::default())
.await
.expect("Failed to create multipart upload");
let mut part1_reader = PutObjReader::from_vec(part1);
let uploaded_part1 = ecstore
.put_object_part(
bucket_name.as_str(),
object_name,
&upload.upload_id,
1,
&mut part1_reader,
&ObjectOptions::default(),
)
.await
.expect("Failed to upload first multipart part");
let mut part2_reader = PutObjReader::from_vec(part2);
let uploaded_part2 = ecstore
.put_object_part(
bucket_name.as_str(),
object_name,
&upload.upload_id,
2,
&mut part2_reader,
&ObjectOptions::default(),
)
.await
.expect("Failed to upload second multipart part");
ecstore
.clone()
.complete_multipart_upload(
bucket_name.as_str(),
object_name,
&upload.upload_id,
vec![
CompletePart {
part_num: 1, part_num: 1,
etag: uploaded_part1.etag.clone(), etag: part.etag.clone(),
..Default::default() ..Default::default()
}, }],
CompletePart { &ObjectOptions::default(),
part_num: 2, )
etag: uploaded_part2.etag.clone(), .await
..Default::default() .expect("Failed to complete multipart upload");
},
],
&ObjectOptions::default(),
)
.await
.expect("Failed to complete multipart upload");
enqueue_transition_for_existing_objects(ecstore.clone(), bucket_name.as_str()) enqueue_transition_for_existing_objects(ecstore.clone(), multipart_bucket.as_str())
.await .await
.expect("Failed to enqueue transitioned restore object"); .expect("Failed to enqueue transitioned multipart object");
let transitioned = wait_for_transition(&ecstore, bucket_name.as_str(), object_name, TRANSITION_WAIT_TIMEOUT) let multipart_info =
.await wait_for_transition(&ecstore, multipart_bucket.as_str(), multipart_object, TRANSITION_WAIT_TIMEOUT)
.expect("multipart object should transition after enqueueing existing objects"); .await
assert_eq!(transitioned.parts.len(), 2); .expect("object should transition after enqueueing existing objects");
ecstore assert_eq!(multipart_info.transitioned_object.status, "complete");
.clone() assert_eq!(multipart_info.transitioned_object.tier, tier_name);
.restore_transitioned_object( assert!(backend.contains(&multipart_info.transitioned_object.name).await);
bucket_name.as_str(),
object_name, let src_bucket = format!("test-immediate-copy-src-{}", &Uuid::new_v4().simple().to_string()[..8]);
&ObjectOptions { let dst_bucket = format!("test-immediate-copy-dst-{}", &Uuid::new_v4().simple().to_string()[..8]);
transition: TransitionOptions { let src_object = "test/source.txt";
restore_request: RestoreRequest { let dst_object = "test/copied.txt";
days: Some(1), let payload = b"copy object immediate transition";
description: None,
glacier_job_parameters: None, create_test_bucket(&ecstore, src_bucket.as_str()).await;
output_location: None, create_test_bucket(&ecstore, dst_bucket.as_str()).await;
select_parameters: None, set_bucket_lifecycle_transition_with_tier(dst_bucket.as_str(), &tier_name)
tier: None, .await
type_: None, .expect("Failed to set destination lifecycle configuration");
upload_test_object(&ecstore, src_bucket.as_str(), src_object, payload).await;
let mut src_info = ecstore
.get_object_info(src_bucket.as_str(), src_object, &ObjectOptions::default())
.await
.expect("Failed to load source object info");
src_info.put_object_reader = Some(PutObjReader::from_vec(payload.to_vec()));
ecstore
.copy_object(
src_bucket.as_str(),
src_object,
dst_bucket.as_str(),
dst_object,
&mut src_info,
&ObjectOptions::default(),
&ObjectOptions::default(),
)
.await
.expect("Failed to copy object");
enqueue_transition_for_existing_objects(ecstore.clone(), dst_bucket.as_str())
.await
.expect("Failed to enqueue transitioned copied object");
let copy_info = wait_for_transition(&ecstore, dst_bucket.as_str(), dst_object, TRANSITION_WAIT_TIMEOUT)
.await
.expect("copied object should transition after enqueueing existing objects");
assert_eq!(copy_info.transitioned_object.status, "complete");
assert_eq!(copy_info.transitioned_object.tier, tier_name);
assert!(backend.contains(&copy_info.transitioned_object.name).await);
let bucket_name = format!("test-lifecycle-update-{}", &Uuid::new_v4().simple().to_string()[..8]);
let object_name = "test/existing.txt";
let payload = b"existing object before lifecycle";
create_test_bucket(&ecstore, bucket_name.as_str()).await;
upload_test_object(&ecstore, bucket_name.as_str(), object_name, payload).await;
set_bucket_lifecycle_transition_with_tier(bucket_name.as_str(), &tier_name)
.await
.expect("Failed to set lifecycle configuration");
enqueue_transition_for_existing_objects(ecstore.clone(), bucket_name.as_str())
.await
.expect("Failed to enqueue transition for existing objects");
let info = wait_for_transition(&ecstore, bucket_name.as_str(), object_name, TRANSITION_WAIT_TIMEOUT)
.await
.expect("existing object should transition after lifecycle update");
assert_eq!(info.transitioned_object.status, "complete");
assert_eq!(info.transitioned_object.tier, tier_name);
assert!(backend.contains(&info.transitioned_object.name).await);
let bucket_name = format!("test-restore-mpu-{}", &Uuid::new_v4().simple().to_string()[..8]);
let object_name = "test/restore.txt";
let part1 = vec![b'a'; 5 * 1024 * 1024];
let part2 = b"restored-tail".to_vec();
let expected = [part1.clone(), part2.clone()].concat();
create_test_bucket(&ecstore, bucket_name.as_str()).await;
set_bucket_lifecycle_transition_with_tier(bucket_name.as_str(), &tier_name)
.await
.expect("Failed to set lifecycle configuration");
let upload = ecstore
.new_multipart_upload(bucket_name.as_str(), object_name, &ObjectOptions::default())
.await
.expect("Failed to create multipart upload");
let mut part1_reader = PutObjReader::from_vec(part1);
let uploaded_part1 = ecstore
.put_object_part(
bucket_name.as_str(),
object_name,
&upload.upload_id,
1,
&mut part1_reader,
&ObjectOptions::default(),
)
.await
.expect("Failed to upload first multipart part");
let mut part2_reader = PutObjReader::from_vec(part2);
let uploaded_part2 = ecstore
.put_object_part(
bucket_name.as_str(),
object_name,
&upload.upload_id,
2,
&mut part2_reader,
&ObjectOptions::default(),
)
.await
.expect("Failed to upload second multipart part");
ecstore
.clone()
.complete_multipart_upload(
bucket_name.as_str(),
object_name,
&upload.upload_id,
vec![
CompletePart {
part_num: 1,
etag: uploaded_part1.etag.clone(),
..Default::default()
},
CompletePart {
part_num: 2,
etag: uploaded_part2.etag.clone(),
..Default::default()
},
],
&ObjectOptions::default(),
)
.await
.expect("Failed to complete multipart upload");
enqueue_transition_for_existing_objects(ecstore.clone(), bucket_name.as_str())
.await
.expect("Failed to enqueue transitioned restore object");
let transitioned = wait_for_transition(&ecstore, bucket_name.as_str(), object_name, TRANSITION_WAIT_TIMEOUT)
.await
.expect("multipart object should transition after enqueueing existing objects");
assert_eq!(transitioned.parts.len(), 2);
ecstore
.clone()
.restore_transitioned_object(
bucket_name.as_str(),
object_name,
&ObjectOptions {
transition: TransitionOptions {
restore_request: RestoreRequest {
days: Some(1),
description: None,
glacier_job_parameters: None,
output_location: None,
select_parameters: None,
tier: None,
type_: None,
},
..Default::default()
}, },
..Default::default() ..Default::default()
}, },
..Default::default() )
}, .await
) .expect("Failed to restore transitioned multipart object");
.await
.expect("Failed to restore transitioned multipart object");
let restored = ecstore let restored = ecstore
.get_object_info(bucket_name.as_str(), object_name, &ObjectOptions::default()) .get_object_info(bucket_name.as_str(), object_name, &ObjectOptions::default())
.await .await
.expect("Failed to load restored object info"); .expect("Failed to load restored object info");
assert_eq!(restored.parts.len(), 2); assert_eq!(restored.parts.len(), 2);
assert!(restored.restore_expires.is_some()); assert!(restored.restore_expires.is_some());
assert!(!restored.restore_ongoing); assert!(!restored.restore_ongoing);
let mut reader = ecstore let mut reader = ecstore
.get_object_reader(bucket_name.as_str(), object_name, None, http::HeaderMap::new(), &ObjectOptions::default()) .get_object_reader(bucket_name.as_str(), object_name, None, http::HeaderMap::new(), &ObjectOptions::default())
.await .await
.expect("Failed to read restored object"); .expect("Failed to read restored object");
let mut data = Vec::new(); let mut data = Vec::new();
reader reader
.stream .stream
.read_to_end(&mut data) .read_to_end(&mut data)
.await .await
.expect("Failed to consume restored object stream"); .expect("Failed to consume restored object stream");
assert_eq!(data, expected); assert_eq!(data, expected);
}
.boxed_local()
.await;
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+29
View File
@@ -50,6 +50,14 @@ pub const SUFFIX_SOURCE_DELETEMARKER: &str = "source-deletemarker";
pub const SUFFIX_SOURCE_PROXY_REQUEST: &str = "source-proxy-request"; pub const SUFFIX_SOURCE_PROXY_REQUEST: &str = "source-proxy-request";
pub const SUFFIX_SOURCE_REPLICATION_REQUEST: &str = "source-replication-request"; pub const SUFFIX_SOURCE_REPLICATION_REQUEST: &str = "source-replication-request";
pub const SUFFIX_SOURCE_REPLICATION_CHECK: &str = "source-replication-check"; pub const SUFFIX_SOURCE_REPLICATION_CHECK: &str = "source-replication-check";
// LWW timestamps for replicated tag/retention/legal-hold modifications. MinIO
// declares these with mixed case (internal/http/headers.go:
// X-Minio-Source-Replication-Tagging-Timestamp / -Retention-Timestamp /
// -LegalHold-Timestamp); HTTP header names compare case-insensitively, so the
// lowercase suffix forms interoperate. Values are RFC3339 on the wire.
pub const SUFFIX_SOURCE_REPLICATION_TAGGING_TIMESTAMP: &str = "source-replication-tagging-timestamp";
pub const SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP: &str = "source-replication-retention-timestamp";
pub const SUFFIX_SOURCE_REPLICATION_LEGALHOLD_TIMESTAMP: &str = "source-replication-legalhold-timestamp";
pub const SUFFIX_REPLICATION_SSEC_CRC: &str = "replication-ssec-crc"; pub const SUFFIX_REPLICATION_SSEC_CRC: &str = "replication-ssec-crc";
/// Returns true if the key is object-encryption metadata understood by RustFS or MinIO. /// Returns true if the key is object-encryption metadata understood by RustFS or MinIO.
@@ -196,6 +204,27 @@ mod tests {
assert_eq!(get_object_encryption_original_size(&metadata).expect("valid size"), Some(42)); assert_eq!(get_object_encryption_original_size(&metadata).expect("valid size"), Some(42));
} }
#[test]
fn replication_timestamp_headers_match_minio_wire_names() {
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_TAGGING_TIMESTAMP, "2026-01-02T03:04:05Z");
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP, "2026-01-02T03:04:06Z");
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_LEGALHOLD_TIMESTAMP, "2026-01-02T03:04:07Z");
// The exact names MinIO's object-api-options.go reads (its Get()
// canonicalizes case, so a case-insensitive match is wire-equivalent).
for name in [
"X-Minio-Source-Replication-Tagging-Timestamp",
"X-Minio-Source-Replication-Retention-Timestamp",
"X-Minio-Source-Replication-LegalHold-Timestamp",
"x-rustfs-source-replication-tagging-timestamp",
"x-rustfs-source-replication-retention-timestamp",
"x-rustfs-source-replication-legalhold-timestamp",
] {
assert!(headers.contains_key(name), "replication timestamp header {name} must be written");
}
}
#[test] #[test]
fn test_get_header() { fn test_get_header() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
-3
View File
@@ -659,9 +659,6 @@ mod test {
// Port should be in valid range (u16 max is always <= 65535) // Port should be in valid range (u16 max is always <= 65535)
assert!(port1 > 0); assert!(port1 > 0);
assert!(port2 > 0); assert!(port2 > 0);
// Different calls should typically return different ports
assert_ne!(port1, port2);
} }
#[test] #[test]
+1 -1
View File
@@ -16,7 +16,6 @@ for later deletion.
- `table-catalog-strong-snapshot-v1` durable strong catalog snapshot compatibility: version 1 writes continue during mixed-version rollout until operators confirm that every serving node reads version 2, and version 1 table/view identifier collisions remain available only for cleanup. Remove version 1 writes and collision cleanup after the minimum supported RustFS release reads version 2 and every retained durable strong snapshot is collision-free and has been upgraded to version 2. - `table-catalog-strong-snapshot-v1` durable strong catalog snapshot compatibility: version 1 writes continue during mixed-version rollout until operators confirm that every serving node reads version 2, and version 1 table/view identifier collisions remain available only for cleanup. Remove version 1 writes and collision cleanup after the minimum supported RustFS release reads version 2 and every retained durable strong snapshot is collision-free and has been upgraded to version 2.
- `table-catalog-migration-fence-v1` durable strong migration fence compatibility: version 1 "PREPARING" fences did not distinguish a known-absent global strong snapshot from an unknown baseline, so retries read them but fail closed if the global snapshot is missing. Version 2 preserves the same JSON shape and records the pre-migration global snapshot ETag in the existing target_snapshot_etag field while the fence is "PREPARING". Remove version 1 reads after every supported direct-upgrade source writes version 2 fences and operators have completed or cancelled every older in-progress backing migration. - `table-catalog-migration-fence-v1` durable strong migration fence compatibility: version 1 "PREPARING" fences did not distinguish a known-absent global strong snapshot from an unknown baseline, so retries read them but fail closed if the global snapshot is missing. Version 2 preserves the same JSON shape and records the pre-migration global snapshot ETag in the existing target_snapshot_etag field while the fence is "PREPARING". Remove version 1 reads after every supported direct-upgrade source writes version 2 fences and operators have completed or cancelled every older in-progress backing migration.
- `table-catalog-backing-manifest-v1-wire-labels` durable strong backing manifest labels: version 1 published "STRONG_KV_WAL" and "CUT_OVER_LINEARIZABLE_READS" before the implementation was narrowed to the ETag-CAS durable snapshot backing. Internal names and operator documentation describe the implemented semantics, while version 1 responses retain those labels for existing clients. Replace the labels only in a new manifest version with an explicit client migration contract. - `table-catalog-backing-manifest-v1-wire-labels` durable strong backing manifest labels: version 1 published "STRONG_KV_WAL" and "CUT_OVER_LINEARIZABLE_READS" before the implementation was narrowed to the ETag-CAS durable snapshot backing. Internal names and operator documentation describe the implemented semantics, while version 1 responses retain those labels for existing clients. Replace the labels only in a new manifest version with an explicit client migration contract.
- `cross-pool-fence-v1` authenticated unsupported advertisement: predeployment servers recognize the versioned cross-pool fence capability probe but report support version 0, allowing a later all-peer probe to distinguish predeployment nodes without activating a second lock domain. Replace the unsupported advertisement only when composite lock acquisition, a cluster-wide activation fence, complete fleet proof, commit-time proof revalidation, and fail-closed revocation ship together.
- `replacement-recovery-status-v1` replacement recovery status capability: new peers treat an unimplemented ReplacementRecoveryStatus RPC as an explicitly non-definitive rolling-upgrade response, so Admin v4 cannot claim distributed replacement completion from old peers. Remove the fallback after the minimum supported RustFS peer version implements ReplacementRecoveryStatus. - `replacement-recovery-status-v1` replacement recovery status capability: new peers treat an unimplemented ReplacementRecoveryStatus RPC as an explicitly non-definitive rolling-upgrade response, so Admin v4 cannot claim distributed replacement completion from old peers. Remove the fallback after the minimum supported RustFS peer version implements ReplacementRecoveryStatus.
- `table-catalog-dotted-namespace` Iceberg REST namespace path compatibility: existing RustFS clients use dotted namespace paths, while the standard multi-level contract uses the URL-encoded unit separator `%1F`. New servers accept both forms so a rolling upgrade does not invalidate existing catalog configuration. Remove the dotted fallback after the minimum supported RustFS release advertises `%1F` and all supported clients have refreshed their catalog configuration. - `table-catalog-dotted-namespace` Iceberg REST namespace path compatibility: existing RustFS clients use dotted namespace paths, while the standard multi-level contract uses the URL-encoded unit separator `%1F`. New servers accept both forms so a rolling upgrade does not invalidate existing catalog configuration. Remove the dotted fallback after the minimum supported RustFS release advertises `%1F` and all supported clients have refreshed their catalog configuration.
- `rustfs-5509` FileInfo positional MessagePack decoding: beta.11 serialized 28 fields, while beta.12 inserted transition-version fields in the middle and serialized an incompatible 30-field array. New releases write named maps and retain readers for both shipped array layouts so direct and rolling upgrades can read either release. Remove the positional-array readers after every supported direct-upgrade release writes named maps and no retained RPC payload can contain a pre-map FileInfo array. - `rustfs-5509` FileInfo positional MessagePack decoding: beta.11 serialized 28 fields, while beta.12 inserted transition-version fields in the middle and serialized an incompatible 30-field array. New releases write named maps and retain readers for both shipped array layouts so direct and rolling upgrades can read either release. Remove the positional-array readers after every supported direct-upgrade release writes named maps and no retained RPC payload can contain a pre-map FileInfo array.
@@ -34,6 +33,7 @@ for later deletion.
- `tonic-013-status-render` peer RPC failure classification: internode failures that reach a node only as text (a peer's error_info payload, a status flattened through format!) are classified by matching the rendering of an Unavailable gRPC status. Releases up to 1.0.0-alpha.38 shipped tonic 0.13, which rendered that status as "status: Unavailable, message: ..."; tonic 0.14 renders it as "code: 'The service is currently unavailable', message: ...". Both forms are matched so an older peer's relayed text still marks an unreachable peer offline. Remove the tonic 0.13 form after the minimum supported RustFS peer version ships tonic 0.14 or later. - `tonic-013-status-render` peer RPC failure classification: internode failures that reach a node only as text (a peer's error_info payload, a status flattened through format!) are classified by matching the rendering of an Unavailable gRPC status. Releases up to 1.0.0-alpha.38 shipped tonic 0.13, which rendered that status as "status: Unavailable, message: ..."; tonic 0.14 renders it as "code: 'The service is currently unavailable', message: ...". Both forms are matched so an older peer's relayed text still marks an unreachable peer offline. Remove the tonic 0.13 form after the minimum supported RustFS peer version ships tonic 0.14 or later.
- `rustfs-5063` pre-beta.9 Local KMS recovery: persisted Local KMS configs from beta.8 and earlier predate the explicit insecure-development flag, and encrypted key files use the legacy SHA-256 KDF. Remove the config fallback after supported upgrades have rewritten or explicitly resaved all pre-beta.9 configs with the development-default field, and remove the legacy KDF after supported upgrades have rewritten all pre-beta.9 Local KMS key files with explicit at-rest protection. - `rustfs-5063` pre-beta.9 Local KMS recovery: persisted Local KMS configs from beta.8 and earlier predate the explicit insecure-development flag, and encrypted key files use the legacy SHA-256 KDF. Remove the config fallback after supported upgrades have rewritten or explicitly resaved all pre-beta.9 configs with the development-default field, and remove the legacy KDF after supported upgrades have rewritten all pre-beta.9 Local KMS key files with explicit at-rest protection.
- `sse-local-dek-json-v1` legacy local SSE DEK decoding: releases before the JSON envelope wrote wrapped DEKs as `base64(nonce):base64(ciphertext)`, so readers retain that decoder while all new writes use the versioned JSON envelope. Remove the colon decoder after the minimum supported direct-upgrade release writes JSON envelopes and migration tooling has rewritten every retained legacy object. - `sse-local-dek-json-v1` legacy local SSE DEK decoding: releases before the JSON envelope wrote wrapped DEKs as `base64(nonce):base64(ciphertext)`, so readers retain that decoder while all new writes use the versioned JSON envelope. Remove the colon decoder after the minimum supported direct-upgrade release writes JSON envelopes and migration tooling has rewritten every retained legacy object.
- `multipart-compression-default-off-window` staged multipart disk-compression rollout: releases before the resumable legacy decompressor fail transient reads of compressed objects under mid-payload suspension, so multipart uploads advertise the compression marker only when RUSTFS_COMPRESSION_MULTIPART_ENABLED is set in addition to RUSTFS_COMPRESSION_ENABLED, keeping rolling upgrades from creating new compressed multipart objects while pre-fix nodes may still serve reads. Flip the default to enabled (and retire the extra switch) after the minimum supported direct-upgrade release ships the resumable decompressor.
## Review Checklist ## Review Checklist
+1 -1
View File
@@ -111,7 +111,7 @@ inventory. Generic function-local names such as `CACHE`, `LOCK`, `INIT`, and
| `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object_usecase.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. | | `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object_usecase.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. |
| `SUPPORTED_HEADERS` | `rustfs/src/storage/options.rs` | Cache or constant / owner-local constant | Supported-header lookup state stays private to storage option parsing. | | `SUPPORTED_HEADERS` | `rustfs/src/storage/options.rs` | Cache or constant / owner-local constant | Supported-header lookup state stays private to storage option parsing. |
| `AUDIT_TARGET_SPECS`, `NOTIFICATION_TARGET_SPECS` | `rustfs/src/admin/handlers/audit.rs`, `rustfs/src/admin/handlers/event.rs`, `rustfs/src/admin/handlers/plugins_instances.rs` | Cache or constant / owner-local constant | Admin target descriptor tables stay private to their handler owners. | | `AUDIT_TARGET_SPECS`, `NOTIFICATION_TARGET_SPECS` | `rustfs/src/admin/handlers/audit.rs`, `rustfs/src/admin/handlers/event.rs`, `rustfs/src/admin/handlers/plugins_instances.rs` | Cache or constant / owner-local constant | Admin target descriptor tables stay private to their handler owners. |
| `SITE_REPLICATION_PEER_CLIENT`, `SITE_REPLICATION_STATE_LOCK` | `rustfs/src/admin/handlers/site_replication.rs` | Process-global owner-local cache / guard | Site-replication peer client cache and state lock stay private to site-replication handlers. | | `SITE_REPLICATION_PEER_CLIENT` | `rustfs/src/admin/handlers/site_replication.rs` | Process-global owner-local cache | Site-replication peer client cache stays private to site-replication handlers. The state RMW transaction holds no process-local mutex — see `rustfs/src/admin/site_replication_state.rs`. |
| `AUDIT_MODULE_ENABLED`, `NOTIFY_MODULE_ENABLED`, `PERSISTED_NOTIFY_MODULE_ENABLED`, `PERSISTED_AUDIT_MODULE_ENABLED`, `PERSISTED_MODULE_SWITCH_CONFIGURED` | `rustfs/src/server/audit.rs`, `rustfs/src/server/event.rs`, `rustfs/src/server/module_switch.rs` | Process-global owner-local toggles | Audit/notify module snapshots stay private to the server module switch owners. | | `AUDIT_MODULE_ENABLED`, `NOTIFY_MODULE_ENABLED`, `PERSISTED_NOTIFY_MODULE_ENABLED`, `PERSISTED_AUDIT_MODULE_ENABLED`, `PERSISTED_MODULE_SWITCH_CONFIGURED` | `rustfs/src/server/audit.rs`, `rustfs/src/server/event.rs`, `rustfs/src/server/module_switch.rs` | Process-global owner-local toggles | Audit/notify module snapshots stay private to the server module switch owners. |
| `DELETE_TAIL_TOTAL`, `DELETE_CLEANUP_TOTAL`, `DELETE_REPLICATION_TOTAL`, `DELETE_NOTIFY_TOTAL` | `rustfs/src/delete_tail_activity.rs` | Process-global owner-local counters | Delete-tail activity counters stay private behind delete-tail activity helpers. | | `DELETE_TAIL_TOTAL`, `DELETE_CLEANUP_TOTAL`, `DELETE_REPLICATION_TOTAL`, `DELETE_NOTIFY_TOTAL` | `rustfs/src/delete_tail_activity.rs` | Process-global owner-local counters | Delete-tail activity counters stay private behind delete-tail activity helpers. |
| `EMBEDDED_SERVER_STARTED` | `rustfs/src/startup_lifecycle.rs` | Process-global owner-local guard | Embedded startup single-start protection stays private to startup lifecycle. | | `EMBEDDED_SERVER_STARTED` | `rustfs/src/startup_lifecycle.rs` | Process-global owner-local guard | Embedded startup single-start protection stays private to startup lifecycle. |

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