* fix(lock): let waiters hear releases and let acquisition succeed past registered waiters
Same-key write contention scaled superlinearly with writer count: 8
concurrent conditional PUTs on one key cost ~340-460 ms, 16 cost ~700 ms,
32 cost ~5 s, against ~4 ms per uncontended write and ~10 ms actual lock
holds (measured via RUSTFS_OBJECT_LOCK_DIAG at 1 ms thresholds). Outcomes
were always correct; the cost was pure waiting.
Two coupled defects in fast_lock caused it:
1. The slow path's early retries slept without subscribing to anything.
notify_writer()/notify_readers() are gated on the waiter counters,
which a sleeper never increments, so a release during the backoff
woke nobody. The lock sat free while every loser slept out its full
backoff, and the ladder compounded: successive acquires landed at
the cumulative ladder offsets (10+20+40+80+100... ms).
2. try_acquire_exclusive demanded the entire packed state word be zero,
including the readers_waiting/writers_waiting counter bits. A lock
with registered waiters could be acquired by no one - including the
waiters themselves, each blocked by the others' registration - so
contended acquisition only succeeded in windows where every waiter
happened to be unregistered. This is also why (1) could not be fixed
by simply registering the sleepers: registration alone deadlocks
acquisition until the acquire deadline. try_acquire_shared already
masks correctly and preserves the counter bits in its CAS; the
exclusive path now mirrors it.
The fix: mask the acquisition CAS to ownership bits only (writer flag,
active readers), and turn the early-retry sleep into a notification wait
bounded by the same backoff, so a release wakes a waiter immediately
while the bound still protects against lost or stolen wakeups exactly as
NOTIFY_WAIT_CAP does for the post-retry wait.
With both changes, 8 concurrent same-key CAS writers resolve in 17-29 ms
(was 340-460 ms) and 32 resolve in 20-53 ms (was ~5 s), with per-racer
cost now decreasing in N. Outcomes remain exactly one winner, N-1
precondition failures, zero errors at every width. cargo test -p
rustfs-lock passes 113/113 at pristine-parity runtime, including
test_concurrent_write_lock_contention, which previously only passed
because sleepers were invisible to it.
* test(lock): pin both halves of the waiter-starvation fix
The fix commit touched only production files, so reverting either half
left the suite green: test_concurrent_write_lock_contention only waits
for five writers to finish and never asserts that acquisition happens
before the backoff ladder runs out.
Three tests, one per revert:
* exclusive_acquisition_ignores_registered_waiters (state.rs) - a free
lock with registered waiters must be acquirable, and the CAS must
preserve the counters. Fails against the all-zero `expected`.
* early_retry_registers_as_waiter (shard.rs) - a waiter in the
early-retry backoff must appear in the writer waiter count within the
~750ms early-retry phase, since notify_writer/notify_readers are gated
on those counters. Fails against a bare `sleep`, which registers
nowhere.
* contended_writers_drain_promptly_after_release (tests.rs) - 16 same-key
writers, all registered behind one holder, must drain within 1s of the
release rather than sit out their 5s acquire deadlines. Fails against
the all-zero `expected` end to end.
Wakeup latency is deliberately not asserted anywhere. NOTIFY_POOL is a
process-global of 128 Notify slots shared by every lock, so a waiter in
a concurrently-running test can consume another's notify_one and push it
to the end of its rung: a 24-key latency probe measured ~150us in
isolation and ~92ms - a full unexpired rung - alongside the existing
64-key missed-wakeup test. That is the stolen wakeup NOTIFY_WAIT_CAP
already exists to bound, and it makes any in-suite latency budget flaky.
cargo test -p rustfs-lock: 116/116.
Signed-off-by: Miguel Amador <miguel@amador.one>
---------
Signed-off-by: Miguel Amador <miguel@amador.one>
Preserve metadata replication operations in the durable MRF and route tagging, retention, and legal-hold updates through the existing full-object replication transport. Keep ACL propagation outside the contract because the current object model has no durable object ACL state.
Refs #1616
The snapshots in crates/kms/src/api_types.rs pinned DeleteKeyResponse,
ListKeysResponse, DescribeKeyResponse and CancelKeyDeletionResponse, none
of which is serialized by any handler: those endpoints answer with
DeleteKmsKeyResponse and siblings in rustfs/src/admin/handlers/kms_keys.rs,
separate types carrying different fields. A breaking change to an admin
response could not fail them. Tag, untag and update-description had the
same gap, where the handler discards the kms-side response and serves its
own KmsKeyMetadataResponse.
Pin the shapes in the crate that produces them, and delete the four kms
mirrors. They were never in the pub use api_types list, had no
constructors and no callers, and only looked live because those snapshots
named them.
Keep the api_types snapshots that pin something real: configure, start,
stop and status are served verbatim by kms_dynamic, and the tag family
are live ObjectEncryptionService return types whose snapshots pin this
crate's public API rather than a wire shape.
A same-name CopyObject marks the operation `metadata_only`, which lets the
store layer rewrite `xl.meta` in place and leave the data blocks untouched.
The handler independently strips the source encryption metadata and calls
`sse_encryption`, which mints a *fresh* DEK. On an unversioned bucket both
happen at once, so the object ends up with a new DEK sitting beside ciphertext
sealed under the old one, and can never be decrypted again.
The mirror case is silent: an encrypted source copied without any destination
SSE keeps its ciphertext while losing the key metadata, so GET returns raw
ciphertext as if it were plaintext, with HTTP 200 and no error anywhere.
Keep `metadata_only` off whenever either side of the copy is encrypted, so the
store layer performs a full read/write rewrite through `put_object`. This is
the same resolution the versioned historical-restore path already uses for
this risk (issue #4238), and it matches MinIO's
`isSourceEncrypted || isTargetEncrypted -> metadataOnly = false` guard in
CopyObjectHandler.
The target half of the predicate deliberately tests `effective_sse` rather
than the request headers MinIO inspects: `effective_sse` also resolves the
bucket default-encryption rule, and `sse_encryption` mints a DEK from that
resolved value. A header-only check would miss a same-key copy performed under
a bucket default rule. The source half reuses `ObjectInfo::is_encrypted` so a
future encryption flavour is covered here as soon as it is recognised there.
Versioned buckets were already safe: that path falls through to `put_object`
regardless of `metadata_only`. RestoreObject also sets `metadata_only` but
only appends restore keys and never re-derives a DEK, so it is unaffected.
SizeSummary::tier_stats was populated for every scanned object but
apply_scanner_size_summary dropped it, so per-tier usage never reached
DataUsageInfo. Wire it through the same merge chain repl_target_stats
already uses, up to DataUsageInfo::tier_stats.
DataUsageEntry used the derived MessagePack encoding, which serialises
structs as arrays: appending a field turns the whole cache into a decode
error for older readers, so mixed-version nodes would invalidate each
other's cache every scan cycle. Give it the same hand-written
map-encoded Serialize DataUsageCacheInfo already carries, and record the
invariant in AGENTS.md.
Widen TierStats counters from i32 to u64 so a tier past 2^31 versions
cannot make checked_merge reject an entire usage snapshot, and drop the
duplicate TierStats/AllTierStats definitions in the scanner crate in
favour of the data-usage ones.
* feat(kms): add data key rewrap and wrapping inspection primitives
Rewrap re-protects an existing data key envelope with the master key's
current version without touching the data key itself, which is the
precondition for ever retiring an older version: until every envelope a
version wrapped has been moved off it, destroying that version orphans
every object whose data key it wrapped.
Adds KmsBackend::rewrap_data_key and its read-only counterpart
describe_data_key_wrapping, both gated by a new BackendCapabilities::rewrap
flag and defaulting to UnsupportedCapability. Vault KV2 unwraps with the
frozen version record that wrapped the envelope and re-wraps with the
current material; Vault Transit uses the native transit/rewrap endpoint so
the data key never enters this process.
No read or write path changes: nothing calls these yet.
* test(kms): cover the rewrap primitive against a scripted Vault
* fix(kms): resolve both key materials before the data key is unwrapped
Keeps every fallible step out of the window in which the plaintext data
key exists, so no error path can drop it without zeroizing it first.
* fix(kms): fail closed on Local key records this build cannot interpret
The Local backend's protection marker is the only version discriminator its
key records have, and three readers walked past it.
`ensure_missing_salt_can_be_generated` skipped every record it could not read
or parse, so a directory whose protection state is unknown still got a fresh
salt published before startup validation failed. That write is the
irreversible step: the next startup finds a salt file, never re-enters the
guard, and the evidence that the real salt was lost is gone. Every record the
guard now rejects already failed startup key validation a few lines later, so
no directory that initializes today stops initializing.
Backup export and restore folded an unknown marker into "material corrupt" /
"bundle corrupted". The record is intact and a newer build reads it fine, so
the operator response is a version change, not a disaster recovery. Both now
classify the marker before their schema parse, sharing one probe with the
backend reader.
`list_keys` dropped any record it could not decode from the page. Concurrent
removal stays a skip; anything else fails the listing rather than answering
"these are your keys" with a set that silently omits one.
* test(kms): cover every fail-closed path around the Local protection marker
Each test fails on the pre-fix code in the way the fix is about: the salt
cases because a replacement salt is published before startup validation
fails, the export and restore cases because the verdict comes back as
corruption, and the listing case because the record is edited out of the page.
The restore commit marker's unknown-version branch had no test at all,
unlike its Vault counterpart; it is now driven from the decoder, from the
restore entry point, and from backend startup.
Also states the widened salt guard in the Local backend operations doc,
including the operator recovery path for an unrecognized record.
* fix(kms): say 'not a readable JSON object' when the marker probe cannot parse
The probe now fails on any input that is not a JSON object, not only on
malformed JSON, so the message must cover both.
* test(kms): assert the salt file before the error variant
The replacement salt is written before the error the guard reports, so the
file assertion is the one that fails on a regression.
* test(kms): guard the new backup error variant's display string
api_types carried a second DeleteKeyRequest that nothing referenced: it is
absent from the lib.rs re-export list, so rustfs_kms::DeleteKeyRequest has
always resolved to types::DeleteKeyRequest through `pub use types::*`, and
the admin handler builds the real type via `use rustfs_kms::types::*`.
The copy had also drifted apart from the type it shadowed. It still called
force_immediate "for development/testing only" and described the 7-30 day
window as advisory, and it never gained the confirm_key_id field that the
immediate-deletion gate now requires. A caller that reached into api_types
and deserialized into it would silently drop confirm_key_id.
api_types::DeleteKeyResponse stays: it is live, pinned by the
kms_management_responses_have_stable_json_shapes snapshot alongside the
list/describe/cancel response shapes, and it mirrors the admin wire
response rather than duplicating types::DeleteKeyResponse, whose fields
differ. Its doc comment now records why no request twin sits beside it.
docs(kms): correct the KV2 at-rest boundary and rotation rejection claims
The Vault KV2 section claimed the backend reports its confidentiality
boundary as `at_rest_protection: vault-kv2-acl` through `backend_info`.
That accessor, the `BackendInfo` type and its assertion test were removed
in rustfs/rustfs#5501, and no replacement reports the boundary. State
instead that the boundary is documented only, that `kms/status` exposes a
capability matrix covering supported operations rather than key-material
location, and that the backup manifest's `at_rest_protection` field is a
bundle declaration rather than a backend self-report.
The rotation section named `InvalidOperation` as the error Local and
Static return. Neither advertises `rotate`, so the product path falls to
the shared `KmsBackend` default and returns `UnsupportedCapability`;
`InvalidOperation` survives only in a test-only client helper.
* feat(kms): report configuration references that block a key deletion
Adds a KeyImpactReport that states which configuration still points at a
key, how exhaustively the sources were read, and which sources were not
consulted at all. The report deliberately carries no in-use or
safe-to-delete claim: it covers the configuration layer only, so an empty
reference list means nothing was found in the scanned sources, never that
the key is unreferenced.
Immediate deletion destroys key material without ever reaching the
deletion worker, so it never passed the worker's reference gate. The
manager now consults the same checker on that path and refuses with a
typed KeyStillReferenced error. This only ever adds a refusal; the
scheduled deletion path and the worker's blocking behaviour are
unchanged.
* test(kms): cover the immediate-deletion reference refusal
* feat(kms): surface configuration references on the admin key endpoints
DeleteKey and DescribeKey now return an impact section listing the
configuration that points at the key, so an operator scheduling a
deletion sees what will refuse to destroy the material instead of
learning it from a server-side log once the window has run out.
The section is reported, never acted on: scheduling still succeeds while
references exist, and the deletion worker's gate remains the only thing
that decides whether material is destroyed. An immediate deletion that
the manager refuses for an outstanding reference now answers 409.
* test(kms): pin the impact wire shape and the unreferenced force-delete path
* fix(kms): make the DescribeKey impact section opt-in
Collecting the section lists every bucket, and DescribeKey is polled, so
carrying that fan-out on the default read path trades a hot path's cost
for a diagnostic. It is now collected only for impact=true; without the
parameter the endpoint does exactly the work it did before and returns
no impact field.
A value that is neither true nor false is refused rather than read as
off, so a typo cannot answer a request for the section with a response
that merely lacks one. DeleteKey still reports unconditionally: that is
the request whose consequences the caller cannot otherwise see, and it
is not polled.
* fix(kms): box the query-parse refusal now that responses carry impact
The delete response grew an impact section, which pushed it past the
size clippy accepts inline in a Result. It is a full response body
rather than an error code, so it is boxed at the one place that returns
it as an error; the wire shape and the public field type are unchanged.
* fix(kms): persist and report the Vault KV2 key rotation timestamp
The rotation-age gauge reads KeyInfo::rotated_at and falls back to
created_at when it is absent. The KV2 backend never persisted a rotation
time and hardcoded None in describe_key, so a key rotated many times and
a key that was never rotated reported the same age.
Record the rotation time on the same check-and-set write that switches
the current version, and report the stored value from describe_key and
from the recovered-create path. Records written before the field existed
keep deserializing and stay unstamped: no timestamp is invented for a
rotation this node cannot vouch for.
* test(kms): cover Vault KV2 rotation timestamp persistence and legacy records
* docs(kms): correct the static backend's MinIO compatibility claim
`StaticConfig` claimed the backend derives DEKs via "HMAC-SHA256 +
AES-256-GCM, matching the MinIO builtin/static KMS wire format". Neither
half holds: the configured key is used directly as the AES-256-GCM key
with no derivation step, and wrapped DEKs are serialized as RustFS's own
`DataKeyEnvelope` JSON. MinIO's KMS ciphertext uses a different shape,
which `is_data_key_envelope` explicitly classifies as foreign (see the
`minio_legacy` case in encryption/dek.rs).
The claim as written tells a migrating operator that MinIO-written
ciphertext will open here, which it will not. Restate what the backend
actually does and point at rustfs/backlog#1638 for the real interop work.
Also refresh the neighbouring ciphertext-format note in static_kms.rs,
which still described a raw `ciphertext || nonce` layout that the JSON
envelope replaced.
* ci(minio-interop): fix the dead test selector and guard against empty runs
The job selected its tests with `-p rustfs-ecstore -E
'binary(minio_generated_read_test)'`. #5435 moved those reader tests from
crates/ecstore/tests/minio_generated_read_test.rs into the `rustfs` crate
as a `#[cfg(test)] mod`, which removed that test binary; the selector has
selected zero interop tests since. Point it at the tests where they now
live, verified locally:
cargo nextest list --run-ignored all -p rustfs --features rio-v2 \
-E 'test(minio_generated_read_test::)' # 4 tests, was 0
Add a guard step in front of the run. The old `binary(...)` form happened
to fail loudly once its binary disappeared, but the name-based form that
replaces it is a valid filterset even when it matches nothing, so a later
rename would silently reduce this job to a pass that asserts nothing. The
guard counts the selection and fails with an explicit reason; the count
comes from `filter-match.status`, since the JSON's top-level `test-count`
is the package total and ignores `-E`. `--no-tests=fail` on the run step
covers the same case if the guard is ever dropped.
Also record in the header what this job does and does not prove: MinIO
wrapped-DEK envelopes are still rejected by both envelope parsers, and
that work is tracked in rustfs/backlog#1638.
* fix(kms): page the Local key listing so the deletion sweep sees every key
The Local backend answered every ListKeys with the first `limit` entries of
`read_dir` and a hardcoded `truncated: false`, so the deletion sweep ended
after one page: on a deployment with more keys than a page, expired key
material past the first page was never destroyed, and the lifecycle gauges
published that partial page as if it were the whole key set.
Listing now orders the key set by identifier and pages through it, with the
marker as an exclusive lower bound on the identifier rather than an index, so
a key added or removed between pages — including the marker key, which the
sweep itself destroys — cannot make the listing skip keys or restart. Only the
page is read from disk, so a list costs the requested limit rather than the
size of the key set.
The pagination arithmetic lives in a shared helper so the other self-paging
backends can adopt the same semantics, and the sweep now stops instead of
re-listing when a backend hands back the cursor it was just given.
* fix(kms): give ListKeys a defined zero-limit and cursor contract
`GET /rustfs/admin/v3/kms/keys?limit=0` reached the Vault KV2 and Vault
Transit backends as a page size of zero, where the page arithmetic indexed the
element before an empty page and aborted the request. Both backends also
resolved the marker by searching for it in the key list, so a marker naming a
key that had since been removed silently restarted the listing from the
beginning instead of resuming after it.
All four self-paging backends now share one contract: a zero limit is answered
as an empty, non-truncated page without reaching the backend at all, and the
marker is an exclusive lower bound on the key identifier rather than a position
in the list. The Vault backends read metadata only for the page they return,
so a list costs the requested limit instead of the whole key set, and KV2 now
applies the usage and status filters it previously accepted and ignored.
* feat(kms): accept the AWS backend through KMS configuration
The AWS KMS backend could be constructed but not selected: the admin
configure API had no AWS variant and startup rejected the backend name.
The configure request pins the region rather than defaulting it, because
that configuration is persisted once and replayed on every node: leaving
the region to each node's ambient provider chain would let nodes address
different regions, and therefore different keys, while reporting an
identical configuration. The request accepts no credential fields, so
credentials stay with the aws-config provider chain on each node, and
`deny_unknown_fields` refuses attempts to submit them anyway.
* test(kms): cover AWS backend selection through the service manager
An end-to-end check that an admin configure request selects the AWS
backend, builds a client, and passes the startup health check. Marked
#[ignore]: it needs real AWS credentials, though it creates no key and
is therefore not billable on its own.