Compare commits

..

218 Commits

Author SHA1 Message Date
overtrue 1f8d013b8d merge: sync main and preserve prepared delete ownership 2026-10-25 22:13:43 +08:00
overtrue b8858d0c70 test(ecstore): preserve control progress during physical pause 2026-10-25 21:46:10 +08:00
overtrue 4fd444852c fix(ecstore): compile legacy delete helpers only in tests 2026-10-25 20:56:56 +08:00
overtrue 05f84793a3 test(ecstore): use the scoped metadata fixture setter 2026-10-25 20:46:44 +08:00
overtrue 2fa9a433ad fix(ecstore): compare multipart metadata keys in commit scope 2026-10-25 20:46:28 +08:00
overtrue 08811ec243 fix(ecstore): fail closed on multipart recovery lock contention 2026-10-25 20:43:41 +08:00
overtrue 0f89e9b8b8 chore: merge fixed main ead77d7 into plugin integration 2026-10-25 20:33:46 +08:00
overtrue de8b465c41 fix(ecstore): batch concurrent fresh authority reads 2026-10-25 20:15:49 +08:00
overtrue 03f77844b7 chore: sync main listing fixes for plugin integration 2026-10-25 11:36:42 +08:00
overtrue b44c76db52 test(e2e): simplify inventory request error propagation 2026-10-25 10:51:20 +08:00
overtrue d0096af742 test(rpc): bound unavailable recovery assertion frame 2026-10-25 10:25:19 +08:00
overtrue 35e07b44f4 test(ecstore): retain lifecycle ownership in Select fixture 2026-10-25 10:00:05 +08:00
overtrue 7d91176cea fix(ecstore): include integrity indexes in artifact inventory 2026-10-25 09:50:25 +08:00
overtrue 001d935569 test(windows): cover artifact prefix admission 2026-10-25 09:32:54 +08:00
overtrue cc157adca8 fix(ecstore): retain typed lifecycle owner after upload cancellation 2026-10-25 08:57:12 +08:00
overtrue e0917fb617 fix(ecstore): allow sibling multipart publications 2026-10-25 08:57:12 +08:00
overtrue 6a30694765 perf(rpc): avoid duplicate delete recovery authority reads 2026-10-25 08:57:12 +08:00
overtrue 991d647c78 chore: merge main into plugin runner integration 2026-10-25 08:57:02 +08:00
overtrue aafa85c6e5 test(ecstore): observe Linux physical rename tails 2026-10-25 08:45:06 +08:00
overtrue 01f5aa9c3a fix(ecstore): retain lifecycle owner during migration cleanup 2026-10-25 08:43:45 +08:00
overtrue e27bfb2006 test: remove redundant values in plugin boundary tests 2026-10-25 08:43:45 +08:00
overtrue f095c6a476 refactor(plugins): reuse object references and align inventory tests 2026-10-25 05:22:00 +08:00
overtrue a223e74c83 fix(ecstore): bound the bucket creation handler future 2026-10-25 05:10:30 +08:00
overtrue 1d1964215d fix(storage): validate artifact namespace write limits 2026-10-25 05:10:30 +08:00
overtrue 5b23462652 chore: sync integration with main 2026-10-25 05:05:16 +08:00
overtrue 02f6d87201 refactor(ecstore): simplify prepared deletion arguments 2026-10-25 05:05:13 +08:00
overtrue 37a54d3ff2 fix(storage): reject empty artifact prefix components 2026-10-25 05:05:13 +08:00
overtrue d1aaaee9bf refactor(ecstore): group storage targets and simplify assertions 2026-10-25 04:59:16 +08:00
overtrue bebd5d0631 test(policy): remove redundant artifact grant clones 2026-10-25 04:48:39 +08:00
overtrue 0de18a3577 test(ecstore): isolate Windows batch delete alias locks 2026-10-25 04:33:14 +08:00
overtrue 04756b0a93 test(admin): preserve plugin copy trait resolution 2026-10-25 04:19:18 +08:00
overtrue 6fc62edcf7 fix(admin): route plugin errors through the existing S3 facade 2026-10-25 04:12:32 +08:00
overtrue 68d1790b74 test(rpc): isolate guarded recovery request state 2026-10-25 03:41:30 +08:00
overtrue 6fde372f5b fix(ecstore): retain bootstrap receivers through format publication 2026-10-25 03:39:25 +08:00
overtrue 503a852214 merge(main): sync listing fixes before plugin integration 2026-10-25 03:38:01 +08:00
overtrue ab7a8c400f test(storage): split RPC cases and initialize metadata fixtures 2026-10-25 03:21:21 +08:00
overtrue 85663e9647 fix(ecstore): isolate large reader and startup futures 2026-10-25 02:48:36 +08:00
overtrue 61e4cfbc2a merge(main): preserve multipart recovery and plugin fences 2026-10-25 02:22:18 +08:00
overtrue 675722e5f8 fix(storage): reduce request future stack residency 2026-10-25 02:12:43 +08:00
overtrue f6dfe86c58 merge: sync main and preserve delete recovery ownership 2026-10-25 01:58:45 +08:00
overtrue 3c9b9589f2 fix(ecstore): bound nested storage futures 2026-10-25 01:40:55 +08:00
overtrue a5ad283c8c merge: sync main for plugin integration validation 2026-10-25 00:49:27 +08:00
overtrue dcdd53ccf3 fix(plugins): admit parallel artifact writes under shared fences 2026-10-25 00:42:53 +08:00
overtrue bdd7efc96d fix(plugins): reconcile runner integration with current main 2026-10-25 00:14:43 +08:00
overtrue 9fbf5d0553 feat(storage): retain managed artifact versions for database backups 2026-10-20 23:51:32 +09:00
overtrue e7bebb0fc5 chore: sync main through 5313b72ba into integration 2026-10-20 20:06:41 +09:00
overtrue cacdaa65a0 feat(storage): add fenced exact artifact version deletion 2026-10-20 20:05:56 +09:00
overtrue efe2255ccf chore: sync main through 6f582b054 into integration 2026-10-20 04:51:06 +09:00
overtrue f4b694ed19 chore: sync main through 05b483b31 into integration 2026-10-19 19:38:08 +09:00
overtrue aa95a32ea2 chore: sync main rebalance status fix into plugin integration 2026-10-19 18:14:28 +09:00
overtrue 3f3e892d86 feat(admin): stream sealed artifact inventories 2026-10-19 16:39:50 +09:00
overtrue 874d2babcc chore: sync main CI scheduling into plugin integration 2026-10-19 16:27:42 +09:00
overtrue 7c85783a08 chore: merge main into plugin runner integration 2026-10-19 15:28:53 +09:00
overtrue 541fbbe74a feat(storage): authenticate all-member artifact inventory streams 2026-10-19 14:46:05 +09:00
overtrue 08ba7a7976 feat(storage): inventory sealed artifact versions on local disks 2026-10-19 13:19:30 +09:00
overtrue 81f214c99a merge: sync upstream deletion recovery and rollout fixes 2026-10-19 12:14:05 +09:00
overtrue dff05785de merge: sync upstream auth and deployment fixes 2026-10-19 11:12:56 +09:00
overtrue cf2e3af4d9 merge: sync main into plugin runner integration 2026-10-19 10:29:29 +09:00
overtrue e0f0628bc1 feat(storage): seal artifact writes across one disk inventory 2026-10-19 08:15:20 +09:00
overtrue 6de3034e36 fix(storage): reject ordinary deletes from managed artifact buckets
Reject single, batch, and exact-version deletes before staging mutations.
Transfer real lifecycle ownership through detached deletion and reuse the
cleaner's existing owner to avoid a queued-writer deadlock.

Correct the physical-tail cancellation observer and cover disconnected PUT
publication plus managed delete refusal with four-node tests.
2026-10-19 06:57:35 +09:00
overtrue ae259e127d feat(storage): seal distributed artifact repair writes 2026-10-19 05:49:40 +09:00
overtrue 8233e3fe4c feat(storage): enable distributed artifact PUT sealing 2026-10-19 02:38:26 +09:00
overtrue f88b7b6ba5 fix(storage): bind volume creation to the receiving instance 2026-10-19 01:52:02 +09:00
overtrue 0307c9a7e2 fix(storage): retain admission through HTTP stream writes 2026-10-19 01:44:38 +09:00
overtrue 6a8d0aba58 fix(storage): retain admission through deletion cleanup 2026-10-19 01:06:40 +09:00
overtrue ee6c2afe43 fix(storage): serialize multipart publication and rollback 2026-10-19 00:09:40 +09:00
overtrue 9ca8bc3ca6 fix(storage): record failed rename admission metrics 2026-10-18 23:21:59 +09:00
overtrue 3678703a98 fix(storage): retain dual-volume admission through file rename 2026-10-18 23:11:43 +09:00
overtrue fe798c13e7 fix(rpc): admit grouped version deletion before classification 2026-10-18 22:35:09 +09:00
overtrue 24b77349aa feat(storage): admit legacy bulk version deletion 2026-10-18 22:22:18 +09:00
overtrue 8c55396bfe fix(rpc): admit staged single-object version deletion 2026-10-18 22:04:43 +09:00
overtrue 5be8badb20 fix(storage): retain admission across staged version deletion 2026-10-18 21:48:17 +09:00
overtrue 46daaadbb3 fix(rpc): admit legacy single-object delete rollback 2026-10-18 21:40:48 +09:00
overtrue f3b6c4df6b fix(storage): retain admission through single-object delete rollback 2026-10-18 21:38:26 +09:00
overtrue 7183849227 fix(storage): admit ordinary version deletion before classification 2026-10-18 18:41:13 +08:00
overtrue 942ce79c4e fix(storage): admit ordinary undo before artifact classification 2026-10-18 06:08:50 +08:00
overtrue fec1262f1a fix(storage): admit metadata updates before bucket classification 2026-10-18 05:39:45 +08:00
overtrue 5eb46cb29d fix(rpc): admit both volumes before legacy rename classification 2026-10-18 05:30:33 +08:00
overtrue 2cd904d0e7 feat(storage): prepare legacy renames with dual-volume ownership 2026-10-18 05:18:24 +08:00
overtrue 61eec6da18 fix(storage): reject contended rollback backup publication 2026-10-18 04:53:45 +08:00
overtrue 5b8cd2a5dc test(storage): initialize real lifecycle owners in fixtures 2026-10-18 04:51:11 +08:00
overtrue d7824049b2 fix(storage): bind metadata RPCs to prepared volume leases 2026-10-18 04:32:19 +08:00
overtrue d46e4c54a6 fix(storage): bind legacy file RPCs to volume ownership 2026-10-18 04:00:46 +08:00
overtrue 3ca0b5f35e fix(storage): retain volume locks through physical mutations 2026-10-18 03:26:41 +08:00
overtrue 00b5ddc0ad feat(storage): persist artifact receiver write fences 2026-10-18 03:01:03 +08:00
overtrue ad4c2bc9b1 docs(storage): describe explicit attempt repair sealing 2026-10-17 23:24:07 +08:00
overtrue ef5fe69d52 feat(admin): expose attempt repair sealing control 2026-10-17 23:23:52 +08:00
overtrue ba6f37e58d test(ecstore): verify durable attempt repair seals 2026-10-17 23:23:52 +08:00
overtrue 31eb2e5a4a feat(ecstore): persist attempt repair admission seals 2026-10-17 23:22:11 +08:00
overtrue 2f3654793d feat(admin): inspect managed artifact bucket identity 2026-10-17 18:42:22 +08:00
overtrue ce09e7817c test(iam): use valid broker policy in cross-bucket fixture 2026-10-17 18:00:39 +08:00
overtrue 31dc8a0fea test(storage): reload physical artifact faults from fresh stores 2026-10-17 17:49:25 +08:00
overtrue d243a4a29e test(storage): verify artifact creation observation boundaries 2026-10-17 17:35:48 +08:00
overtrue 1048374cbd feat(admin): create dedicated versioned artifact buckets 2026-10-17 17:30:38 +08:00
overtrue de32dece41 feat(iam): add explicit cross-bucket delegation v2 restrictions 2026-10-17 17:27:30 +08:00
overtrue f79f318ec4 test(admin): register artifact handler integration checks 2026-10-17 17:11:06 +08:00
overtrue 60fe68a04a test(admin): complete plugin delegation route inventory 2026-10-17 16:47:13 +08:00
overtrue 498be1fa53 test(admin): exercise artifact controls with real IAM and storage 2026-10-17 16:43:52 +08:00
overtrue b798d291d7 feat(admin): expose scoped artifact seal and retirement controls 2026-10-17 16:43:52 +08:00
overtrue 57c4d4c719 test(policy): distinguish attempt namespace from object scope 2026-10-17 16:36:19 +08:00
overtrue 560b083278 feat(policy): scope artifact control actions to IAM resources 2026-10-17 16:34:13 +08:00
overtrue 36afe8bb57 docs(storage): register and describe artifact retirement checks 2026-10-17 15:47:38 +08:00
overtrue 57963574b7 feat(ecstore): retire exact artifact versions before repair 2026-10-17 15:47:38 +08:00
overtrue c56a597374 test(ecstore): cover exact artifact version retirement 2026-10-17 15:46:46 +08:00
overtrue 5354e6dccf test(ecstore): establish repair ownership before inventory races 2026-10-17 15:30:48 +08:00
overtrue 7608c5af11 docs(storage): document the ecstore regression stack setting 2026-10-17 15:29:36 +08:00
overtrue c926e10de0 test(ecstore): create an authoritative multipart restore bucket 2026-10-17 15:25:11 +08:00
overtrue 972dafccde test(ecstore): expose owned fixtures through the set boundary 2026-10-17 15:21:59 +08:00
overtrue 5116bb2bbb test(ecstore): restore with an authoritative Object Lock snapshot 2026-10-17 15:19:56 +08:00
overtrue 08d6e4343d test(ecstore): import lifecycle fixture for shard identity cases 2026-10-17 15:19:56 +08:00
overtrue 7d3a6acc57 test(ecstore): resolve legacy identity in owned disk fixtures 2026-10-17 15:19:56 +08:00
overtrue aabb26b519 test(ecstore): retain lifecycle owners in raw write regressions 2026-10-17 15:19:56 +08:00
overtrue 163e6064da test(ecstore): align fixture visibility with its shared constructor 2026-10-17 15:01:40 +08:00
overtrue c7a9b76543 test(ecstore): preserve slice iteration in multipart checks 2026-10-17 14:58:42 +08:00
overtrue 7cc4ce5447 test(ecstore): preserve live fixture owners when retaining roots 2026-10-17 14:57:45 +08:00
overtrue 44fe49e5fa fix(ecstore): bound repair scope futures and clarify deadlines 2026-10-17 13:31:40 +08:00
overtrue dde2c50309 fix(s3): expose the opaque bucket lifecycle owner 2026-10-17 07:58:26 +08:00
overtrue d5a8f022bd test(ecstore): distinguish repair caller and disk deadlines 2026-10-17 05:58:01 +08:00
overtrue 150d349ade test(ecstore): exercise real metadata repair disk timeouts 2026-10-17 05:58:01 +08:00
overtrue 38056f3783 test(ecstore): acquire real owners in borrowed write fixtures 2026-10-17 05:58:01 +08:00
overtrue 0b40e35e76 test(ecstore): give raw disk fixtures a real metadata owner 2026-10-17 05:58:01 +08:00
overtrue 458b144ac7 fix(s3): retain validated lifecycle owners for copy writes 2026-10-16 23:40:01 +08:00
overtrue d58b80d54e test(ecstore): retain ordinary repair lifecycle through physical tail 2026-10-16 23:40:01 +08:00
overtrue f8d0174fa7 test(ecstore): register artifact repair coverage and document scope 2026-10-16 23:33:13 +08:00
overtrue a57cc0d44c fix(ecstore): carry bucket generation owners into raw PUT tails 2026-10-16 23:33:04 +08:00
overtrue 8006cb7eae test(ecstore): cover artifact repair admission and physical tails 2026-10-16 23:30:11 +08:00
overtrue 8d40a995a3 fix(ecstore): require authoritative targets for raw artifact PUTs 2026-10-16 23:30:11 +08:00
overtrue a451b53bcc feat(ecstore): retain repair owners through artifact mutation tails 2026-10-16 23:27:55 +08:00
overtrue a6127c8fde test(ecstore): require real lifecycle owners for managed writes 2026-10-16 10:12:26 +08:00
overtrue f346c13e5b fix(ecstore): validate lifecycle owners before PUT and Copy binding 2026-10-16 10:12:16 +08:00
overtrue 2634e829ca docs(ecstore): clarify lifecycle ownership for managed writes 2026-10-16 10:08:47 +08:00
overtrue dc15444256 fix(ecstore): require owned lifecycle fences for artifact admission 2026-10-16 10:08:20 +08:00
overtrue ebb19907a9 test(ecstore): remove unused artifact fixture import 2026-10-16 10:04:47 +08:00
overtrue d29babf251 docs(ecstore): define managed artifact write admission boundary 2026-10-16 10:04:01 +08:00
overtrue d8a9522919 test(ecstore): reject unsupported artifact write entrypoints 2026-10-16 10:03:35 +08:00
overtrue 60dc84bf0e feat(ecstore): reject unsupported artifact bucket write paths 2026-10-16 10:03:35 +08:00
overtrue 061e89ed2b test(ecstore): run seal corruption fixture on existing large stack 2026-10-16 10:00:35 +08:00
overtrue 3065142735 test(ecstore): isolate physical timeout runtime and topology fixtures 2026-10-16 09:55:43 +08:00
overtrue c6a8e30f5a fix(ecstore): route artifact validation through storage contracts 2026-10-16 09:55:04 +08:00
overtrue f51b52d7e9 test(ecstore): reject artifact PUT seals on distributed topology 2026-10-16 09:50:06 +08:00
overtrue c54160c22c fix(ecstore): restrict artifact PUT seals to local storage 2026-10-16 09:50:06 +08:00
overtrue 1f67b65ab8 docs(storage): bound artifact PUT sealing to local stores 2026-10-16 09:47:22 +08:00
overtrue a7806e65f0 fix(ecstore): adapt seal digest and multipart owner types 2026-10-16 09:43:45 +08:00
overtrue 6a3678997a test(ecstore): retain artifact seal lock through physical timeout 2026-10-16 09:43:37 +08:00
overtrue 64ea8b8e09 docs(storage): clarify failed PUT seal outcomes 2026-10-16 09:42:17 +08:00
overtrue 7773aac0fd fix(ecstore): bind seal bucket and preserve ordinary PUT allocation 2026-10-16 09:42:17 +08:00
overtrue 8763f87ca7 feat(ecstore): seal ordinary artifact PUT attempts durably 2026-10-16 09:40:51 +08:00
overtrue 9bbac59977 test(ecstore): assert attempt lock ownership through PUT tails 2026-10-16 09:36:06 +08:00
overtrue 3bd57517ff docs(storage): define artifact PUT sealing boundary 2026-10-16 09:35:15 +08:00
overtrue 8ee78c6891 test(ecstore): cover durable artifact PUT seal boundaries 2026-10-16 09:34:45 +08:00
overtrue 21c594e625 test(ecstore): reject artifact incarnation spoofing 2026-10-16 09:24:40 +08:00
overtrue 748095280a fix(ecstore): reject forged artifact bucket incarnations 2026-10-16 09:24:28 +08:00
overtrue 960195458f feat(ecstore): preserve artifact bucket creation identity 2026-10-16 09:20:17 +08:00
overtrue de58fdfc7d docs(storage): define dedicated artifact bucket invariants 2026-10-16 09:19:12 +08:00
overtrue 869d5ec24b test(ecstore): cover artifact bucket recovery and cache boundaries 2026-10-16 09:19:12 +08:00
overtrue 19902438a6 test(ecstore): cover immutable artifact bucket creation 2026-10-16 09:15:10 +08:00
overtrue bd244d13f5 fix(iam): ignore policy statement ordering during recheck 2026-10-15 18:08:51 +08:00
overtrue 38395d35ec fix(example): make plugin output writable 2026-10-15 10:56:32 +08:00
overtrue 68064b5ad9 feat(example): add Rust libvips image plugin template 2026-10-15 10:47:58 +08:00
overtrue c1da0c361b feat(admin): proxy plugin run state history 2026-10-15 08:58:15 +08:00
overtrue cb7626c1f1 feat(admin): replay plugin runs 2026-10-15 07:43:25 +08:00
overtrue 8186a7175c feat(admin): page plugin run history 2026-10-15 07:18:25 +08:00
overtrue c9e1446126 feat(admin): cancel plugin runs 2026-10-15 07:00:15 +08:00
overtrue 11d21ec6f4 feat(admin): query plugin run status 2026-10-15 06:43:32 +08:00
overtrue 974ab6f785 feat(admin): submit plugin jobs to runner 2026-10-15 05:48:19 +08:00
overtrue c3f4cdf27c refactor: remove native capture raft prototype 2026-10-14 07:02:17 +08:00
overtrue e82d7c107a fix(capture): check instance readiness for empty source pages 2026-10-14 06:15:20 +08:00
overtrue 9d2d6a8e46 fix(capture): gate source delivery on object publication 2026-10-14 05:15:33 +08:00
overtrue ff2cbc3c47 feat(capture-authority): mint exact committed Created authorization 2026-10-14 04:57:26 +08:00
overtrue d65ef3e29a fix(capture): respect startup storage facade boundaries 2026-10-14 04:56:57 +08:00
overtrue 0a2f58c7a9 feat(capture): wire native Raft authority into startup 2026-10-14 04:43:26 +08:00
overtrue 4d97501327 feat(ecstore): add authenticated native Raft TCP transport 2026-10-14 03:42:24 +08:00
overtrue 3b2af12c64 feat(capture-authority): add real native Raft owner lifecycle 2026-10-14 02:47:52 +08:00
overtrue c73ddde848 feat(capture-authority): add durable Raft state machine snapshots 2026-10-14 02:30:43 +08:00
overtrue ef72a37fd7 feat(capture): adapt Raft log storage and authorize prefix purge 2026-10-14 02:06:07 +08:00
overtrue e46cf750dd feat(capture): expose bounded local native source reader 2026-10-14 01:21:11 +08:00
overtrue fe955fa3ee feat(iam): issue plugin credentials from original-user delegations 2026-10-13 22:16:46 +08:00
overtrue 64578c067e feat(capture): persist local checkpoints and compact covered logs 2026-10-13 05:03:29 +08:00
overtrue 78715f0921 feat(ecstore): add durable native object preparation
Encode actual reader data into private local shards and recover matching write-quorum receipts. Preserve original metadata and namespace owners across retries and cancellation, without granting publication authority.

Validate with 13 focused tests and two rejected digest/quorum mutations. Document the preparation and recovery boundary.
2026-10-13 03:56:13 +08:00
overtrue 3284aafd73 feat(ecstore): persist private native preparation receipts 2026-10-13 01:02:05 +08:00
overtrue 9286442f87 feat(capture): add rebuildable committed event pagination 2026-10-12 08:31:29 +08:00
overtrue b8aad1c168 fix(capture): validate checkpoint cut against captured committed log 2026-10-11 23:48:08 +08:00
overtrue 5fcb229760 feat(ecstore): observe bounded config under retained write owner 2026-10-11 22:59:16 +08:00
overtrue 94baef7462 feat(capture): add explicit schema-bound checkpoint capabilities 2026-10-11 21:30:55 +08:00
overtrue 60d90f085a feat(ecstore): add bounded persisted config observations 2026-10-11 20:29:27 +08:00
overtrue dfa5d50565 feat(capture): persist first-decision provenance 2026-10-11 18:58:12 +08:00
overtrue a902886737 feat(capture): atomically migrate legacy Created history 2026-10-11 16:39:10 +08:00
overtrue 8e53b87421 test(capture): specify atomic populated native migration 2026-10-11 16:39:10 +08:00
overtrue f7631e91b1 feat(capture): stream an owned coherent snapshot view 2026-10-11 16:30:12 +08:00
overtrue f9bc4e921f test(capture): specify coherent snapshot view ownership 2026-10-11 16:30:12 +08:00
overtrue 781584307a feat(capture): apply committed native entries on the ordered worker 2026-10-11 15:35:02 +08:00
overtrue 59d641a5fe docs(capture): define recoverable checkpoint and purge contracts 2026-10-11 15:00:38 +08:00
overtrue c618b57870 feat(capture): stream snapshot rows with a permanent failure fence 2026-10-11 14:44:05 +08:00
overtrue bbe1d54098 feat(capture): add bounded streaming snapshot codec 2026-10-11 14:21:30 +08:00
overtrue 9e8ea4dcdb feat(capture-authority): serialize native async writes 2026-10-11 13:35:15 +08:00
overtrue 63cf8e2f2f feat(capture-authority): add native OpenRaft log reader 2026-10-11 13:01:43 +08:00
overtrue b7fe40f4b3 feat(capture): support complete native log range bounds 2026-10-11 12:28:23 +08:00
overtrue 5b872b8e56 feat(capture): apply native entries atomically with durable replay 2026-10-11 11:50:32 +08:00
overtrue ca5be1ef95 feat(capture): persist native Raft entries in explicitly enabled stores 2026-10-11 05:49:17 +08:00
overtrue 9373e8fa3e Bind capture commands and responses to native Raft entries 2026-10-11 03:45:17 +08:00
overtrue cb32965522 feat(capture): preserve exact raft values and membership codec 2026-10-11 02:04:47 +08:00
overtrue fb6cf49a67 feat(capture): validate peer metadata and CNOD codec 2026-10-11 01:25:21 +08:00
overtrue 8c97049e68 build(capture): pin OpenRaft and guard resolved features 2026-10-11 00:56:30 +08:00
overtrue 17d4fe27cc feat(capture): apply committed Created decisions atomically 2026-10-10 23:35:52 +08:00
overtrue fb0c7972b2 feat(capture): persist committed log boundary 2026-10-10 23:10:28 +08:00
overtrue eba7967755 feat(capture): persist atomic log suffix truncation 2026-10-10 22:59:26 +08:00
overtrue 451c1c31ad feat(capture): add durable local authority storage 2026-10-10 22:33:44 +08:00
overtrue 8780dd670e test(ecstore): verify publication across process kills 2026-10-10 20:19:46 +08:00
overtrue f29bf2c902 fix(iam): preserve empty identity read failures 2026-10-10 11:37:07 +08:00
overtrue fd03436090 feat(ecstore): expose unbounded preserve-empty config reads 2026-10-10 11:10:27 +08:00
overtrue 1f1e7b1c97 fix(iam): preserve identity decryption errors 2026-10-10 10:37:15 +08:00
overtrue 45a952609e test(iam): add real identity storage fixtures 2026-10-10 10:01:03 +08:00
overtrue 323190a95a test(test-utils): support caller-owned instance contexts 2026-10-10 09:43:01 +08:00
overtrue 38cdda9292 fix(ecstore): expose config reader without private bounds 2026-10-10 09:11:47 +08:00
overtrue 85c84fa73d feat(policy): add dormant principal incarnation records 2026-10-10 08:12:14 +08:00
overtrue 51402087c5 feat(ecstore): expose bounded config snapshot reader 2026-10-10 08:03:27 +08:00
overtrue a994984fe2 build(policy): prepare identity incarnation UUID dependency 2026-10-10 06:57:51 +08:00
overtrue 48eb0e402c test(iam): use identity constructor in existing fixtures 2026-10-10 06:52:04 +08:00
335 changed files with 67841 additions and 57479 deletions
@@ -6,17 +6,7 @@
- Route every new sync/flush through the durability-mode and bucket override
gates; mode `none` must not pay the new fsync.
- Keep blocking filesystem/CPU work off async runtime threads, but do not split
one small operation into many `spawn_blocking` round trips; coalesce `open`,
`read`/`write`, `fsync`, and `close` into one blocking closure per disk.
- Check for per-disk syscall amplification (`disks × parts/files`): pin a
directory once (`openat2` on Linux) and reuse the handle across sibling reads
instead of re-walking ancestor components per file or re-opening identical
`source_parent == destination_parent` directories.
- After `fstat`/`file.metadata()`, require `Vec::with_capacity(len + 1)` before
`read_to_end` so small metadata reads do not trigger 6–7 exponential-growth
`read()` syscalls.
- Avoid redundant `store.load()` or `root_missing()` round-trips under the same
exclusive lock when the state or original bytes were just loaded or persisted.
one small operation into many `spawn_blocking` round trips.
- Measure lock hold time across I/O and compare acquisition order for ABBA.
- Keep cleanup, extra stat/rename, and diagnostics out of the PUT commit critical
section when they need not be there.
@@ -25,7 +25,7 @@ Read only the current phase's procedure; retain completed evidence across turns.
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
| Confirmation | Complete the gate below and end the turn. |
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website; reconcile main and remove the completed release branch](references/post-release-updates.md) only after complete publication. |
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
## Human Confirmation
@@ -37,6 +37,6 @@ Only a new affirmative user reply to that exact report authorizes Phase 6. The o
- Before the final tag exists, land fixes on main, backport to the candidate, verify target versions, and restart branch CI/acceptance. Use a new preview iteration once a preview was tagged; main may advance independently.
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting unpublished candidate branches or preview tags requires an explicit decision. Completed release branches follow Phase 7 cleanup; keep release tags.
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting candidate branches or preview tags requires an explicit decision.
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
@@ -48,10 +48,3 @@ Use the existing announcement configuration from [website PR #112](https://githu
3. Use the site's current package manager and required checks. For this config-only change, type-check and lint the changed file, build the site, and inspect the generated homepage for the version, visible link text, and destination. Confirm `/download` actually offers the announced version before using that link.
4. Commit, push, and create or update the website PR when included in the authorized release scope. Include the published RustFS Release URL and checks run. Keep merge and production-deployment authority separate; reuse permission already given, but this reference grants none by itself.
5. After an authorized merge/deployment, verify the live `https://rustfs.com/` banner and its destination. If only the PR is ready, report that state and URL; do not report the banner as deployed. A website failure does not invalidate or move the published release tag: retry this phase after resolving the failure.
## Reconcile main and remove the completed release branch
1. Compare the exact published tag with current main. Audit every release-side commit since their merge base, including inherited backports. An ahead count is a history difference, not proof of missing changes. Use ancestry and patch equivalence, then inspect current code and regression evidence for squashed, adapted, superseded, or reverted changes. Record each commit as present, superseded, release-only, or missing; a positive `git cherry` result alone does not prove a missing fix.
2. Bring missing shared changes to main through a reviewed PR and the required checks before closing the follow-up. Preserve main's later changes; decide version bumps and pinned Console configuration separately. If a history merge is needed, use a merge commit and verify the release commit is an ancestor of main; squash does not establish that ancestry. Do not merge main into the published release branch or move its tags. Report unresolved changes instead of claiming reconciliation is complete.
3. After publication and authorized follow-ups are complete, delete the remote `release/<target>` branch. Immediately before deletion, verify its HEAD still equals the published tag's commit and no open PR targets or originates from it. Preserve a branch with newer work or open PRs and report the blocker. Use a deletion with an expected-old-SHA check to avoid removing a concurrently advanced branch.
4. Verify the remote branch is absent and the final and preview tags still resolve to their recorded commits. Remove a local release branch only if it is task-owned, unchecked out, and has no unpreserved work. Record cleanup separately from artifact publication; an explicitly authorized deletion before reconciliation does not make unresolved main changes complete.
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
sha256-darwin=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
sha256-linux=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
sha256-darwin=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=a67a347f26be186c598b5beaf68e6bcbe1855350a35e1423dd5b8a81bcba06a5
sha256-linux=ad4fabe811c6cca1aba89fad34ccd404b5832a6a955875c2826f6e0fa1ea4f1f
sha256-darwin=b53fe6d46cdffedfad6a83da0dfffa9aec10cd7c8ace53469f129587bc9f4d50
sha256-linux=6be90f5c3886e9852ac27d51f2d47e6d2fa8bb424e0d03ee931268dcd0020db8
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=3814cdde8cacf4187e18188d342ed70931cc32e268e42e5bb438d565bb4d0f81
sha256-linux=663025a80273579f95ae3f49af02c0afcbfce94df09701c360a13e607b7637db
sha256-darwin=347a0d4e5e547d5388703062ce66a66167a845b6e57ec591ccb4c732a567c68d
sha256-linux=4f9efa47194279185bb68249e82c788fa147abe944b6c764050e3d47f31e7926
+1 -1
View File
@@ -1 +1 @@
sha256=f065e7071aac9d48a7c1b301f328fa7cc14f11c4e7dc46da3e55057c4ea29bd5
sha256=002ec5d973f82288e50a096a2c8b3b38749f62a7a3e169775566874d8ee98b4a
+6 -1
View File
@@ -29,7 +29,7 @@
{
"invariant": "list-uncommitted-version",
"suite": "rustfs-filemeta",
"name": "metacache::tests::resolve_merge_keeps_the_newest_version_at_listing_quorum"
"name": "metacache::tests::resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge"
},
{
"invariant": "minio-object-fixture",
@@ -241,6 +241,11 @@
"suite": "rustfs-ecstore",
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_cancelled_quorum_walk_releases_senders_and_disk_lock"
},
{
"invariant": "list-objects-shared-corpus-enabled-index-contracts",
"suite": "rustfs-ecstore",
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_enabled_index_contracts"
},
{
"invariant": "borrowed-context-boundary",
"suite": "rustfs-ecstore",
-1
View File
@@ -52,7 +52,6 @@ script-tests: ## Run shell script tests
./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
-22
View File
@@ -68,17 +68,6 @@ command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
# boundaries. Reserve the run's capacity so unrelated fixtures cannot starve
# their existing deadlines and typed-failure assertions (rustfs/backlog#2706).
# Keep each test's internal concurrency and zero-retry policy unchanged.
[[profile.default.overrides]]
filter = """
package(rustfs-ecstore) & (
test(/^set_disk::ops::multipart::tests::complete_multipart_(quorum_success_releases_logical_guards_before_physical_tail|waits_for_tail_before_releasing_guards_and_marking_capacity)$/)
| test(=store::heal::tests::full_format_heal_lost_after_last_save_does_not_publish_or_renew_and_latches)
| test(/^store::init::tests::(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page|dispatch_manifest_shutdown_(drains_admitted_delete_batch_without_tail_admission|stops_aborting_validation_tail_reads|stops_authorized_tail_reads_before_receipt_or_cas))$/)
| (binary(metadata_read_lock_reuse_test) & test(=replica_multipart_completes_while_metadata_writer_waits_for_its_snapshot))
)
"""
threads-required = "num-test-threads"
[[profile.default.overrides]]
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
threads-required = "num-test-threads"
@@ -362,17 +351,6 @@ path = "junit.xml"
# Match the local durable-progress reservations. All members, deadlines,
# assertions, and internal concurrency remain enabled without retries.
[[profile.ci.overrides]]
filter = """
package(rustfs-ecstore) & (
test(/^set_disk::ops::multipart::tests::complete_multipart_(quorum_success_releases_logical_guards_before_physical_tail|waits_for_tail_before_releasing_guards_and_marking_capacity)$/)
| test(=store::heal::tests::full_format_heal_lost_after_last_save_does_not_publish_or_renew_and_latches)
| test(/^store::init::tests::(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page|dispatch_manifest_shutdown_(drains_admitted_delete_batch_without_tail_admission|stops_aborting_validation_tail_reads|stops_authorized_tail_reads_before_receipt_or_cas))$/)
| (binary(metadata_read_lock_reuse_test) & test(=replica_multipart_completes_while_metadata_writer_waits_for_its_snapshot))
)
"""
threads-required = "num-test-threads"
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
threads-required = "num-test-threads"
@@ -74,6 +74,7 @@ Notes:
- `.github/workflows/e2e-s3tests.yml` (weekly full s3-tests sweep)
- `.github/workflows/mint.yml` (weekly multi-SDK mint run)
- `.github/workflows/fuzz.yml` (nightly fuzz corpus jobs)
- `.github/workflows/performance-ab.yml` (nightly warp A/B gate)
- **Pending:** the ci-7 e2e nightly-full workflow does not exist yet
(backlog#1149 ci-7). When it lands, wire it up with the snippet above.
+1
View File
@@ -18,6 +18,7 @@
"never_ran_grace_until": "2026-09-08T00:00:00Z"
},
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
{
"workflow": ".github/workflows/runner-hygiene.yml",
"max_age_hours": 792,
+4 -4
View File
@@ -7,7 +7,7 @@
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
Every other label in this repository is capacity RustFS pays for. ARC pods and lab machines accrue cost while the machine exists. A Ubicloud label is metered for the minutes a job holds its VM:
Every other label in this repository is capacity RustFS pays for by the hour whether or not a job is running well:
| Label | Paid by | What it is | Use it for |
|---|---|---|---|
@@ -15,7 +15,7 @@ Every other label in this repository is capacity RustFS pays for. ARC pods and l
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
| `ubicloud-standard-16-arm` | RustFS | Ubicloud VM, alias of `ubicloud-standard-16-arm-ubuntu-2404`: Ubuntu 24.04 arm64, 16 vCPU, 48 GB, 300 GB disk. Reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds (`linux-aarch64-musl`, `linux-aarch64-gnu`) |
| `sm-standard-4-arm` | RustFS | ARC pod, aarch64; reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds |
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
@@ -40,7 +40,7 @@ A hosted runner is also the only choice for a few things the pods cannot do: `mo
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build. The `build.yml` aarch64 release legs append `-ubicloud-arm` for this reason. `cache-warm.yml` does not write those keys.
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build.
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
## Where self-hosted minutes leak
@@ -62,7 +62,7 @@ gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
```
Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
Only rows whose label starts with `sm-`, `dind-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
## Checklist for a new or moved job
+5
View File
@@ -29,6 +29,7 @@ on:
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
- 'scripts/security/check_workflow_pins.sh'
@@ -47,6 +48,7 @@ on:
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
- 'scripts/security/check_workflow_pins.sh'
@@ -153,6 +155,9 @@ jobs:
- name: Check preview release workflow policy
run: ./scripts/security/check_preview_release_workflow.sh
- name: Check performance A/B workflow trust boundary
run: ./scripts/security/check_performance_ab_workflow.sh
- name: Check tier evidence workflow isolation
run: ./scripts/security/check_tier_artifact_workflow.sh
@@ -1,70 +0,0 @@
name: BTF prototype reference
on:
pull_request:
paths:
- "scripts/inspect_btf_prototypes.py"
- "scripts/test_inspect_btf_prototypes.py"
- "scripts/fixtures/btf_prototypes.c"
- ".github/workflows/btf-prototype-reference.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: btf-prototype-reference-${{ github.ref }}
cancel-in-progress: true
jobs:
reference:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Check prototype and CLI contracts
run: python3 -B scripts/test_inspect_btf_prototypes.py
- name: Install only missing native reference tools
run: |
select_bpftool() {
local candidate
local candidates=()
if command -v bpftool >/dev/null; then
candidates+=("$(command -v bpftool)")
fi
for candidate in /usr/lib/linux-tools-*/bpftool /usr/lib/linux-tools/*/bpftool; do
if [[ -x "$candidate" ]]; then
candidates+=("$candidate")
fi
done
while IFS= read -r candidate; do
if [[ -x "$candidate" ]] && file -Lb "$candidate" | grep -q 'ELF' \
&& "$candidate" version >/dev/null 2>&1; then
printf '%s\n' "$candidate"
return 0
fi
done < <(printf '%s\n' "${candidates[@]}" | sort -Vr)
return 1
}
packages=()
command -v clang >/dev/null || packages+=(clang)
command -v llvm-objcopy >/dev/null || packages+=(llvm)
select_bpftool >/dev/null || packages+=(linux-tools-generic)
if (( ${#packages[@]} )); then
sudo apt-get update
sudo apt-get install --no-install-recommends -y "${packages[@]}"
fi
# File parsing needs a working binary, not a host-kernel wrapper.
selected_bpftool="$(select_bpftool)"
"$selected_bpftool" version
printf 'BPFTOOL=%s\n' "$selected_bpftool" >> "$GITHUB_ENV"
- name: Compare compiled declarations with bpftool
run: |
clang -target bpf -O2 -g -c scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/prototypes.o"
llvm-objcopy --dump-section ".BTF=$RUNNER_TEMP/prototypes.btf" "$RUNNER_TEMP/prototypes.o"
python3 -B scripts/test_inspect_btf_prototypes.py --native-reference "$RUNNER_TEMP/prototypes.btf"
+7 -17
View File
@@ -181,18 +181,12 @@ jobs:
selected="all"
fi
# linux-aarch64-* need a real aarch64 host (placement rule: hardware)
# so the packaged binary and console smoke execute the artifact.
# ubicloud-standard-16-arm is that host: Ubuntu 24.04 arm64, alias of
# ubicloud-standard-16-arm-ubuntu-2404 (16 vCPU, 48 GB). linux-x86_64-* stay on the
# ARC sm-standard-4 pool. GNU rows keep cross=true so cargo-zigbuild
# pins the glibc 2.31 floor on the same architecture; musl stays a
# native cargo build.
all='{"include":[
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-aarch64-musl","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"linux-aarch64-gnu","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
]}'
@@ -250,11 +244,7 @@ jobs:
with:
rust-version: stable
target: ${{ matrix.target }}
# rust-cache keys include OS and architecture but not the runner image.
# The Ubicloud aarch64 legs therefore use their own suffix so they
# cannot restore a cache written on a different arm64 image.
# x86_64, macOS, and Windows keys stay unsuffixed.
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}${{ matrix.os == 'ubicloud-standard-16-arm' && '-ubicloud-arm' || '' }}
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}
# main only. A cache saved on refs/tags/X is scoped to that tag: no
# other tag, no main run and no PR can restore it, so every release
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
@@ -774,9 +764,9 @@ jobs:
exit 0
fi
# ARC pods (the x86_64 legs) do not ship the aws CLI. Ubicloud arm64
# images follow the GitHub runner image and often do. Install the
# official CLI bundle when it is missing so R2 uploads work on either.
# The self-hosted Linux runners do not ship the aws CLI (GitHub-hosted
# images did). Install the official CLI bundle on demand so R2 uploads
# survive a fresh runner without relying on a distro package.
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI not found on runner; installing..."
if command -v apt-get >/dev/null 2>&1; then
+1 -1
View File
@@ -45,7 +45,7 @@ on:
# 07:00 UTC Sunday — staggered clear of the other Sunday crons: ci (00:00),
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
# and e2e-replication-nightly (04:00) lanes.
# e2e-replication-nightly (04:00) and performance-ab (06:00) lanes.
- cron: "43 7 * * 0"
concurrency:
@@ -38,8 +38,8 @@ name: e2e-nightly
on:
workflow_dispatch:
schedule:
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00)
# and stale (01:30).
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
# stale (01:30) and performance-ab (06:00).
- cron: "29 4 * * *"
# Only alert-on-failure needs more than read access; it declares its own
-25
View File
@@ -19,11 +19,9 @@ on:
paths:
- ".github/workflows/e2e-upgrade.yml"
- "crates/e2e_test/src/common.rs"
- "crates/e2e_test/src/distributed/**"
- "crates/e2e_test/src/fake_s3_target/**"
- "crates/e2e_test/src/lib.rs"
- "crates/e2e_test/src/replication_extension_test.rs"
- "crates/e2e_test/src/rpc_fault_proxy.rs"
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
- "crates/ecstore/**"
- "crates/filemeta/**"
@@ -130,7 +128,6 @@ jobs:
run: chmod +x target/debug/rustfs
- name: Verify and unpack pinned previous release
id: verify_source
env:
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
run: |
@@ -188,28 +185,6 @@ jobs:
done
exit "$status"
- name: Run mixed-version MPU recovery
if: ${{ !cancelled() && steps.verify_source.outcome == 'success' }}
timeout-minutes: 15
env:
NO_PROXY: 127.0.0.1,localhost,::1
HTTP_PROXY: ""
HTTPS_PROXY: ""
ALL_PROXY: ""
RUSTFS_E2E_LOG_FILTER: "warn,rustfs_ecstore::cluster::rpc::client=debug"
run: |
set -euo pipefail
export no_proxy="$NO_PROXY"
export RUSTFS_E2E_LOG_DIR="$RUNNER_TEMP/rustfs-upgrade-logs/mpu-mixed-recovery"
mkdir -p "$RUSTFS_E2E_LOG_DIR"
mpu_test='distributed::multipart_fence_acceptance_test::mixed_version_failed_complete_requires_current_coordinator_for_abort'
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
"$mpu_test" -- --list --exact | tee "$RUSTFS_E2E_LOG_DIR/list.log"
test "$(grep -Fxc "${mpu_test}: test" "$RUSTFS_E2E_LOG_DIR/list.log")" -eq 1
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
"$mpu_test" -- --exact --nocapture --test-threads=1 \
2>&1 | tee "$RUSTFS_E2E_LOG_DIR/test.log"
- name: Upload scanner/heal G09 evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+384
View File
@@ -0,0 +1,384 @@
# 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.
# Warp A/B budget gate for the hotpath series (rustfs/backlog#935 HP-14, item 4).
#
# Two entry points, honestly scoped:
# * schedule (nightly, on main): post-merge detection — catches a regression
# within 24h of landing, not before merge.
# * workflow_dispatch: an explicitly selected trusted ref.
# The dispatch input can run the gate with --allow-regression so a deliberate
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
# blocked (rustfs/backlog#935 correction 1).
name: Performance A/B
on:
schedule:
- cron: "31 6 * * *" # 06:31 UTC nightly, against main
workflow_dispatch:
inputs:
duration:
description: "warp duration per round"
required: false
default: "12s"
type: string
allow_regression:
description: "Pass the gate despite a FAIL (deliberate tradeoff)"
required: false
default: false
type: boolean
permissions:
actions: read
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
warp-ab:
if: github.repository == 'rustfs/rustfs'
name: Warp A/B budget gate
runs-on: sm-standard-2
# A normal nightly restores the last successful binary and builds only the
# candidate; daily access keeps that cache warm. A cache miss may build both
# and needs room for the A/B run plus artifact and cache publication.
timeout-minutes: 180
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
fetch-depth: 0 # baseline may be an earlier successful scheduled head
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install warp
run: |
set -euo pipefail
WARP_VERSION="1.0.0"
curl -fsSL "https://github.com/minio/warp/releases/download/v${WARP_VERSION}/warp_Linux_x86_64.tar.gz" \
| sudo tar -xz -C /usr/local/bin warp
warp --version
- name: Decide exemption
id: exempt
env:
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
run: |
allow="false"
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
allow="true"
fi
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
# A failed regression run must keep comparing against the last known-good
# scheduled head. Otherwise the next nightly would absorb the regression
# into its baseline and turn green without a fix.
- name: Find last successful scheduled baseline
id: scheduled_baseline
if: github.event_name == 'schedule'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
result-encoding: string
script: |
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "performance-ab.yml",
event: "schedule",
status: "success",
per_page: 1,
});
return data.workflow_runs[0]?.head_sha ?? "";
# Manual runs compare a selected ref with current main. Scheduled runs
# compare current main with the last successful scheduled head. With no
# history, the first run measures the candidate against itself and seeds
# that head only if the complete rig succeeds.
- name: Resolve baseline / candidate commits
id: commits
env:
SCHEDULED_BASELINE_SHA: ${{ steps.scheduled_baseline.outputs.result }}
run: |
set -euo pipefail
candidate_sha="$(git rev-parse HEAD)"
if [[ "${{ github.event_name }}" == "schedule" ]]; then
baseline_sha="${SCHEDULED_BASELINE_SHA:-$candidate_sha}"
else
baseline_sha="$(git rev-parse origin/main)"
fi
git cat-file -e "${baseline_sha}^{commit}"
if ! git merge-base --is-ancestor "$baseline_sha" "$candidate_sha"; then
echo "::error::baseline $baseline_sha is not an ancestor of candidate $candidate_sha; update the selected ref before comparing" >&2
exit 1
fi
echo "baseline_sha=$baseline_sha" >> "$GITHUB_OUTPUT"
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
echo "baseline commit: $baseline_sha"
echo "candidate commit: $candidate_sha"
# Exact-key restore of the candidate binary saved by its successful
# scheduled run. A miss leaves cache-hit unset and falls back to a source
# build of that known-good head.
- name: Restore cached baseline binary
id: baseline_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
# Self-heal: on a nightly/dispatch run where the candidate commit IS the
# baseline commit, a cache miss would make the rig build the same commit
# twice (~65min per side with the post-#4806 LTO profile — no job budget
# fits that). Build it once here, reuse it for both phases, and save it
# back to the cache so the next run hits.
- name: Build baseline on cache miss (same-commit self-heal)
id: selfheal
if: >-
steps.baseline_cache.outputs.cache-hit != 'true' &&
steps.commits.outputs.baseline_sha == steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cargo build --release --bin rustfs
mkdir -p baseline-bin
cp target/release/rustfs baseline-bin/rustfs
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Build baseline on cache miss (different candidate)
id: baseline_build
if: >-
steps.baseline_cache.outputs.cache-hit != 'true' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
baseline_root="$RUNNER_TEMP/rustfs-baseline-${{ github.run_id }}"
baseline_target="$RUNNER_TEMP/rustfs-baseline-target-${{ github.run_id }}"
git worktree add --detach "$baseline_root" "${{ steps.commits.outputs.baseline_sha }}"
cargo build --release --manifest-path "$baseline_root/Cargo.toml" --bin rustfs --target-dir "$baseline_target"
mkdir -p baseline-bin
cp "$baseline_target/release/rustfs" baseline-bin/rustfs
git worktree remove --force "$baseline_root"
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Build candidate binary
id: candidate_build
if: steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cargo build --release --bin rustfs
mkdir -p candidate-bin
cp target/release/rustfs candidate-bin/rustfs
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Save self-healed baseline to cache
if: steps.selfheal.outputs.built == 'true' || steps.baseline_build.outputs.built == 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
- name: Run warp A/B and gate
id: ab
env:
INPUT_DURATION: ${{ github.event.inputs.duration }}
run: |
set -euo pipefail
# The formal runner executes A1 baseline -> B1 candidate -> B2 candidate
# -> A2 baseline for each workload and drive-sync cell. It requires three
# rounds per leg to emit tail latency and error-rate evidence.
# --health-timeout 180 outlasts the server's own 120s startup-readiness
# budget, which the rig's previous 60s health poll undershot (the first
# two nightly failures). perf-6 recalibrates these once the noise study
# lands.
duration="${INPUT_DURATION:-12s}"
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
selfheal_built="${{ steps.selfheal.outputs.built }}"
baseline_built="${{ steps.baseline_build.outputs.built }}"
candidate_built="${{ steps.candidate_build.outputs.built }}"
args=(--duration "$duration" --rounds 3 --cooldown 5 --health-timeout 180 --baseline-revision "$baseline_sha" --candidate-revision "$candidate_sha")
if [[ "$baseline_hit" == "true" || "$selfheal_built" == "true" || "$baseline_built" == "true" ]]; then
chmod +x baseline-bin/rustfs
base_bin="$PWD/baseline-bin/rustfs"
args+=(--baseline-bin "$base_bin")
if [[ "$baseline_hit" == "true" ]]; then
base_src="actions-cache (rustfs-baseline-$baseline_sha)"
elif [[ "$selfheal_built" == "true" ]]; then
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
else
base_src="isolated baseline source build (saved as rustfs-baseline-$baseline_sha)"
fi
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
# No commits landed since the last successful baseline, so reuse
# the one binary for both phases and measure only rig drift.
args+=(--candidate-bin "$base_bin")
cand_src="same binary as baseline (same commit)"
elif [[ "$candidate_built" == "true" ]]; then
chmod +x candidate-bin/rustfs
args+=(--candidate-bin "$PWD/candidate-bin/rustfs")
cand_src="source build of the checked-out ref"
else
echo "::error::candidate binary was not built" >&2
exit 2
fi
else
echo "::error::baseline binary was not restored or built" >&2
exit 2
fi
echo "baseline binary: $base_src"
echo "candidate binary: $cand_src"
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
args+=(--allow-regression --exemption-reason "workflow dispatch override")
fi
# Do not let a gate FAIL abort the job here; capture status and surface
# it after the step summary is written.
set +e
bash scripts/run_hotpath_warp_abba.sh "${args[@]}"
echo "status=$?" >> "$GITHUB_OUTPUT"
set -e
# Locate the newest run dir + gate.md for the summary/comment/artifact
# steps. On a startup failure there is no gate.md, but the run dir still
# holds server-logs/ for diagnosis.
# Run dirs are UTC-timestamp names (no special chars); ls is safe here.
# shellcheck disable=SC2012
run_dir="$(ls -td target/hotpath-abba/*/ 2>/dev/null | head -n1 || true)"
echo "run_dir=${run_dir%/}" >> "$GITHUB_OUTPUT"
# shellcheck disable=SC2012
gate_md="$(ls -t target/hotpath-abba/*/candidate_gate.md 2>/dev/null | head -n1 || true)"
echo "gate_md=$gate_md" >> "$GITHUB_OUTPUT"
- name: Upload A/B results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: hotpath-warp-ab-${{ github.run_number }}
# Includes per-cell median_summary.csv / baseline_compare.csv, both gates,
# and server-logs/ (rustfs.log + startup env per phase) so a failed run
# is diagnosable. Short retention: this is churny nightly debug data.
path: target/hotpath-abba/
if-no-files-found: warn
retention-days: 14
- name: Write gate summary
if: always()
run: |
set -euo pipefail
status="${{ steps.ab.outputs.status }}"
gate_md="${{ steps.ab.outputs.gate_md }}"
run_dir="${{ steps.ab.outputs.run_dir }}"
{
echo "## Hotpath warp A/B — run ${{ github.run_number }}"
echo
if [[ "$status" == "0" ]]; then
echo "Rig/gate exit: \`0\` (pass or warn)."
else
echo "Rig/gate exit: \`${status:-unknown}\` — **FAILED**."
fi
echo
if [[ -n "$gate_md" && -f "$gate_md" ]]; then
cat "$gate_md"
else
echo "No \`gate.md\` produced — the rig failed **before** the gate"
echo "(most likely server startup / health). Failing phase(s) below;"
echo "full logs in the \`hotpath-warp-ab-${{ github.run_number }}\` artifact."
if [[ -n "$run_dir" && -d "$run_dir/server-logs" ]]; then
for f in "$run_dir"/server-logs/*.log; do
[[ -f "$f" ]] || continue
echo
echo "<details><summary>$(basename "$f")</summary>"
echo
echo '```'
tail -n 30 "$f"
echo '```'
echo "</details>"
done
fi
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Stage successful candidate baseline
if: >-
steps.ab.outputs.status == '0' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cp candidate-bin/rustfs baseline-bin/rustfs
- name: Cache successful candidate baseline
if: >-
steps.ab.outputs.status == '0' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.candidate_sha }}
# Scheduled failure alerting is handled by the alert-on-failure job below
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
- name: Enforce gate
if: always()
run: |
status="${{ steps.ab.outputs.status }}"
if [[ -z "$status" ]]; then
echo "::error::warp A/B setup failed before the rig ran. Check the first failed workflow step." >&2
exit 1
fi
if [[ "$status" != "0" ]]; then
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / gate.md artifact." >&2
exit "$status"
fi
echo "warp A/B budget gate passed."
alert-on-failure:
name: Alert on scheduled failure
needs: [warp-ab]
# `always()` is required: without it this job is skipped when a needed
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
# ci-8); manual dispatch failures are already watched by a human.
# `cancelled` is included alongside `failure` on purpose: a job that hits
# timeout-minutes ends as `cancelled`, and the 2026-07-11..07-14 nightly
# timeouts went silent precisely because the guard was failure-only. The
# composite action already reports cancelled/timed-out jobs in the issue
# body.
if: >-
github.repository == 'rustfs/rustfs' && (
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
)
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}
@@ -16,8 +16,8 @@
#
# Forces a job failure and then runs .github/actions/schedule-failure-issue
# through the exact `needs` + `always() && contains(needs.*.result,
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz).
# Dispatch it twice to verify both the issue-creation and
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz,
# performance-ab). Dispatch it twice to verify both the issue-creation and
# the dedupe-comment paths, then close the resulting
# "[scheduled-failure] Schedule Failure Alert Drill" issue in rustfs/backlog
# (the default target repository). Requires the BACKLOG_ISSUE_TOKEN secret
@@ -28,6 +28,7 @@ on:
- "mint"
- "minio-interop"
- "Nightly GNU Build"
- "Performance A/B"
- "Runner Hygiene"
types: [completed]
+15 -4
View File
@@ -25,6 +25,8 @@ on:
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/storage-api/src/bucket.rs"
- "crates/storage-api/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
@@ -48,6 +50,8 @@ on:
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/storage-api/src/bucket.rs"
- "crates/storage-api/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
@@ -103,10 +107,6 @@ jobs:
shell: pwsh
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
- name: Test multipart parent durability barriers
shell: pwsh
run: cargo test -p rustfs-ecstore --lib multipart_owned_record_parent_barriers_ -- --nocapture
- name: Test Windows path conversion
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
@@ -144,6 +144,17 @@ jobs:
shell: pwsh
run: "cargo test -p rustfs-ecstore --lib bucket::utils:: -- --nocapture"
- name: Test artifact prefix Windows file-name rules
shell: pwsh
run: |
$testName = 'bucket::tests::artifact_prefix_preserves_host_file_name_rules'
cargo test --locked -p rustfs-storage-api --lib $testName -- --exact --nocapture --color never 2>&1 |
Tee-Object -Variable testOutput
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (($testOutput -join "`n") -notmatch 'test result: ok\. 1 passed; 0 failed; 0 ignored;') {
throw 'Expected exactly one passing artifact prefix test on Windows'
}
- name: Test Windows file-name rules
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
Generated
+88 -169
View File
@@ -365,9 +365,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "arrow"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f04e0a89cc175eaf3740282537851e6fd9d7276f97730b9d0ea2859c11a85df"
checksum = "4c25317ebddb8f2e6f006759336c95e5ded847a446f3524c27853bfe7d77a69b"
dependencies = [
"arrow-arith",
"arrow-array",
@@ -386,9 +386,9 @@ dependencies = [
[[package]]
name = "arrow-arith"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157943c720919e8f5b6731407ce5cd4c9a5ef47a75bf10edaf24b7081b5763ef"
checksum = "2ab555c0ffe9cb817041c0cee80baa9663a22225d0a035693579771f2693cadc"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -400,9 +400,9 @@ dependencies = [
[[package]]
name = "arrow-array"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b5b1682924f11ecf6929689c16cbc69efbb6dd141b06ad98ee1cb10d858c43"
checksum = "2dae1d91737421e92c6dd7c7f9d0f2d25198fd6fc539738b628f4dad10adf533"
dependencies = [
"ahash",
"arrow-buffer",
@@ -419,9 +419,9 @@ dependencies = [
[[package]]
name = "arrow-buffer"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5d687afc6c927965a8a759f3b68de91933d9dfa2e1bb6e156bac84b85665e92"
checksum = "1fe9cd0064f2d7cf8ecfaf77cd70e67a980bc71aea92b63102bd37a7d3379a09"
dependencies = [
"bytes",
"half",
@@ -431,9 +431,9 @@ dependencies = [
[[package]]
name = "arrow-cast"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f23c3379feede42135ef27ca60e67f6e643cd4343ae74e4c8b16c83f55afd85"
checksum = "ee510bc252781d700400979a96bd1eadebeae23901132bbabd92118da1b376c5"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -453,9 +453,9 @@ dependencies = [
[[package]]
name = "arrow-csv"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85b1984628b091b8319b3c305fce21d04f9e9b2578bb733b0d0108462ae1d698"
checksum = "ace83d5935421b950fea3a0a3f9972c0837c297ef98ade57127a5661b681112c"
dependencies = [
"arrow-array",
"arrow-cast",
@@ -468,9 +468,9 @@ dependencies = [
[[package]]
name = "arrow-data"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0a031c7ff5e2bdb443117054fb466af1d4df5b7f0ecbe197713dc8b113b1ba"
checksum = "54f128c73bb7fe9f7137218f910edd5b436f390e0f040b1956f5028390787897"
dependencies = [
"arrow-buffer",
"arrow-schema",
@@ -481,9 +481,9 @@ dependencies = [
[[package]]
name = "arrow-ipc"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5dd1afadda2eb821aaf851bb4c0adef60c30cc0f1a29bd02fb99dd07fb9f58d"
checksum = "8e244f39b82fa7a823e8b5984a0bd988234f9c92c1df79378fb85090e6e4c821"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -497,9 +497,9 @@ dependencies = [
[[package]]
name = "arrow-json"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2bc4d56a0dad84cc51c5812ccff48b642047a1f3be80349cd84318983e79edb"
checksum = "727b21ebf8894dcd9504d6ab66bffdac338391e1f85cde594526a0210299ea40"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -522,9 +522,9 @@ dependencies = [
[[package]]
name = "arrow-ord"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "561b8209faa2cd489764b7fd07d7cb9e8d274d78048c38de59f77c41140cb834"
checksum = "70d376b9b883239ae0f9963cdeeb59126ccaac08d60adf208a6ad3e2dd4048c3"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -535,9 +535,9 @@ dependencies = [
[[package]]
name = "arrow-row"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c68b87a569f92b90c46cb0a09d86bd9609ddc9b1bf1fc5a503c8c6907c14e"
checksum = "48096811f92b5c3a0c59d33cfecd7d7d2ed037d81e54a27a42d232e7700e6de2"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -548,9 +548,9 @@ dependencies = [
[[package]]
name = "arrow-schema"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e87acb0c6bf6237fca7a50d7b51e801c98a18a5fe6f3bb34f7d77b85ca165388"
checksum = "37b6c8a6b12cf3869d299ce36ed8e1cd6fe9836264b68fb6c6538473a36124a1"
dependencies = [
"serde_core",
"serde_json",
@@ -558,9 +558,9 @@ dependencies = [
[[package]]
name = "arrow-select"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c844141958d805b47d1208e0d12aac53fcf388ce8011639fe82aa492adde0bb4"
checksum = "e523e85fa9fb3181a391bf0edfa6a1ae0dc46df8cbf7f6b96034dcb1e2a7a04a"
dependencies = [
"ahash",
"arrow-array",
@@ -572,9 +572,9 @@ dependencies = [
[[package]]
name = "arrow-string"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc130892b1a9552da77971924b390e06cf375d1f7e4344a7b7b40ca4426fa470"
checksum = "b931a905a0e4b8832d9f54fb369cf6e545f9dc03cb3d9611216a7bd2653df60a"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -1514,23 +1514,6 @@ dependencies = [
"serde",
]
[[package]]
name = "bindgen"
version = "0.73.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa"
dependencies = [
"bitflags 2.13.2",
"cexpr",
"clang-sys",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn 3.0.9",
]
[[package]]
name = "binrw"
version = "0.15.2"
@@ -1743,13 +1726,13 @@ dependencies = [
[[package]]
name = "brotli"
version = "9.1.0"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6737ce8b81460bf9407909f73ff50bf13f7cb8577e1e76715f57ed0eba58ba44"
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
dependencies = [
"alloc-no-stdlib 3.0.0",
"alloc-stdlib 0.3.0",
"brotli-decompressor 6.2.0",
"brotli-decompressor 6.0.1",
]
[[package]]
@@ -1764,9 +1747,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "6.2.0"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf"
checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
dependencies = [
"alloc-no-stdlib 3.0.0",
"alloc-stdlib 0.3.0",
@@ -1984,15 +1967,6 @@ dependencies = [
"shlex",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -2109,17 +2083,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "clang-sys"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clap"
version = "4.6.7"
@@ -2236,7 +2199,7 @@ version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
dependencies = [
"brotli 9.1.0",
"brotli 9.0.0",
"bzip2",
"compression-core",
"flate2",
@@ -2244,7 +2207,7 @@ dependencies = [
"lz4",
"memchr",
"zstd 0.14.3",
"zstd-safe",
"zstd-safe 8.2.0",
]
[[package]]
@@ -2579,9 +2542,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.7.10"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11"
checksum = "cc907c45bb7e9ef8fc3778defd0f3cd8c81288f1b4e748eba064948f20c47122"
dependencies = [
"cpubits",
"ctutils",
@@ -2657,9 +2620,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.6"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a"
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
dependencies = [
"cmov",
"subtle",
@@ -3669,9 +3632,9 @@ dependencies = [
[[package]]
name = "der"
version = "0.8.3"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004"
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
dependencies = [
"const-oid 0.10.2",
"pem-rfc7468 1.0.0",
@@ -4077,7 +4040,6 @@ dependencies = [
"rustfs-utils",
"rustls",
"s3s",
"same-file",
"serde",
"serde_json",
"serde_urlencoded",
@@ -4104,7 +4066,7 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
dependencies = [
"der 0.8.3",
"der 0.8.2",
"digest 0.11.3",
"elliptic-curve",
"rfc6979",
@@ -4169,9 +4131,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "elliptic-curve"
version = "0.14.3"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c"
checksum = "0ad4496221fcfc21015fd8c0e26fcfab00504832dc0604f52acdd7bad2a8dc81"
dependencies = [
"base16ct",
"crypto-bigint",
@@ -5273,9 +5235,9 @@ dependencies = [
[[package]]
name = "hotpath"
version = "0.29.2"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b4af2b5ecf2650278fe33b0e49b68c8c484d2306475c127628316dea740cf5c"
checksum = "62f2351902fa259b758c7303505273393f6b08172ab5bdd5739a2d2bc6d554e8"
dependencies = [
"arc-swap",
"async-channel",
@@ -5308,15 +5270,15 @@ dependencies = [
[[package]]
name = "hotpath-drain"
version = "0.29.2"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5cceb6c15fbaaeb00b51acc7be7fad533d0b56cb422b1cfc16cf9ca1624d33a"
checksum = "83dab2340134d9976d62f9f5172f9c6584c7f521b092f258e0e58e412e160b53"
[[package]]
name = "hotpath-macros"
version = "0.29.2"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c0a42eb299ebb7ec6b9161489e5bf132603a34ca12cecadb8490937aa0492a"
checksum = "a04bbe5701b097f224a08187a78b75bb9d9b4bbaff6a279c2a50ba73edbb7aac"
dependencies = [
"proc-macro2",
"quote",
@@ -6436,7 +6398,7 @@ dependencies = [
"hashbrown 0.16.1",
"indexmap 2.14.2",
"metrics",
"ordered-float 5.5.1",
"ordered-float 5.5.0",
"quanta",
"radix_trie",
"rand 0.9.5",
@@ -7370,9 +7332,9 @@ dependencies = [
[[package]]
name = "ordered-float"
version = "5.5.1"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab"
checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
dependencies = [
"num-traits",
]
@@ -7489,9 +7451,9 @@ dependencies = [
[[package]]
name = "pageant"
version = "0.2.5"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0bc922d9aa8cfcf598de27b3c6122cb2eeda9d6fe71527a22ebcb7f633540ff"
checksum = "f0786409b5aaa884655c9b4065681db0835db0cc185a6fb19fb17a5d38656866"
dependencies = [
"base16ct",
"byteorder",
@@ -7560,9 +7522,9 @@ dependencies = [
[[package]]
name = "parquet"
version = "59.3.2"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eb260d566f2fbdabe356d26aaa5d13403aac29b5b4e44463b8f9bd7b2b12680"
checksum = "8b3d2ef0fb136e9341570f219ecb168902e76079c6d81ad555f0a4840b1e08a3"
dependencies = [
"ahash",
"arrow-array",
@@ -7847,7 +7809,7 @@ version = "0.8.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
dependencies = [
"der 0.8.3",
"der 0.8.2",
"spki 0.8.1",
]
@@ -7890,7 +7852,7 @@ dependencies = [
"aes 0.9.3",
"aes-gcm",
"cbc 0.2.1",
"der 0.8.3",
"der 0.8.2",
"pbkdf2 0.13.0",
"rand_core 0.10.1",
"scrypt 0.12.0",
@@ -7914,7 +7876,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [
"der 0.8.3",
"der 0.8.2",
"pkcs5 0.8.1",
"rand_core 0.10.1",
"spki 0.8.1",
@@ -9176,9 +9138,9 @@ dependencies = [
[[package]]
name = "russh"
version = "0.65.0"
version = "0.64.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bb77a4b259312b2abbc541bca1c13cb4e9b61c560a7fd894068c3b92e0c9dc3"
checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58"
dependencies = [
"aes 0.9.3",
"aws-lc-rs",
@@ -9193,7 +9155,7 @@ dependencies = [
"curve25519-dalek 5.0.0",
"data-encoding",
"delegate",
"der 0.8.3",
"der 0.8.2",
"digest 0.11.3",
"ecdsa",
"ed25519-dalek 3.0.0",
@@ -10336,6 +10298,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@@ -10725,6 +10688,7 @@ dependencies = [
"hotpath",
"insta",
"rustfs-filemeta",
"rustfs-utils",
"serde",
"serde_json",
"time",
@@ -10884,7 +10848,7 @@ version = "1.0.2"
dependencies = [
"base64-simd",
"blake2",
"brotli 9.1.0",
"brotli 9.0.0",
"bytes",
"convert_case 0.12.0",
"crc-fast",
@@ -11345,7 +11309,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
dependencies = [
"base16ct",
"ctutils",
"der 0.8.3",
"der 0.8.2",
"hybrid-array",
"subtle",
"zeroize",
@@ -11511,15 +11475,6 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -11799,9 +11754,9 @@ checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "sketches-ddsketch"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45f82fa313d625dd72aaa65ec0c9f8c95407add0510c1bef3c18ea7257ec34ce"
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
[[package]]
name = "slab"
@@ -11930,7 +11885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
dependencies = [
"base64ct",
"der 0.8.3",
"der 0.8.2",
]
[[package]]
@@ -12679,30 +12634,6 @@ dependencies = [
"webpki-roots 0.26.11",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.14.2",
"serde_core",
"serde_spanned",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
@@ -12719,9 +12650,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
dependencies = [
"indexmap 2.14.2",
"toml_datetime 1.1.2+spec-1.1.0",
"toml_datetime",
"toml_parser",
"winnow 1.0.4",
"winnow",
]
[[package]]
@@ -12730,15 +12661,9 @@ version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow 1.0.4",
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "tonic"
version = "0.14.6"
@@ -13765,12 +13690,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.4"
@@ -14037,9 +13956,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.2"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec2e3161de27091f18a831d9494e9c53378a34833af5c1dda35ad85849da18d6"
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
dependencies = [
"aes 0.9.3",
"bzip2",
@@ -14091,18 +14010,10 @@ dependencies = [
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"apache-avro",
"arrow-array",
"arrow-ipc",
"arrow-schema",
"bytes",
"kafka-protocol",
"parquet",
"semver",
"toml",
"zstd 0.14.3",
"zstd-safe",
"zstd-safe 7.3.0",
]
[[package]]
@@ -14111,7 +14022,16 @@ version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
dependencies = [
"zstd-safe",
"zstd-safe 8.2.0",
]
[[package]]
name = "zstd-safe"
version = "7.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
dependencies = [
"zstd-sys",
]
[[package]]
@@ -14129,7 +14049,6 @@ version = "2.1.2+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
dependencies = [
"bindgen",
"cc",
"pkg-config",
]
+4 -7
View File
@@ -66,7 +66,6 @@ members = [
"crates/io-metrics", # I/O metrics collection for performance analysis
"crates/io-core", # Buffered Bytes, mmap-then-copy, and aligned pread I/O helpers
"crates/zip", # ZIP file handling and compression
"crates/zstd-compat", # Fixed codec for dependencies requiring zstd 0.13
]
resolver = "3"
@@ -261,7 +260,7 @@ aws-smithy-http-client = { default-features = false, version = "1.5.1" }
aws-smithy-runtime-api = { version = "1.19.1" }
aws-smithy-types = { version = "1.9.0" }
base64-simd = "0.8.0"
brotli = "9.1.0"
brotli = "9.0.0"
clap = { version = "4.6.7" }
const-str = { version = "1.1.0" }
convert_case = "0.12.0"
@@ -303,7 +302,6 @@ num_cpus = { version = "1.17.0" }
nvml-wrapper = "0.13.0"
object = { version = "=0.40.0", default-features = false }
parking_lot = "0.13.0"
same-file = "1.0.6"
path-absolutize = "4.0.1"
percent-encoding = "2.3.2"
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
@@ -356,7 +354,7 @@ winapi-util = "0.1.11"
windows = { version = "0.62.2" }
windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.19" }
zip = "9.1.2"
zip = "9.1.0"
zstd = "0.14.3"
# Observability and Metrics
@@ -377,7 +375,7 @@ libunftp = { version = "0.23.1" }
unftp-core = "0.1.0"
suppaftp = { version = "12.2.1" }
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.65.0" }
russh = { version = "0.64.1" }
russh-sftp = "3.0.1"
# WebDAV
@@ -386,7 +384,7 @@ dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.7.2" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "0.29.2", default-features = false }
hotpath = { version = "0.29.1", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.49" }
@@ -426,7 +424,6 @@ strip = "none"
# Pin upstream fixes until compatible crates.io releases are available.
[patch.crates-io]
zstd = { path = "crates/zstd-compat" }
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
+3 -3
View File
@@ -141,7 +141,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
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
@@ -220,7 +220,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
./docker-buildx.sh --push
# Build specific version
./docker-buildx.sh --release 1.0.4 --push
./docker-buildx.sh --release 1.0.3 --push
# Build for custom registry
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -238,7 +238,7 @@ You can also use Make targets for convenience:
```bash
make docker-buildx # Build locally
make docker-buildx-push # Build and push
make docker-buildx-version VERSION=1.0.4 # Build specific version
make docker-buildx-version VERSION=1.0.3 # Build specific version
make help-docker # Show all Docker-related commands
```
+3 -3
View File
@@ -138,7 +138,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:1.0.4
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
```
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
./docker-buildx.sh --push
# 构建指定版本
./docker-buildx.sh --release 1.0.4 --push
./docker-buildx.sh --release 1.0.3 --push
# 构建并推送到自定义仓库
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
```bash
make docker-buildx # 本地构建
make docker-buildx-push # 构建并推送
make docker-buildx-version VERSION=1.0.4 # 构建指定版本
make docker-buildx-version VERSION=1.0.3 # 构建指定版本
make help-docker # 显示所有 Docker 相关命令
```
-4
View File
@@ -197,10 +197,6 @@ pub const ENV_HEAL_MRF_REPLAY_BATCH: &str = "RUSTFS_HEAL_MRF_REPLAY_BATCH";
/// Default behavior keeps the MRF intent pipeline enabled.
pub const DEFAULT_HEAL_MRF_ENABLE: bool = true;
/// Opt in to lifecycle V2 transitions after qualifying every reader and the rollback binary.
pub const ENV_MRF_LIFECYCLE_V2_ENABLE: &str = "RUSTFS_MRF_LIFECYCLE_V2_ENABLE";
pub const DEFAULT_MRF_LIFECYCLE_V2_ENABLE: bool = false;
/// Default MRF queue capacity (matches MinIO's 100k MRF list ceiling).
pub const DEFAULT_HEAL_MRF_QUEUE_SIZE: usize = 100_000;
-1
View File
@@ -146,7 +146,6 @@ zip.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
[dev-dependencies]
same-file.workspace = true
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
rustfs-heal.workspace = true
tempfile.workspace = true
+2 -2
View File
@@ -200,8 +200,8 @@ python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run
python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
# Distributed 4-node 4-disk lane uses the fault-test hooks server; the
# scanner_heal_topology_ fault cases fail closed without it.
# Distributed 4-node 4-disk lane includes physical-worker fault injection and
# scanner_heal_topology_ cases; both require the fault-test hooks server.
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test
-4
View File
@@ -29,10 +29,6 @@
//! 3. Starting with the default `rustfsadmin` credentials emits the
//! default-credentials startup warning (observable operator signal).
// Keep inventory acceptance in the admin-auth PR smoke selection.
#[cfg(test)]
mod target_inventory_test;
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
@@ -1,205 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Exercise the target inventory through HTTP authentication and authorization.
//! Handler-level tests separately control reconciliation locks and config faults.
use crate::common::{
RustFSTestEnvironment, admin_create_user, admin_request, admin_request_with_session_token, build_test_sts_client,
init_logging, local_http_client,
};
use http::header::{AUTHORIZATION, HOST};
use reqwest::StatusCode;
use rustfs_signer::{constants::UNSIGNED_PAYLOAD, sign_v4};
use s3s::Body;
use serde_json::Value;
use std::{error::Error, time::Duration};
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
const SECRET: &str = "inventory-acceptance-token";
const LIMITED_KEY: &str = "inventory-limited";
const LIMITED_SECRET: &str = "inventory-limited-secret";
const PATHS: [&str; 5] = [
"/rustfs/admin/v3/target/list",
"/rustfs/admin/v3/audit/target/list",
"/rustfs/admin/v4/plugins/instances",
"/rustfs/admin/v4/plugins/instances/builtin:webhook:notify:acceptance",
"/rustfs/admin/v4/extensions/instances",
];
async fn duplicate_authorization(
env: &RustFSTestEnvironment,
path: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}{path}", env.url);
let uri: http::Uri = url.parse()?;
let authority = uri.authority().ok_or("inventory URL missing authority")?.to_string();
let request = http::Request::builder()
.method(http::Method::GET)
.uri(uri)
.header(HOST, authority)
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
.body(Body::empty())?;
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
let mut headers = signed.headers().clone();
let authorization = headers.get(AUTHORIZATION).ok_or("signer omitted authorization")?.clone();
headers.append(AUTHORIZATION, authorization);
assert_eq!(headers.get_all(AUTHORIZATION).iter().count(), 2);
Ok(local_http_client().get(url).headers(headers).send().await?)
}
fn assert_unloaded_target(entry: &Value, path: &str) {
assert_eq!(entry["service"], "webhook", "{path}: unexpected target service");
assert!(
entry["account_id"]
.as_str()
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance")),
"{path}: fixture target is missing"
);
assert_eq!(entry["status"], "offline", "{path}: unloaded targets must remain visible as offline");
if !path.contains("/extensions/") {
assert_eq!(entry["health_reason"], "not_loaded_in_runtime", "{path}: unexpected health reason");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn target_inventory_preserves_unloaded_targets_and_http_auth_boundaries() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
// Enabled destinations remain configured while both delivery modules are
// disabled, so inventory reads must not require a loaded target runtime.
env.start_rustfs_server_with_env(
vec![],
&[
("RUSTFS_NOTIFY_ENABLE", "false"),
("RUSTFS_AUDIT_ENABLE", "false"),
("RUSTFS_NOTIFY_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
("RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
("RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
("RUSTFS_AUDIT_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
("RUSTFS_AUDIT_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
],
)
.await?;
admin_create_user(&env, LIMITED_KEY, LIMITED_SECRET).await?;
tokio::time::timeout(Duration::from_secs(60), async {
let assumed = build_test_sts_client(&env.url, &env.access_key, &env.secret_key, None, "e2e-target-inventory")
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/target-inventory")
.role_session_name("target-inventory-console-session")
.send()
.await?;
let temporary = assumed
.credentials()
.ok_or("AssumeRole should return temporary inventory credentials")?;
assert_ne!(
temporary.access_key_id(),
env.access_key,
"inventory must exercise a distinct session credential"
);
assert!(
!temporary.session_token().is_empty(),
"temporary inventory credentials must include a token"
);
for path in PATHS {
for (principal, access_key, secret_key, session_token) in [
("root", env.access_key.as_str(), env.secret_key.as_str(), None),
(
"temporary admin",
temporary.access_key_id(),
temporary.secret_access_key(),
Some(temporary.session_token()),
),
] {
let (status, body) = admin_request_with_session_token(
&env.url,
http::Method::GET,
path,
None,
access_key,
secret_key,
session_token,
)
.await?;
assert_eq!(status, StatusCode::OK, "{path}: signed {principal} inventory failed");
assert!(!body.contains(SECRET), "{path}: inventory exposed a target secret");
let value: Value = serde_json::from_str(&body)?;
let entries: Vec<&Value> = if path.ends_with("/target/list") {
let field = if path.contains("/audit/") {
"audit_endpoints"
} else {
"notification_endpoints"
};
value[field].as_array().ok_or("missing endpoint inventory")?.iter().collect()
} else if path.ends_with("/instances") {
value["instances"]
.as_array()
.ok_or("missing instance inventory")?
.iter()
.collect()
} else {
vec![&value]
};
let targets: Vec<_> = entries
.into_iter()
.filter(|entry| {
entry["account_id"]
.as_str()
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance"))
})
.collect();
let expected = if path.ends_with("/instances") { 2 } else { 1 };
assert_eq!(targets.len(), expected, "{path}: configured targets disappeared");
for entry in &targets {
assert_unloaded_target(entry, path);
if path.contains("/v4/") {
assert_eq!(entry["config"]["auth_token"], "***redacted***", "{path}: missing secret redaction");
}
}
if expected == 2 {
for domain in ["notify", "audit"] {
assert!(targets.iter().any(|entry| entry["domain"] == domain), "{path}: missing {domain} domain");
}
}
}
let unsigned = local_http_client().get(format!("{}{path}", env.url)).send().await?;
assert_eq!(unsigned.status(), StatusCode::FORBIDDEN, "{path}: unsigned request was not denied");
let body = unsigned.text().await?;
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
let ambiguous = duplicate_authorization(&env, path).await?;
assert_eq!(
ambiguous.status(),
StatusCode::FORBIDDEN,
"{path}: duplicate authorization was not rejected"
);
let body = ambiguous.text().await?;
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
let (status, body) = admin_request(&env.url, http::Method::GET, path, None, LIMITED_KEY, LIMITED_SECRET).await?;
assert_eq!(status, StatusCode::FORBIDDEN, "{path}: principal without admin policy was not denied");
assert!(body.contains("<Code>AccessDenied</Code>"), "{path}: unexpected authorization error");
assert!(!body.contains("Signature is required") && !body.contains("SignatureDoesNotMatch"));
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
})
.await??;
env.stop_server();
Ok(())
}
@@ -20,14 +20,15 @@
//! offline before heal converges, a strict latest-listing quorum of 3 can only
//! ever observe 2 copies, so ListObjectsV2 silently dropped the object even
//! though GetObject (read quorum 2) still succeeded. Exposed by the flaky
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853). The
//! listing now follows MinIO's rule: an entry needs `ceil(asked/2)` copies
//! among the online drives it asked, so three online drives need two copies
//! (see `SetDisks::list_path` in `crates/ecstore/src/store/list_objects.rs`).
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853); the
//! product fix relaxes the listing's required object quorum by the number of
//! set drives the listing could not consult (see
//! `latest_listing_required_object_quorum` in
//! `crates/ecstore/src/store/list_objects.rs`).
#[cfg(test)]
mod tests {
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment, init_logging};
use crate::common::{RustFSTestClusterEnvironment, init_logging};
use aws_sdk_s3::Client;
use bytes::Bytes;
use std::collections::HashSet;
@@ -50,13 +51,13 @@ mod tests {
format!("degraded-object-{idx:02}")
}
async fn list_all_keys(client: &Client, bucket: &str) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
async fn list_all_keys(client: &Client) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
let mut keys = HashSet::new();
let mut continuation_token: Option<String> = None;
loop {
let response = client
.list_objects_v2()
.bucket(bucket)
.bucket(BUCKET)
.set_continuation_token(continuation_token.clone())
.send()
.await?;
@@ -146,7 +147,7 @@ mod tests {
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
let deadline = Instant::now() + LISTING_DEADLINE;
let listed = loop {
let listed = match list_all_keys(&client, BUCKET).await {
let listed = match list_all_keys(&client).await {
Ok(keys) => keys,
Err(error) if Instant::now() < deadline => {
info!("retrying degraded listing: {error}");
@@ -170,97 +171,4 @@ mod tests {
Ok(())
}
/// 4-node x 4-drive cluster (one 16-drive set), #8828 shape:
/// 1. PUT objects while the storage class is EC:8, so each is 8+8.
/// 2. Restart with the EC:4 default, as after a storage-class change.
/// 3. Stop two nodes (8 drives). Each object keeps eight data shards, so
/// GET succeeds and ListObjectsV2 must list every key: the listing
/// quorum is half of the eight drives it can ask.
/// 4. Stop a third node. Four online drives are below the set's read
/// quorum, so LIST must report 503 instead of a partial page.
#[tokio::test]
async fn ec8_objects_stay_listable_with_half_of_an_ec4_set_offline() -> TestResult {
const EC8_BUCKET: &str = "degraded-listing-ec8";
init_logging();
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 4)).await?;
cluster.set_env("RUSTFS_HEAL_ENABLED", "false");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:8");
cluster.start().await?;
cluster.create_test_bucket(EC8_BUCKET).await?;
let client = cluster.create_s3_client(0)?;
for idx in 0..OBJECT_COUNT {
let key = object_key(idx);
client
.put_object()
.bucket(EC8_BUCKET)
.key(&key)
.body(Bytes::from(format!("ec8 listing payload {idx}")).into())
.send()
.await
.map_err(|error| format!("EC:8 PUT for {key} failed: {error}"))?;
}
info!("restarting with the EC:4 default storage class");
cluster.stop();
// A later entry for the same variable wins when the node process starts.
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4");
cluster.start().await?;
let client = cluster.create_s3_client(0)?;
info!("stopping nodes 2 and 3 (eight of sixteen drives)");
cluster.stop_node(2)?;
cluster.stop_node(3)?;
for idx in 0..OBJECT_COUNT {
let key = object_key(idx);
let deadline = Instant::now() + GET_RETRY_DEADLINE;
loop {
match client.get_object().bucket(EC8_BUCKET).key(&key).send().await {
Ok(response) => {
let body = response.body.collect().await?.into_bytes();
assert_eq!(body.as_ref(), format!("ec8 listing payload {idx}").as_bytes());
break;
}
Err(error) if Instant::now() < deadline => {
info!("retrying EC:8 GET for {key}: {error}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(error) => return Err(format!("EC:8 object {key} failed GET with eight drives: {error}").into()),
}
}
}
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
let deadline = Instant::now() + LISTING_DEADLINE;
loop {
match list_all_keys(&client, EC8_BUCKET).await {
Ok(listed) if listed == expected => break,
Ok(listed) => assert!(
Instant::now() < deadline,
"EC:8 objects readable with eight drives stayed missing from ListObjectsV2: listed={listed:?}"
),
Err(error) => assert!(
Instant::now() < deadline,
"ListObjectsV2 kept failing with eight of sixteen drives online: {error}"
),
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
info!("stopping node 1 (four of sixteen drives remain)");
cluster.stop_node(1)?;
let deadline = Instant::now() + LISTING_DEADLINE;
loop {
match client.list_objects_v2().bucket(EC8_BUCKET).send().await {
Err(error) if error.raw_response().is_some_and(|response| response.status().as_u16() == 503) => break,
Err(error) => assert!(Instant::now() < deadline, "below the set read quorum LIST must report 503, got {error}"),
Ok(response) => panic!("below the set read quorum LIST must fail, but listed {} keys", response.contents().len()),
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Ok(())
}
}
@@ -0,0 +1,378 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
#![cfg(unix)]
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, payload_for, s3_probe_client, unique_bucket, wait_for_ready,
};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk};
use crate::common::init_logging;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{Delete, ObjectIdentifier};
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
const PUBLIC_INTERNAL_ERROR: &str = "We encountered an internal error, please try again.";
#[derive(Debug, PartialEq, Eq)]
pub(super) struct PhysicalEntry {
identity: (u64, u64),
changed: (i64, i64),
modified: (i64, i64),
bytes: Option<Vec<u8>>,
}
// Include directory identity/ctime as well as file bytes. Creating and removing
// a temporary marker/rollback file cannot hide behind a restored final xl.meta.
// Reads change atime, which is deliberately excluded.
pub(super) fn physical_snapshot(dist: &DistCluster, bucket: &str) -> TestResult<BTreeMap<(usize, PathBuf), PhysicalEntry>> {
let mut snapshot = BTreeMap::new();
for (node, instance) in dist.cluster.nodes.iter().enumerate() {
let root = Path::new(&instance.data_dirs[0]).join(bucket);
for entry in walkdir::WalkDir::new(&root) {
let entry = entry?;
let metadata = std::fs::symlink_metadata(entry.path())?;
assert!(!metadata.file_type().is_symlink(), "unexpected fixture symlink");
snapshot.insert(
(node, entry.path().strip_prefix(&root)?.to_path_buf()),
PhysicalEntry {
identity: (metadata.dev(), metadata.ino()),
changed: (metadata.ctime(), metadata.ctime_nsec()),
modified: (metadata.mtime(), metadata.mtime_nsec()),
bytes: if metadata.is_dir() {
None
} else {
Some(std::fs::read(entry.path())?)
},
},
);
}
}
Ok(snapshot)
}
struct Version {
key: String,
id: String,
body: Vec<u8>,
manifests: Vec<VersionShardCensus>,
}
impl Version {
async fn put(dist: &DistCluster, bucket: &str, key: &str, seed: &str) -> TestResult<Self> {
let body = payload_for(seed, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(key)
.body(body.clone().into())
.send(),
)
.await??;
let id = output.version_id().ok_or("fixture PUT omitted version ID")?.to_owned();
let manifests = dist
.cluster
.nodes
.iter()
.map(|node| census_object_version_on_disk(Path::new(&node.data_dirs[0]), bucket, key, Some(&id)))
.collect::<TestResult<Vec<_>>>()?;
assert!(
manifests
.iter()
.all(|manifest| manifest.is_complete() && !manifest.expected_part_numbers.is_empty())
);
Ok(Self {
key: key.to_owned(),
id,
body,
manifests,
})
}
}
fn target(key: &str, version: Option<&str>) -> TestResult<ObjectIdentifier> {
Ok(ObjectIdentifier::builder()
.key(key)
.set_version_id(version.map(str::to_owned))
.build()?)
}
async fn assert_fixture(
dist: &DistCluster,
bucket: &str,
prefix: &str,
versions: &[Version],
marker: (&str, &str),
) -> TestResult {
let expected: BTreeSet<_> = versions
.iter()
.map(|version| (version.key.clone(), version.id.clone()))
.collect();
for node in 0..dist.cluster.nodes.len() {
let client = dist.client(node)?;
let listed = timeout(
Duration::from_secs(30),
client.list_object_versions().bucket(bucket).prefix(prefix).send(),
)
.await??;
let actual: BTreeSet<_> = listed
.versions()
.iter()
.map(|version| {
(
version.key().unwrap_or_default().to_owned(),
version.version_id().unwrap_or_default().to_owned(),
)
})
.collect();
assert_eq!(listed.versions().len(), versions.len());
assert_eq!(actual, expected, "delete changed exact data versions on node {node}");
assert_eq!(listed.delete_markers().len(), 1);
assert_eq!(listed.delete_markers()[0].key(), Some(marker.0));
assert_eq!(listed.delete_markers()[0].version_id(), Some(marker.1));
assert_eq!(listed.delete_markers()[0].is_latest(), Some(true));
for version in versions {
assert_exact_version(&client, bucket, &version.key, &version.id, &version.body).await?;
let disk = Path::new(&dist.cluster.nodes[node].data_dirs[0]);
assert!(
census_object_version_on_disk(disk, bucket, &version.key, Some(&version.id))?
.matches_manifest(&version.manifests[node])
);
}
}
Ok(())
}
async fn assert_single_denied(dist: &DistCluster, node: usize, bucket: &str, key: &str, version: Option<&str>) -> TestResult {
let before = physical_snapshot(dist, bucket)?;
// One attempt: a later retry must not conceal a first local mutation.
let client = s3_probe_client(&dist.client(node)?);
let error = timeout(
Duration::from_secs(30),
client
.delete_object()
.bucket(bucket)
.key(key)
.set_version_id(version.map(str::to_owned))
.send(),
)
.await?
.expect_err("ordinary deletion succeeded in a managed bucket");
let service = error
.as_service_error()
.ok_or("delete failed without an S3 service rejection")?;
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(500));
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some(PUBLIC_INTERNAL_ERROR));
assert!(
physical_snapshot(dist, bucket)? == before,
"first delete through node {node} wrote or rolled back managed data"
);
Ok(())
}
async fn assert_batch_denied(dist: &DistCluster, node: usize, bucket: &str, objects: &[ObjectIdentifier]) -> TestResult {
let before = physical_snapshot(dist, bucket)?;
let client = s3_probe_client(&dist.client(node)?);
let response = timeout(
Duration::from_secs(30),
client
.delete_objects()
.bucket(bucket)
.delete(Delete::builder().set_objects(Some(objects.to_vec())).quiet(false).build()?)
.send(),
)
.await??;
assert!(response.deleted().is_empty(), "managed batch reported a successful delete");
assert_eq!(response.errors().len(), objects.len(), "batch must reject every requested occurrence");
for (error, requested) in response.errors().iter().zip(objects) {
assert_eq!(error.key(), Some(requested.key()));
assert_eq!(error.version_id(), requested.version_id());
assert_eq!(error.code(), Some("InternalError"));
assert_eq!(error.message(), Some(PUBLIC_INTERNAL_ERROR));
}
assert!(
physical_snapshot(dist, bucket)? == before,
"first batch through node {node} wrote or rolled back managed data"
);
Ok(())
}
fn copy_existing_object(source: &Path, destination: &Path) -> TestResult {
assert!(!destination.exists());
for entry in walkdir::WalkDir::new(source) {
let entry = entry?;
let target = destination.join(entry.path().strip_prefix(source)?);
if entry.file_type().is_dir() {
std::fs::create_dir_all(&target)?;
} else {
assert!(entry.file_type().is_file());
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}
async fn ordinary_delete_control(dist: &DistCluster, bucket: &str) -> TestResult {
let client = s3_probe_client(&dist.client(0)?);
let key = "ordinary-observer-control.bin";
let version = Version::put(dist, bucket, key, key).await?;
let before = physical_snapshot(dist, bucket)?;
let deleted = client.delete_object().bucket(bucket).key(key).send().await?;
assert_eq!(deleted.delete_marker(), Some(true));
let marker = deleted.version_id().ok_or("ordinary delete omitted marker ID")?;
assert!(
physical_snapshot(dist, bucket)? != before,
"physical observer missed real marker creation"
);
client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(marker)
.send()
.await?;
// The logical version inventory is restored, but ctime/identity still
// expose the two writes. This checks the observer used by managed negatives.
let listed = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
assert_eq!(listed.versions().len(), 1);
assert_eq!(listed.versions()[0].version_id(), Some(version.id.as_str()));
assert!(listed.delete_markers().is_empty());
assert!(
physical_snapshot(dist, bucket)? != before,
"marker round-trip hid transient physical writes"
);
assert_exact_version(&client, bucket, key, &version.id, &version.body).await?;
client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(&version.id)
.send()
.await?;
assert!(
client
.list_object_versions()
.bucket(bucket)
.prefix(key)
.send()
.await?
.versions()
.is_empty()
);
let current = Version::put(dist, bucket, "ordinary-batch-current.bin", "current").await?;
let exact = Version::put(dist, bucket, "ordinary-batch-exact.bin", "exact").await?;
let response = client
.delete_objects()
.bucket(bucket)
.delete(
Delete::builder()
.objects(target(&current.key, None)?)
.objects(target(&exact.key, Some(&exact.id))?)
.objects(target(&exact.key, Some(&exact.id))?)
.build()?,
)
.send()
.await?;
assert!(response.errors().is_empty());
assert_eq!(response.deleted().len(), 3);
let listed = client
.list_object_versions()
.bucket(bucket)
.prefix(&current.key)
.send()
.await?;
assert_eq!(listed.versions().len(), 1);
assert_eq!(listed.delete_markers().len(), 1);
assert!(
client
.list_object_versions()
.bucket(bucket)
.prefix(&exact.key)
.send()
.await?
.versions()
.is_empty()
);
Ok(())
}
#[tokio::test]
async fn four_node_artifact_delete_entrypoints_preserve_versions_and_physical_state() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (identity, admin) = create_artifact_attempt(&dist).await?;
let bucket = identity["bucket"]
.as_str()
.ok_or("managed receipt omitted bucket")?
.to_owned();
let prefix = identity["attempt_prefix"]
.as_str()
.ok_or("managed receipt omitted prefix")?
.to_owned();
let key = format!("{prefix}native.bin");
let mut versions = vec![
Version::put(&dist, &bucket, &key, "native-old").await?,
Version::put(&dist, &bucket, &key, "native-new").await?,
];
let ordinary = unique_bucket("delete-control");
dist.create_bucket(&ordinary).await?;
enable_versioning(&dist.client(0)?, &ordinary).await?;
// A new managed bucket cannot legitimately create a marker. Model existing
// persisted state from before adoption/upgrade using a real S3-generated
// ordinary object+marker, copied disk-for-disk in the same erasure set.
// Only this object's files move; bucket authority/configuration is untouched.
let marker_key = format!("{prefix}existing-marker.bin");
versions.push(Version::put(&dist, &ordinary, &marker_key, "existing-data").await?);
let output = dist
.client(2)?
.delete_object()
.bucket(&ordinary)
.key(&marker_key)
.send()
.await?;
assert_eq!(output.delete_marker(), Some(true));
let marker = output.version_id().ok_or("ordinary fixture omitted marker ID")?.to_owned();
dist.cluster.stop();
for node in &dist.cluster.nodes {
let disk = Path::new(&node.data_dirs[0]);
copy_existing_object(&disk.join(&ordinary).join(&marker_key), &disk.join(&bucket).join(&marker_key))?;
}
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
assert_fixture(&dist, &bucket, &prefix, &versions, (&marker_key, &marker)).await?;
ordinary_delete_control(&dist, &ordinary).await?;
let batch = vec![
target(&key, None)?,
target(&key, Some(&versions[0].id))?,
target(&key, Some(&versions[1].id))?,
target(&marker_key, Some(&marker))?,
target(&key, Some(&versions[0].id))?,
target(&marker_key, Some(&versions[2].id))?,
];
for phase in ["open", "sealed", "restarted"] {
if phase == "sealed" {
confirm_seal(&dist, &admin, &json!({"version": 1, "target": identity}).to_string(), &identity).await?;
} else if phase == "restarted" {
dist.restart_with_current_binary().await?;
}
for node in 0..dist.cluster.nodes.len() {
assert_single_denied(&dist, node, &bucket, &key, None).await?;
assert_single_denied(&dist, node, &bucket, &key, Some(&versions[0].id)).await?;
assert_single_denied(&dist, node, &bucket, &marker_key, Some(&marker)).await?;
assert_batch_denied(&dist, node, &bucket, &batch).await?;
}
assert_fixture(&dist, &bucket, &prefix, &versions, (&marker_key, &marker)).await?;
}
Ok(())
}
@@ -0,0 +1,434 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
#![cfg(unix)]
use super::artifact_delete_test::physical_snapshot;
use super::artifact_seal_test::{ArtifactAdmin, create_artifact_attempt};
use super::harness::{DistCluster, DistLayout, TestResult, wait_for_ready};
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
signed_s3_request,
};
use futures::{StreamExt as _, TryStreamExt as _};
use http::{Method, StatusCode};
use serde::Deserialize;
use serde_json::{Value, json};
use std::{collections::BTreeSet, time::Duration};
use tokio::time::timeout;
use uuid::Uuid;
const ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/inventory";
const BUDGET: Duration = Duration::from_secs(45);
struct Reader {
access: String,
secret: String,
}
impl Reader {
async fn create(dist: &DistCluster, target: &Value, action: &str) -> TestResult<Self> {
let reader = Self {
access: format!("inventory{}", &Uuid::new_v4().simple().to_string()[..12]),
secret: Uuid::new_v4().simple().to_string(),
};
let policy_name = format!("inventory{}", &Uuid::new_v4().simple().to_string()[..12]);
let bucket = target["bucket"].as_str().ok_or("target bucket")?;
let prefix = target["attempt_prefix"]
.as_str()
.ok_or("target prefix")?
.trim_end_matches('/');
let resource = if action == "s3:ListBucket" {
format!("arn:aws:s3:::{bucket}")
} else {
format!("arn:aws:s3:::{bucket}/{prefix}")
};
let policy =
json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":action,"Resource":resource}]}).to_string();
let url = &dist.cluster.nodes[0].url;
let access = &dist.cluster.access_key;
let secret = &dist.cluster.secret_key;
admin_create_user_via(AdminTransport::Signed, url, access, secret, &reader.access, &reader.secret).await?;
admin_add_canned_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &policy).await?;
admin_attach_user_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &reader.access).await?;
Ok(reader)
}
async fn request(&self, dist: &DistCluster, target: &Value, invocation: Uuid) -> TestResult<reqwest::Response> {
timeout(
BUDGET,
signed_s3_request(
Method::POST,
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
Some(json!({"version":1,"invocation_id":invocation,"target":target}).to_string()),
Some("application/json"),
&self.access,
&self.secret,
),
)
.await?
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Frame {
version: u32,
invocation_id: Uuid,
target: Value,
sequence: u64,
payload: Payload,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum Payload {
Observation {
disk_id: Uuid,
key: String,
version_id: Uuid,
delete_marker: bool,
},
Complete {
members: usize,
observations: u64,
},
}
async fn read_frames(mut response: reqwest::Response) -> TestResult<(Vec<Frame>, bool)> {
let mut pending = Vec::new();
let mut frames = Vec::new();
let mut clean = true;
loop {
match timeout(BUDGET, response.chunk()).await? {
Ok(Some(chunk)) => {
pending.extend_from_slice(&chunk);
while let Some(end) = pending.iter().position(|byte| *byte == b'\n') {
assert!(end < 1024 * 1024, "HTTP inventory line exceeds encoded bound");
let line: Vec<_> = pending.drain(..=end).collect();
frames.push(serde_json::from_slice(&line[..end])?);
}
assert!(pending.len() < 1024 * 1024, "unbounded incomplete HTTP line");
}
Ok(None) => break,
Err(_) => {
clean = false;
break;
}
}
}
Ok((frames, clean && pending.is_empty()))
}
async fn seal(dist: &DistCluster, user: &ArtifactAdmin, target: &Value) -> TestResult {
let (status, body) = user
.request(
dist,
0,
"/rustfs/admin/v3/plugin-artifacts/seal-writes",
json!({"version":1,"target":target}).to_string(),
)
.await?;
assert_eq!(status, StatusCode::OK, "actual all-member closure: {body}");
assert_eq!(serde_json::from_str::<Value>(&body)?["all_writes_sealed"], true);
Ok(())
}
fn exact_inventory(frames: &[Frame], target: &Value, invocation: Uuid, versions: &BTreeSet<(String, Uuid, bool)>) {
let mut observed = BTreeSet::new();
let mut disks = BTreeSet::new();
let mut count = 0u64;
for (sequence, frame) in frames.iter().enumerate() {
assert_eq!(frame.version, 1);
assert_eq!(frame.invocation_id, invocation);
assert_eq!(&frame.target, target);
assert_eq!(frame.sequence, u64::try_from(sequence).expect("bounded fixture sequence"));
match &frame.payload {
Payload::Observation {
disk_id,
key,
version_id,
delete_marker,
} => {
assert!(!disk_id.is_nil() && !version_id.is_nil());
assert!(
observed.insert((*disk_id, key.clone(), *version_id, *delete_marker)),
"duplicate physical observation"
);
disks.insert(*disk_id);
count += 1;
}
Payload::Complete { members, observations } => {
assert_eq!(sequence + 1, frames.len(), "Complete must be the last HTTP frame");
assert_eq!(*members, 4);
assert_eq!(*observations, count);
}
}
}
assert!(
matches!(frames.last().map(|frame| &frame.payload), Some(Payload::Complete { .. })),
"A03I3A_HTTP_COMPLETE"
);
if !versions.is_empty() {
assert_eq!(disks.len(), 4);
}
for disk in disks {
let actual: BTreeSet<_> = observed
.iter()
.filter(|(id, ..)| *id == disk)
.map(|(_, key, version, marker)| (key.clone(), *version, *marker))
.collect();
assert_eq!(&actual, versions, "A03I3A_EXACT_VERSIONS");
}
assert_eq!(observed.len(), versions.len() * 4);
}
#[tokio::test]
async fn artifact_inventory_http_exact_versions() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, admin) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("prefix")?;
let mut versions = BTreeSet::new();
for (name, body) in [
("same", b"first".to_vec()),
("same", b"second".to_vec()),
("zero", Vec::new()),
] {
let key = format!("{prefix}{name}");
let output = timeout(BUDGET, dist.client(1)?.put_object().bucket(bucket).key(&key).body(body.into()).send()).await??;
let version = Uuid::parse_str(output.version_id().ok_or("actual PUT version")?)?;
versions.insert((key, version, false));
}
seal(&dist, &admin, &target).await?;
// A wildcard action is still restricted to this exact attempt resource.
// The separate authority case exercises the dedicated action alone.
let reader = Reader::create(&dist, &target, "admin:*").await?;
let before = physical_snapshot(&dist, bucket)?;
let invocation = Uuid::new_v4();
let response = reader.request(&dist, &target, invocation).await?;
assert_eq!(response.status(), StatusCode::OK, "A03I3A_HTTP_ROUTE");
assert_eq!(
response.headers().get("content-type").and_then(|value| value.to_str().ok()),
Some("application/x-ndjson")
);
let (frames, clean) = read_frames(response).await?;
assert!(clean, "Complete requires clean HTTP EOF");
assert!(frames.len() > 2, "real multi-frame HTTP body");
exact_inventory(&frames, &target, invocation, &versions);
assert!(
before == physical_snapshot(&dist, bucket)?,
"inventory mutated physical artifact namespace"
);
let (empty, empty_admin) = create_artifact_attempt(&dist).await?;
seal(&dist, &empty_admin, &empty).await?;
let empty_reader = Reader::create(&dist, &empty, "admin:*").await?;
let invocation = Uuid::new_v4();
let response = empty_reader.request(&dist, &empty, invocation).await?;
assert_eq!(response.status(), StatusCode::OK);
let (frames, clean) = read_frames(response).await?;
assert!(clean);
exact_inventory(&frames, &empty, invocation, &BTreeSet::new());
Ok(())
}
#[tokio::test]
async fn artifact_inventory_http_scoped_authority() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, admin) = create_artifact_attempt(&dist).await?;
seal(&dist, &admin, &target).await?;
let reader = Reader::create(&dist, &target, "admin:InventoryPluginArtifactAttempt").await?;
let invocation = Uuid::new_v4();
let response = reader.request(&dist, &target, invocation).await?;
assert_eq!(response.status(), StatusCode::OK, "dedicated inventory authority");
let (frames, clean) = read_frames(response).await?;
assert!(clean);
exact_inventory(&frames, &target, invocation, &BTreeSet::new());
let mut other_prefix = target.clone();
other_prefix["attempt_prefix"] = json!(format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4()));
let mut other_bucket = target.clone();
other_bucket["bucket"] = json!("another-artifact-bucket");
for denied in [&other_prefix, &other_bucket] {
assert_eq!(
reader.request(&dist, denied, Uuid::new_v4()).await?.status(),
StatusCode::FORBIDDEN,
"A03I3A_SCOPED_AUTHORITY"
);
}
let (status, _) = admin
.request(
&dist,
0,
ROUTE,
json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target}).to_string(),
)
.await?;
assert_eq!(status, StatusCode::FORBIDDEN, "A03I3A_SCOPED_AUTHORITY seal-only user");
let listed = Reader::create(&dist, &target, "s3:ListBucket").await?;
dist.client_with_credentials(0, &listed.access, &listed.secret)?
.list_objects_v2()
.bucket(target["bucket"].as_str().ok_or("bucket")?)
.send()
.await?;
assert_eq!(
listed.request(&dist, &target, Uuid::new_v4()).await?.status(),
StatusCode::FORBIDDEN,
"A03I3A_SCOPED_AUTHORITY S3 list-only user"
);
let root_response = signed_s3_request(
Method::POST,
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
Some(json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target}).to_string()),
Some("application/json"),
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(root_response.status(), StatusCode::FORBIDDEN, "original scoped user required");
for body in [
json!({"version":1,"invocation_id":Uuid::nil(),"target":target}),
json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target,"extra":true}),
] {
let response = signed_s3_request(
Method::POST,
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
Some(body.to_string()),
Some("application/json"),
&reader.access,
&reader.secret,
)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "strict inventory request");
}
Ok(())
}
async fn assert_incomplete(response: reqwest::Response) -> TestResult<Vec<Frame>> {
assert_eq!(
response.status(),
StatusCode::OK,
"valid request must exercise the streaming failure boundary"
);
let (frames, _) = read_frames(response).await?;
assert!(
!frames.iter().any(|frame| matches!(frame.payload, Payload::Complete { .. })),
"A03I3A_NO_FALSE_COMPLETE"
);
Ok(frames)
}
#[tokio::test]
async fn artifact_inventory_http_incomplete_has_no_terminal() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, admin) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("prefix")?;
// Real PUTs produce more data than a stalled HTTP/2 flow-control window.
futures::stream::iter(0..256)
.map(|index| {
let client = dist.client(1);
let key = format!("{prefix}slow/{index:04}/{}/{}", "a".repeat(190), "b".repeat(190));
async move {
let output = client?
.put_object()
.bucket(bucket)
.key(key)
.body(vec![1].into())
.send()
.await?;
assert!(!Uuid::parse_str(output.version_id().ok_or("actual PUT version")?)?.is_nil());
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
}
})
.buffer_unordered(8)
.try_collect::<Vec<_>>()
.await?;
seal(&dist, &admin, &target).await?;
let reader = Reader::create(&dist, &target, "admin:InventoryPluginArtifactAttempt").await?;
dist.cluster.stop_node(3)?;
assert_incomplete(reader.request(&dist, &target, Uuid::new_v4()).await?).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
let mut wrong = target.clone();
wrong["bucket_incarnation"] = json!(Uuid::new_v4());
assert_incomplete(reader.request(&dist, &wrong, Uuid::new_v4()).await?).await?;
// The configured endpoint map is ordered. Damage its final receiver, so
// earlier real observations are emitted before the physical scan fails.
let node = dist
.cluster
.nodes
.iter()
.max_by_key(|node| format!("{}{}", node.url, node.data_dirs[0]))
.ok_or("configured members")?;
let metadata = std::path::Path::new(&node.data_dirs[0])
.join(bucket)
.join(format!("{prefix}slow/0000/{}/{}", "a".repeat(190), "b".repeat(190)))
.join("xl.meta");
let original = std::fs::read(&metadata)?;
std::fs::write(&metadata, b"corrupt inventory metadata")?;
let before = physical_snapshot(&dist, bucket)?;
let outcome = assert_incomplete(reader.request(&dist, &target, Uuid::new_v4()).await?).await;
let after = physical_snapshot(&dist, bucket)?;
std::fs::write(&metadata, original)?;
let frames = outcome?;
assert!(before == after, "failed inventory repaired or mutated a physical object");
assert!(
frames
.iter()
.any(|frame| matches!(frame.payload, Payload::Observation { .. })),
"late physical failure must follow real HTTP observations"
);
let client = reqwest::Client::builder()
.no_proxy()
.http2_prior_knowledge()
.http2_initial_stream_window_size(1)
.http2_initial_connection_window_size(1)
.http2_adaptive_window(false)
.build()?;
let url = format!("{}{ROUTE}", dist.cluster.nodes[0].url);
let request = |invocation| {
json!({"version":1,"invocation_id":invocation,"target":target})
.to_string()
.into_bytes()
};
let response = crate::common::signed_request_with_client(
&client,
Method::POST,
&url,
&reader.access,
&reader.secret,
Some(request(Uuid::new_v4())),
Some("application/json"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
// Do not poll the body: its one-byte HTTP/2 receive window stalls transport.
// This waits beyond the fixed30s service budget, not for readiness.
tokio::time::sleep(Duration::from_secs(35)).await;
assert_incomplete(response).await?;
let response = crate::common::signed_request_with_client(
&client,
Method::POST,
&url,
&reader.access,
&reader.secret,
Some(request(Uuid::new_v4())),
Some("application/json"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
drop(response);
let response = timeout(Duration::from_secs(10), reader.request(&dist, &target, Uuid::new_v4())).await??;
let (frames, clean) = timeout(Duration::from_secs(10), read_frames(response)).await??;
assert!(
clean && matches!(frames.last().map(|frame| &frame.payload), Some(Payload::Complete { .. })),
"fresh scan must succeed promptly after an actual disconnected HTTP body"
);
Ok(())
}
@@ -0,0 +1,269 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::artifact_repair_tail_test::PhysicalBarrier;
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{DistCluster, DistLayout, TestResult, payload_for, wait_until};
use crate::chaos::census_object_version_on_disk;
use crate::common::init_logging;
use crate::storage_api::artifact_rpc::{
DiskError, TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest,
};
use http::StatusCode;
use rustfs_filemeta::FileMeta;
use rustfs_protos::proto_gen::node_service::{RenameDataRequest, node_service_client::NodeServiceClient};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
use tonic::Request;
use uuid::Uuid;
const PUT_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-attempt";
fn signed_put_request(body: RenameDataRequest, audience: &str) -> TestResult<Request<RenameDataRequest>> {
let canonical = rustfs_protos::canonical_artifact_rename_data_request_body(&body)?;
let mut request = Request::new(body);
request.set_timeout(Duration::from_secs(15));
set_tonic_canonical_body_digest(&mut request, &canonical)?;
let digest = request
.metadata()
.get("x-rustfs-content-sha256")
.ok_or("missing canonical digest")?
.to_str()?
.to_owned();
request.metadata_mut().as_mut().extend(gen_tonic_signature_headers(
audience,
TONIC_RPC_PREFIX.trim_start_matches('/'),
"ArtifactRenameData",
Some(&digest),
)?);
Ok(request)
}
fn publication_snapshot(paths: &[&Path]) -> TestResult<BTreeMap<PathBuf, Option<Vec<u8>>>> {
let mut snapshot = BTreeMap::new();
for root in paths {
for entry in walkdir::WalkDir::new(root) {
let entry = entry?;
let contents = if entry.file_type().is_dir() {
None
} else {
Some(std::fs::read(entry.path())?)
};
snapshot.insert(entry.path().to_path_buf(), contents);
}
}
Ok(snapshot)
}
async fn assert_raw_put_closed(body: &RenameDataRequest, url: &str, audience: &str, paths: &[&Path]) -> TestResult {
let before = publication_snapshot(paths)?;
// Re-sign the exact original request; replay protection is not the rejection.
let request = signed_put_request(body.clone(), audience)?;
let response = timeout(Duration::from_secs(20), async {
let mut client = NodeServiceClient::connect(url.to_owned()).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(client.artifact_rename_data(request).await?.into_inner())
})
.await??;
assert!(!response.success, "sealed attempt admitted another ordinary PUT");
let error = response.error.ok_or("rejected raw PUT omitted error")?;
assert_eq!(error.code, DiskError::Io(std::io::ErrorKind::PermissionDenied.into()).to_u32());
assert_eq!(error.error_info, "io error artifact attempt mutation domain is closed");
assert_eq!(publication_snapshot(paths)?, before, "rejected raw PUT mutated staged or published files");
Ok(())
}
#[tokio::test]
async fn four_node_artifact_put_seal_waits_for_disconnected_physical_tail() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let _ = rustfs_credentials::set_global_rpc_secret("rustfs-internode-signature-e2e-secret".to_owned());
let rpc_secret = rustfs_credentials::try_get_rpc_token()?;
let dist = DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_RPC_SECRET", &rpc_secret)]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let key = format!("{prefix}detached-put.bin");
let payload = payload_for(&key, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(payload.clone().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("managed PUT omitted version ID")?;
let request_body = json!({"version": 1, "target": target}).to_string();
let baselines = dist
.cluster
.nodes
.iter()
.map(|node| census_object_version_on_disk(Path::new(&node.data_dirs[0]), bucket, &key, Some(version)))
.collect::<TestResult<Vec<_>>>()?;
assert!(
baselines
.iter()
.all(|baseline| baseline.is_complete() && !baseline.expected_part_numbers.is_empty())
);
let node = &dist.cluster.nodes[3];
let disk = PathBuf::from(&node.data_dirs[0]).canonicalize()?;
let object = disk.join(bucket).join(&key);
let metadata_bytes = std::fs::read(object.join("xl.meta"))?;
let fi = FileMeta::load(&metadata_bytes)?.into_fileinfo(bucket, &key, version, true, false, true)?;
let admitted_file_info = serde_json::to_value(&fi)?;
assert!(!fi.is_healing(), "ordinary publication must not carry the repair marker");
let data_dir = fi
.data_dir
.ok_or("physical-tail fixture requires external shards")?
.to_string();
let source = Uuid::new_v4().to_string();
let staging = disk.join(".rustfs.sys/tmp").join(&source);
std::fs::create_dir_all(&staging)?;
let destination = object.join(&data_dir);
// Replay the receiver publication of a real ordinary PUT while its attempt
// remains open. Preserve its referenced version metadata. The admitted
// owned operation may still finish metadata publication after RPC detach.
std::fs::rename(&destination, staging.join(&data_dir))?;
let barrier = PhysicalBarrier::arm(&disk, &destination)?;
let format: Value = serde_json::from_slice(&std::fs::read(disk.join(".rustfs.sys/format.json"))?)?;
let disk_id = Uuid::parse_str(format["xl"]["this"].as_str().ok_or("disk format omitted xl.this")?)?;
let incarnation = Uuid::parse_str(target["bucket_incarnation"].as_str().ok_or("target omitted incarnation")?)?;
let put = RenameDataRequest {
disk: disk_id.to_string(),
src_volume: ".rustfs.sys/tmp".to_owned(),
src_path: source,
dst_volume: bucket.to_owned(),
dst_path: key.clone(),
file_info: serde_json::to_string(&fi)?,
file_info_bin: Default::default(),
scanner_publication_lease_token: Default::default(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
};
let request = signed_put_request(put.clone(), &node.address)?;
// Direct RPC takes no coordinator attempt lock and owns its own connection.
let url = node.url.clone();
let mut rpc = tokio::spawn(async move {
timeout(Duration::from_secs(30), async move {
let mut client = NodeServiceClient::connect(url).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(client.artifact_rename_data(request).await?)
})
.await
});
tokio::select! {
result = &mut rpc => {
let response = result???.into_inner();
panic!(
"ordinary PUT receiver completed before the physical barrier: success={}, error={:?}",
response.success, response.error
);
}
arrived = barrier.observed("arrived") => arrived?,
}
assert!(!destination.exists(), "physical PUT escaped its barrier");
rpc.abort();
assert!(rpc.await.expect_err("disconnect the ordinary PUT waiter").is_cancelled());
// The ordinary PUT wrapper retains an owned task. Observe its RPC-facing
// waiter dropping, rather than cancellation of the continuing disk task.
barrier.observed("rpc-waiter-dropped").await?;
let seal = user.request(&dist, 0, PUT_SEAL_ROUTE, request_body.clone());
tokio::pin!(seal);
let closed = wait_until(
Duration::from_secs(15),
|| async {
Ok(walkdir::WalkDir::new(disk.join(".rustfs-artifact-put-seals"))
.min_depth(2)
.max_depth(2)
.into_iter()
.filter_map(Result::ok)
.any(|entry| entry.file_name() == "closed"))
},
"PUT receiver persisted closing before physical drain",
);
tokio::pin!(closed);
tokio::select! {
result = &mut seal => {
let (status, response) = result?;
panic!("PUT seal returned before its blocked receiver closed admission: {status} {response}");
}
result = &mut closed => result?,
}
let (status, response) = timeout(Duration::from_secs(40), &mut seal).await??;
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"PUT seal confirmed an unfinished physical tail: {response}"
);
assert!(response.contains("artifact operation unconfirmed"));
assert!(!destination.exists());
assert_raw_put_closed(&put, &node.url, &node.address, &[&staging, &object]).await?;
assert!(!destination.exists(), "a second admission published while the worker was paused");
barrier.release()?;
wait_until(
Duration::from_secs(15),
|| async { Ok(destination.join("part.1").is_file()) },
"detached ordinary PUT worker finished",
)
.await?;
confirm_seal(&dist, &user, &request_body, &target).await?;
// Seal confirmation is the boundary: already admitted work may re-encode
// metadata before this point, but the complete FileInfo must be unchanged.
let published_objects: Vec<_> = dist
.cluster
.nodes
.iter()
.map(|node| PathBuf::from(&node.data_dirs[0]).join(bucket).join(&key))
.collect();
let published_paths: Vec<_> = published_objects.iter().map(PathBuf::as_path).collect();
let sealed_snapshot = publication_snapshot(&published_paths)?;
let sealed_file_info =
FileMeta::load(&std::fs::read(object.join("xl.meta"))?)?.into_fileinfo(bucket, &key, version, true, false, true)?;
assert_eq!(
serde_json::to_value(sealed_file_info)?,
admitted_file_info,
"admitted PUT changed FileInfo semantics"
);
for (index, baseline) in baselines.iter().enumerate() {
assert!(
census_object_version_on_disk(Path::new(&dist.cluster.nodes[index].data_dirs[0]), bucket, &key, Some(version))?
.matches_manifest(baseline)
);
assert_exact_version(&dist.client(index)?, bucket, &key, version, &payload).await?;
}
// Stage the exact request again. A source metadata sentinel makes a missing
// admission check observable even if the existing destination later causes
// ENOTEMPTY: ordinary rename rewrites source metadata before that syscall.
let staged_data = staging.join(&data_dir);
std::fs::create_dir_all(&staged_data)?;
for entry in std::fs::read_dir(&destination)? {
let entry = entry?;
assert!(entry.file_type()?.is_file(), "unexpected nested shard fixture");
std::fs::copy(entry.path(), staged_data.join(entry.file_name()))?;
}
std::fs::write(staging.join("xl.meta"), b"closed-admission-must-preserve-this-staged-metadata")?;
assert_raw_put_closed(&put, &node.url, &node.address, &[&staging, &object]).await?;
let listed = dist
.client(0)?
.list_object_versions()
.bucket(bucket)
.prefix(&key)
.send()
.await?;
assert_eq!(listed.versions().len(), 1, "raw PUT retries created another version");
assert_eq!(listed.versions()[0].version_id(), Some(version));
assert!(listed.delete_markers().is_empty());
confirm_seal(&dist, &user, &request_body, &target).await?;
assert!(
publication_snapshot(&published_paths)? == sealed_snapshot,
"published files changed after the first successful physical-drain confirmation"
);
Ok(())
}
@@ -0,0 +1,218 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{DistCluster, DistLayout, TestResult, payload_for, wait_until};
use crate::chaos::census_object_version_on_disk;
use crate::common::init_logging;
use crate::storage_api::artifact_rpc::{TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest};
use http::StatusCode;
use rustfs_filemeta::FileMeta;
use rustfs_protos::proto_gen::node_service::{
ArtifactRepairRenameDataRequest, RenameDataRequest, node_service_client::NodeServiceClient,
};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
use tonic::Request;
use uuid::Uuid;
const REPAIR_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-repairs";
pub(super) struct PhysicalBarrier(PathBuf);
impl PhysicalBarrier {
pub(super) fn arm(disk: &Path, destination: &Path) -> TestResult<Self> {
let path = disk.join(".rustfs.sys/e2e-physical-rename-barrier");
std::fs::write(&path, destination.to_str().ok_or("non-UTF8 fixture destination")?)?;
Ok(Self(path))
}
pub(super) async fn observed(&self, stage: &str) -> TestResult {
wait_until(Duration::from_secs(15), || async { Ok(self.0.with_extension(stage).is_file()) }, stage).await
}
pub(super) fn release(&self) -> TestResult {
std::fs::write(self.0.with_extension("release"), b"release physical worker")?;
Ok(())
}
}
impl Drop for PhysicalBarrier {
fn drop(&mut self) {
// Release even on assertion failure; the worker also has its own deadline.
let _ = self.release();
let _ = std::fs::remove_file(&self.0);
}
}
#[tokio::test]
async fn four_node_artifact_repair_seal_waits_for_disconnected_physical_tail() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
// Match the other raw internode signature tests sharing the OnceLock.
let _ = rustfs_credentials::set_global_rpc_secret("rustfs-internode-signature-e2e-secret".to_owned());
let rpc_secret = rustfs_credentials::try_get_rpc_token()?;
let dist = DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_RPC_SECRET", &rpc_secret)]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let key = format!("{prefix}detached-rename.bin");
let body = payload_for(&key, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(body.clone().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("managed PUT omitted version ID")?;
let request_body = json!({"version": 1, "target": target}).to_string();
confirm_seal(&dist, &user, &request_body, &target).await?;
let node = &dist.cluster.nodes[3];
let disk = PathBuf::from(&node.data_dirs[0]).canonicalize()?;
let object = disk.join(bucket).join(&key);
let metadata = FileMeta::load(&std::fs::read(object.join("xl.meta"))?)?;
let mut fi = metadata.into_fileinfo(bucket, &key, version, true, false, true)?;
fi.set_healing();
let data_dir = fi
.data_dir
.ok_or("physical-tail fixture requires external shard files")?
.to_string();
let baseline = census_object_version_on_disk(&disk, bucket, &key, Some(version))?;
assert!(baseline.is_complete() && !baseline.expected_part_numbers.is_empty());
let source = Uuid::new_v4().to_string();
let staging = disk.join(".rustfs.sys/tmp").join(&source);
std::fs::create_dir_all(&staging)?;
let destination = object.join(&data_dir);
std::fs::rename(&destination, staging.join(&data_dir))?;
let barrier = PhysicalBarrier::arm(&disk, &destination)?;
let format: Value = serde_json::from_slice(&std::fs::read(disk.join(".rustfs.sys/format.json"))?)?;
let disk_id = Uuid::parse_str(format["xl"]["this"].as_str().ok_or("disk format omitted xl.this")?)?;
let incarnation = Uuid::parse_str(target["bucket_incarnation"].as_str().ok_or("target omitted incarnation")?)?;
let repair = ArtifactRepairRenameDataRequest {
rename: Some(RenameDataRequest {
disk: disk_id.to_string(),
src_volume: ".rustfs.sys/tmp".to_owned(),
src_path: source,
dst_volume: bucket.to_owned(),
dst_path: key.clone(),
file_info: serde_json::to_string(&fi)?,
file_info_bin: Default::default(),
scanner_publication_lease_token: Default::default(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
}),
expected_disk_id: disk_id.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_artifact_repair_rename_data_request_body(&repair)?;
let mut request = Request::new(repair);
request.set_timeout(Duration::from_secs(15));
set_tonic_canonical_body_digest(&mut request, &canonical)?;
let digest = request
.metadata()
.get("x-rustfs-content-sha256")
.ok_or("missing canonical digest")?
.to_str()?
.to_owned();
let headers = gen_tonic_signature_headers(
&node.address,
TONIC_RPC_PREFIX.trim_start_matches('/'),
"ArtifactRepairRenameData",
Some(&digest),
)?;
request.metadata_mut().as_mut().extend(headers);
// An independent channel and direct receiver RPC take no coordinator attempt
// lock. Dropping this task closes the client stream while rename is paused.
let url = node.url.clone();
let mut rpc = tokio::spawn(async move {
timeout(Duration::from_secs(30), async move {
let mut client = NodeServiceClient::connect(url).await?;
client
.artifact_repair_rename_data(request)
.await
.map_err(|error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(error) })
})
.await
});
tokio::select! {
result = &mut rpc => {
let response = result???.into_inner();
panic!(
"repair receiver completed before the physical barrier: success={}, error={:?}",
response.success, response.error
);
}
arrived = barrier.observed("arrived") => arrived?,
}
assert!(!destination.exists(), "physical publication ran before its barrier");
rpc.abort();
assert!(rpc.await.expect_err("cancel the disconnected RPC waiter").is_cancelled());
// This acknowledgement is emitted by the receiver's dropped async waiter,
// not by the client and not by a delayed response.
barrier.observed("waiter-dropped").await?;
let seal = user.request(&dist, 0, REPAIR_SEAL_ROUTE, request_body.clone());
tokio::pin!(seal);
let closed = wait_until(
Duration::from_secs(15),
|| async {
Ok(walkdir::WalkDir::new(disk.join(".rustfs-artifact-repair-seals"))
.min_depth(2)
.max_depth(2)
.into_iter()
.filter_map(Result::ok)
.any(|entry| entry.file_name() == "closed"))
},
"receiver persisted repair closure while its physical worker remains paused",
);
tokio::pin!(closed);
tokio::select! {
result = &mut seal => {
let (status, response) = result?;
panic!("repair seal returned before the blocked receiver closed admission: {status} {response}");
}
result = &mut closed => result?,
}
// Cover a complete receiver drain timeout, so early physical-owner release
// causes a successful response here and fails this runtime assertion.
let (status, response) = timeout(Duration::from_secs(40), &mut seal).await??;
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"seal confirmed an unfinished physical tail: {response}"
);
assert!(response.contains("artifact operation unconfirmed"));
assert!(!destination.exists(), "worker escaped the physical barrier");
barrier.release()?;
wait_until(
Duration::from_secs(15),
|| async { Ok(destination.join("part.1").is_file()) },
"detached physical rename completes after release",
)
.await?;
wait_until(
Duration::from_secs(45),
|| async {
let (status, response) = user.request(&dist, 0, REPAIR_SEAL_ROUTE, request_body.clone()).await?;
if status == StatusCode::SERVICE_UNAVAILABLE && response.contains("artifact operation unconfirmed") {
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "repair seal did not confirm after physical drain: {response}");
assert_eq!(
serde_json::from_str::<Value>(&response)?,
json!({"version": 1, "target": target, "attempt_repairs_sealed": true})
);
Ok(true)
},
"exact retry confirms all four receivers after physical drain",
)
.await?;
assert!(census_object_version_on_disk(&disk, bucket, &key, Some(version))?.matches_manifest(&baseline));
assert_exact_version(&dist.client(0)?, bucket, &key, version, &body).await?;
Ok(())
}
@@ -0,0 +1,327 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::artifact_seal_test::{ArtifactAdmin, assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, payload_for, unique_bucket, wait_for_ready, wait_until,
};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post, start_root_heal_when_control_ready};
use crate::common::init_logging;
use http::StatusCode;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
const REPAIR_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-repairs";
const HEAL_BODY: &str =
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
const HEAL_BUDGET: Duration = Duration::from_secs(120);
pub(super) struct RepairObject {
pub(super) key: String,
pub(super) version: String,
pub(super) body: Vec<u8>,
manifests: Vec<VersionShardCensus>,
}
fn roots(dist: &DistCluster) -> Vec<PathBuf> {
dist.cluster
.nodes
.iter()
.map(|node| PathBuf::from(&node.data_dirs[0]))
.collect()
}
impl RepairObject {
pub(super) async fn put(dist: &DistCluster, bucket: &str, key: String) -> TestResult<Self> {
let body = payload_for(&key, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(body.clone().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("managed PUT omitted version ID")?.to_owned();
let manifests = roots(dist)
.iter()
.map(|disk| census_object_version_on_disk(disk, bucket, &key, Some(&version)))
.collect::<TestResult<Vec<_>>>()?;
for manifest in &manifests {
assert!(manifest.is_complete(), "repair needs a complete baseline: {manifest:?}");
assert!(!manifest.expected_part_numbers.is_empty(), "repair must use actual shard files");
}
Ok(Self {
key,
version,
body,
manifests,
})
}
pub(super) fn part(&self, disk: &Path, bucket: &str, node: usize) -> TestResult<PathBuf> {
let data_dir = self.manifests[node]
.data_dir
.as_ref()
.ok_or("external shard omitted data directory")?;
Ok(disk.join(bucket).join(&self.key).join(data_dir).join("part.1"))
}
pub(super) async fn assert_repaired(&self, dist: &DistCluster, bucket: &str) -> TestResult {
wait_until(
HEAL_BUDGET,
|| async {
for (disk, baseline) in roots(dist).iter().zip(&self.manifests) {
if !census_object_version_on_disk(disk, bucket, &self.key, Some(&self.version))?.matches_manifest(baseline) {
return Ok(false);
}
}
Ok(true)
},
"every configured receiver restored the exact version and original shard fingerprints",
)
.await?;
for node in 0..dist.cluster.nodes.len() {
assert_exact_version(&dist.client(node)?, bucket, &self.key, &self.version, &self.body).await?;
}
Ok(())
}
}
pub(super) async fn start_heal(dist: &DistCluster, bucket: &str, prefix: &str, remove: bool) -> TestResult<String> {
let url = format!("{}/rustfs/admin/v3/heal/{bucket}/{prefix}?forceStart=true", dist.cluster.nodes[0].url);
let body = if remove {
HEAL_BODY.replace("\"remove\":false", "\"remove\":true")
} else {
HEAL_BODY.to_owned()
};
let response = timeout(
HEAL_BUDGET,
start_root_heal_when_control_ready(&url, &body, &dist.cluster.access_key, &dist.cluster.secret_key),
)
.await??;
let response: Value = serde_json::from_str(&response)?;
Ok(response["clientToken"]
.as_str()
.ok_or("heal omitted its task token")?
.to_owned())
}
pub(super) async fn finish_heal(dist: &DistCluster, token: &str, rejected_key: Option<&str>) -> TestResult {
let url = format!("{}/rustfs/admin/v3/heal/?clientToken={token}", dist.cluster.nodes[0].url);
wait_until(
HEAL_BUDGET,
|| async {
let response = signed_admin_post(&url, None, &dist.cluster.access_key, &dist.cluster.secret_key).await?;
let status: Value = serde_json::from_str(&response)?;
if status["summary"] == "running" {
return Ok(false);
}
if let Some(key) = rejected_key {
assert_eq!(status["summary"], "stopped", "closed repair must report failure: {status}");
assert!(
status["outcome"]["counters"]["failed"]
.as_u64()
.is_some_and(|count| count > 0)
);
assert!(
status["outcome"]["objects"].as_array().is_some_and(|objects| objects
.iter()
.any(|object| { object["identity"]["object"] == key && object["disposition"]["state"] == "failed" })),
"heal must actually attempt the rejected object: {status}"
);
} else {
assert_eq!(status["summary"], "finished", "heal did not complete: {status}");
}
Ok(true)
},
"exact admin heal task reached its expected terminal outcome",
)
.await
}
async fn repair_seal(dist: &DistCluster, user: &ArtifactAdmin, target: &Value, confirmed: bool) -> TestResult {
wait_until(
Duration::from_secs(45),
|| async {
let request = json!({"version": 1, "target": target}).to_string();
let (status, body) = user.request(dist, 0, REPAIR_SEAL_ROUTE, request).await?;
if !confirmed {
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "offline receiver repair seal: {body}");
assert!(body.contains("<Code>ServiceUnavailable</Code>"));
assert!(body.contains("artifact operation unconfirmed"));
return Ok(true);
}
if status == StatusCode::SERVICE_UNAVAILABLE
&& body.contains("<Code>ServiceUnavailable</Code>")
&& body.contains("<Message>artifact operation unconfirmed; retry the same identity</Message>")
{
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "repair seal failed: {body}");
assert_eq!(
serde_json::from_str::<Value>(&body)?,
json!({"version": 1, "target": target, "attempt_repairs_sealed": true})
);
Ok(true)
},
"repair seal confirms every configured receiver",
)
.await
}
#[tokio::test]
async fn four_node_artifact_shard_and_minority_metadata_repair_after_put_seal() -> TestResult {
init_logging();
// Sub-quorum metadata recovery supports genuine legacy shards only.
let mut dist =
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let shard = RepairObject::put(&dist, bucket, format!("{prefix}shard.bin")).await?;
let metadata = RepairObject::put(&dist, bucket, format!("{prefix}metadata.bin")).await?;
let later = dist
.client(2)?
.put_object()
.bucket(bucket)
.key(&shard.key)
.body(b"newer-version".to_vec().into())
.send()
.await?;
let later_version = later.version_id().ok_or("second PUT omitted version ID")?.to_owned();
assert_ne!(shard.version, later_version);
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
dist.cluster.stop();
let disks = roots(&dist);
std::fs::remove_file(shard.part(&disks[3], bucket, 3)?)?;
for disk in &disks[1..] {
std::fs::remove_file(disk.join(bucket).join(&metadata.key).join("xl.meta"))?;
}
assert!(!census_object_version_on_disk(&disks[3], bucket, &shard.key, Some(&shard.version))?.is_complete());
assert!(disks[0].join(bucket).join(&metadata.key).join("xl.meta").is_file());
assert!(
disks[1..]
.iter()
.all(|disk| !disk.join(bucket).join(&metadata.key).join("xl.meta").exists())
);
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
let token = start_heal(&dist, bucket, prefix, false).await?;
shard.assert_repaired(&dist, bucket).await?;
metadata.assert_repaired(&dist, bucket).await?;
finish_heal(&dist, &token, None).await?;
assert_exact_version(&dist.client(3)?, bucket, &shard.key, &later_version, b"newer-version").await?;
let listed = dist
.client(0)?
.list_object_versions()
.bucket(bucket)
.prefix(prefix)
.send()
.await?;
assert_eq!(listed.versions().len(), 3, "repair must not publish replacement versions");
assert!(listed.delete_markers().is_empty());
Ok(())
}
#[tokio::test]
async fn four_node_artifact_repair_seal_denies_shard_and_metadata_repair_after_restart() -> TestResult {
init_logging();
let mut dist =
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let shard = RepairObject::put(&dist, bucket, format!("{prefix}shard.bin")).await?;
let metadata = RepairObject::put(&dist, bucket, format!("{prefix}metadata.bin")).await?;
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
repair_seal(&dist, &user, &target, true).await?;
let ordinary_bucket = unique_bucket("repair-control");
dist.create_bucket(&ordinary_bucket).await?;
enable_versioning(&dist.client(0)?, &ordinary_bucket).await?;
let ordinary = RepairObject::put(&dist, &ordinary_bucket, "ordinary.bin".to_owned()).await?;
let disks = roots(&dist);
let part = shard.part(&disks[3], bucket, 3)?;
std::fs::remove_file(&part)?;
for disk in &disks[1..] {
std::fs::remove_file(disk.join(bucket).join(&metadata.key).join("xl.meta"))?;
}
for restarted in [false, true] {
if restarted {
dist.restart_with_current_binary().await?;
}
// The same receivers must still execute an ordinary bucket repair.
std::fs::remove_file(ordinary.part(&disks[3], &ordinary_bucket, 3)?)?;
let control = start_heal(&dist, &ordinary_bucket, &ordinary.key, false).await?;
ordinary.assert_repaired(&dist, &ordinary_bucket).await?;
finish_heal(&dist, &control, None).await?;
for object in [&shard, &metadata] {
let token = start_heal(&dist, bucket, &object.key, false).await?;
finish_heal(&dist, &token, Some(&object.key)).await?;
}
assert!(!part.exists(), "sealed repair recreated the missing shard");
assert!(
disks[1..]
.iter()
.all(|disk| !disk.join(bucket).join(&metadata.key).join("xl.meta").exists())
);
assert_exact_version(&dist.client(2)?, bucket, &shard.key, &shard.version, &shard.body).await?;
}
Ok(())
}
#[tokio::test]
async fn four_node_artifact_repair_seal_requires_every_receiver_on_exact_retry() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let request = json!({"version": 1, "target": target}).to_string();
confirm_seal(&dist, &user, &request, &target).await?;
// Repeat after global confirmation: its persisted receipt cannot replace a
// fresh acknowledgement from an offline configured receiver.
for _ in 0..2 {
dist.cluster.stop_node(3)?;
repair_seal(&dist, &user, &target, false).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
repair_seal(&dist, &user, &target, true).await?;
}
Ok(())
}
#[tokio::test]
async fn four_node_artifact_dangling_repair_preserves_surviving_metadata() -> TestResult {
init_logging();
let mut dist =
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let object = RepairObject::put(&dist, bucket, format!("{prefix}dangling.bin")).await?;
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
dist.cluster.stop();
let disks = roots(&dist);
let survivor = disks[0].join(bucket).join(&object.key).join("xl.meta");
let before = std::fs::read(&survivor)?;
for (node, disk) in disks.iter().enumerate() {
std::fs::remove_file(object.part(disk, bucket, node)?)?;
if node != 0 {
std::fs::remove_file(disk.join(bucket).join(&object.key).join("xl.meta"))?;
}
}
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
let token = start_heal(&dist, bucket, &object.key, true).await?;
finish_heal(&dist, &token, Some(&object.key)).await?;
assert_eq!(
std::fs::read(survivor)?,
before,
"distributed repair must not authorize managed dangling deletion"
);
Ok(())
}
@@ -0,0 +1,347 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, put_object, s3_probe_client, unique_bucket, wait_for_ready,
wait_until,
};
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, admin_request, init_logging,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use http::{Method, StatusCode};
use serde_json::{Value, json};
use std::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
const BUCKET_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/buckets";
const INSPECT_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/buckets/inspect";
const SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-attempt";
const ADMIN_BUDGET: Duration = Duration::from_secs(45);
pub(super) struct ArtifactAdmin {
access_key: String,
secret_key: String,
}
impl ArtifactAdmin {
pub(super) async fn request(
&self,
dist: &DistCluster,
node: usize,
route: &str,
body: String,
) -> TestResult<(StatusCode, String)> {
timeout(
ADMIN_BUDGET,
admin_request(
&dist.cluster.nodes[node].url,
Method::POST,
route,
Some(body),
&self.access_key,
&self.secret_key,
),
)
.await?
}
}
async fn create_artifact_admin(dist: &DistCluster, bucket: &str, prefix: &str) -> TestResult<ArtifactAdmin> {
let user = ArtifactAdmin {
access_key: format!("artifact{}", &Uuid::new_v4().simple().to_string()[..12]),
secret_key: Uuid::new_v4().simple().to_string(),
};
let policy_name = format!("artifact{}", &Uuid::new_v4().simple().to_string()[..12]);
let policy = json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["admin:CreatePluginArtifactBucket", "admin:GetPluginArtifactBucket"],
"Resource": format!("arn:aws:s3:::{bucket}"),
},
{
"Effect": "Allow",
"Action": ["admin:SealPluginArtifactAttempt", "admin:SealPluginArtifactRepairs", "admin:SealPluginArtifactWrites"],
"Resource": format!("arn:aws:s3:::{bucket}/{}", prefix.trim_end_matches('/')),
},
],
})
.to_string();
let url = &dist.cluster.nodes[0].url;
let access = &dist.cluster.access_key;
let secret = &dist.cluster.secret_key;
admin_create_user_via(AdminTransport::Signed, url, access, secret, &user.access_key, &user.secret_key).await?;
admin_add_canned_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &policy).await?;
admin_attach_user_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &user.access_key).await?;
Ok(user)
}
pub(super) async fn create_artifact_attempt(dist: &DistCluster) -> TestResult<(Value, ArtifactAdmin)> {
let bucket = unique_bucket("artifact");
let prefix = format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
let user = create_artifact_admin(dist, &bucket, &prefix).await?;
let body = json!({"version": 1, "bucket": bucket, "base_prefix": "outputs/"}).to_string();
let (status, response) = user.request(dist, 0, BUCKET_ROUTE, body).await?;
assert_eq!(status, StatusCode::OK, "artifact bucket creation failed: {response}");
let receipt: Value = serde_json::from_str(&response)?;
assert_eq!(receipt["version"], 1);
assert_eq!(receipt["bucket"], bucket);
assert_eq!(receipt["base_prefix"], "outputs/");
assert_eq!(receipt["versioning_enabled"], true);
let incarnation = receipt["bucket_incarnation"]
.as_str()
.ok_or("artifact receipt omitted incarnation")?;
assert!(!Uuid::parse_str(incarnation)?.is_nil());
let target = json!({
"bucket": bucket,
"bucket_incarnation": incarnation,
"attempt_prefix": prefix,
});
assert_bucket_identity(dist, &user, 1, &target).await?;
Ok((target, user))
}
async fn assert_bucket_identity(dist: &DistCluster, user: &ArtifactAdmin, node: usize, target: &Value) -> TestResult {
let body = json!({"version": 1, "bucket": target["bucket"], "base_prefix": "outputs/"}).to_string();
wait_until(
ADMIN_BUDGET,
|| async {
let (status, response) = user.request(dist, node, INSPECT_ROUTE, body.clone()).await?;
// IAM peers may not have received the new user and policy yet.
// Storage failures must fail immediately, including the old gate's 503.
if matches!(status, StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED)
&& (response.contains("<Code>AccessDenied</Code>") || response.contains("<Code>InvalidAccessKeyId</Code>"))
{
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "cross-node artifact inspect failed: {response}");
assert_eq!(
serde_json::from_str::<Value>(&response)?,
json!({
"version": 1,
"bucket": target["bucket"],
"base_prefix": "outputs/",
"bucket_incarnation": target["bucket_incarnation"],
"versioning_enabled": true,
}),
"inspect must return the created managed bucket identity"
);
Ok(true)
},
"artifact IAM user and policy propagation",
)
.await
}
pub(super) async fn confirm_seal(dist: &DistCluster, user: &ArtifactAdmin, request: &str, target: &Value) -> TestResult {
wait_until(
ADMIN_BUDGET,
|| async {
let (status, response) = user.request(dist, 0, SEAL_ROUTE, request.to_owned()).await?;
// HTTP readiness does not confirm that remote disk handles have
// recovered. Retry only the API's explicit same-identity response.
if status == StatusCode::SERVICE_UNAVAILABLE
&& response.contains("<Code>ServiceUnavailable</Code>")
&& response.contains("<Message>artifact operation unconfirmed; retry the same identity</Message>")
{
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "artifact seal failed: {response}");
let receipt: Value = serde_json::from_str(&response)?;
assert_eq!(receipt, json!({"version": 1, "target": target, "ordinary_put_sealed": true}));
Ok(true)
},
"exact artifact seal confirmation after receiver recovery",
)
.await
}
async fn assert_seal_unconfirmed(dist: &DistCluster, user: &ArtifactAdmin, request: &str) -> TestResult {
let (status, body) = user.request(dist, 0, SEAL_ROUTE, request.to_owned()).await?;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "offline receiver seal response: {body}");
assert!(
body.contains("<Code>ServiceUnavailable</Code>"),
"seal failure lost its service code: {body}"
);
assert!(
body.contains("artifact operation unconfirmed"),
"seal failure lost its retry contract: {body}"
);
Ok(())
}
pub(super) async fn assert_exact_version(client: &Client, bucket: &str, key: &str, version: &str, body: &[u8]) -> TestResult {
let output = timeout(
Duration::from_secs(30),
client.get_object().bucket(bucket).key(key).version_id(version).send(),
)
.await??;
assert_eq!(output.version_id(), Some(version));
let actual = timeout(Duration::from_secs(30), output.body.collect()).await??.into_bytes();
assert_eq!(actual.as_ref(), body);
Ok(())
}
async fn assert_put_denied(client: &Client, ordinary_bucket: &str, bucket: &str, key: &str) -> TestResult {
// The same receiver must still accept a normal write before its sealed probe.
put_object(client, ordinary_bucket, key, b"ordinary-control".to_vec()).await?;
assert_object_bytes(client, ordinary_bucket, key, b"ordinary-control").await?;
let client = s3_probe_client(client);
let result = timeout(
Duration::from_secs(30),
client
.put_object()
.bucket(bucket)
.key(key)
.body(b"must-not-publish".to_vec().into())
.send(),
)
.await?;
let error = result.expect_err("sealed artifact attempt accepted a new PUT");
let service = error
.as_service_error()
.ok_or("sealed PUT failed without a service rejection")?;
assert_eq!(
error.raw_response().map(|response| response.status().as_u16()),
Some(500),
"sealed PUT must return its current HTTP status"
);
assert_eq!(service.code(), Some("InternalError"), "sealed PUT must return its current service code");
// StorageError::Io is redacted at the API boundary; the sealed reason is
// not exposed. Success controls and exact inventory checks prove the effect.
assert_eq!(
service.message(),
Some("We encountered an internal error, please try again."),
"sealed PUT must retain the public redacted message"
);
Ok(())
}
#[tokio::test]
async fn four_node_artifact_put_seal_survives_full_restart() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted attempt prefix")?;
let key = format!("{prefix}result.bin");
let mut versions = Vec::new();
for (node, body) in [(1, b"first-version".as_slice()), (2, b"second-version".as_slice())] {
let client = dist.client(node)?;
let output = timeout(
Duration::from_secs(30),
client.put_object().bucket(bucket).key(&key).body(body.to_vec().into()).send(),
)
.await??;
let version = output.version_id().ok_or("artifact PUT omitted version ID")?.to_owned();
assert!(!Uuid::parse_str(&version)?.is_nil());
assert_exact_version(&dist.client(3)?, bucket, &key, &version, body).await?;
versions.push((version, body.to_vec()));
}
assert_ne!(versions[0].0, versions[1].0);
let ordinary = unique_bucket("ordinary");
dist.create_bucket(&ordinary).await?;
put_object(&dist.client(2)?, &ordinary, "normal.bin", b"before-seal".to_vec()).await?;
assert_object_bytes(&dist.client(3)?, &ordinary, "normal.bin", b"before-seal").await?;
let request = json!({"version": 1, "target": target}).to_string();
confirm_seal(&dist, &user, &request, &target).await?;
let other_prefix = format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
for phase in ["sealed", "restarted"] {
if phase == "restarted" {
dist.cluster.stop();
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
}
assert_bucket_identity(&dist, &user, 2, &target).await?;
for node in 0..dist.cluster.nodes.len() {
// A second attempt in the managed bucket must remain writable;
// a broken bucket-wide write path cannot stand in for sealing.
let client = dist.client(node)?;
let other_key = format!("{other_prefix}{phase}-{node}.bin");
let output = timeout(
Duration::from_secs(30),
client
.put_object()
.bucket(bucket)
.key(&other_key)
.body(b"other-attempt".to_vec().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("other attempt PUT omitted version ID")?;
assert!(!Uuid::parse_str(version)?.is_nil());
assert_exact_version(
&dist.client((node + 1) % dist.cluster.nodes.len())?,
bucket,
&other_key,
version,
b"other-attempt",
)
.await?;
assert_put_denied(&dist.client(node)?, &ordinary, bucket, &format!("{prefix}{phase}-{node}.bin")).await?;
for (version, body) in &versions {
assert_exact_version(&dist.client(node)?, bucket, &key, version, body).await?;
}
}
let listed = dist
.client(0)?
.list_object_versions()
.bucket(bucket)
.prefix(prefix)
.send()
.await?;
assert_eq!(listed.versions().len(), 2, "denied PUT left an artifact version: {listed:?}");
assert!(listed.delete_markers().is_empty(), "artifact seal created a delete marker");
put_object(&dist.client(1)?, &ordinary, "normal.bin", phase.as_bytes().to_vec()).await?;
assert_object_bytes(&dist.client(2)?, &ordinary, "normal.bin", phase.as_bytes()).await?;
}
Ok(())
}
#[tokio::test]
async fn four_node_artifact_seal_requires_every_configured_receiver_on_exact_retry() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted attempt prefix")?;
let key = format!("{prefix}existing.bin");
let output = dist
.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(b"existing".to_vec().into())
.send()
.await?;
let version = output.version_id().ok_or("artifact PUT omitted version ID")?.to_owned();
assert_exact_version(&dist.client(3)?, bucket, &key, &version, b"existing").await?;
let ordinary = unique_bucket("ordinary");
dist.create_bucket(&ordinary).await?;
let request = json!({"version": 1, "target": target}).to_string();
// The second iteration starts from a fully acknowledged global seal. Its
// persisted record cannot replace a fresh acknowledgement from every disk.
for _ in 0..2 {
dist.cluster.stop_node(3)?;
assert_seal_unconfirmed(&dist, &user, &request).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
confirm_seal(&dist, &user, &request, &target).await?;
}
assert_put_denied(&dist.client(3)?, &ordinary, bucket, &format!("{prefix}late.bin")).await?;
assert_exact_version(&dist.client(2)?, bucket, &key, &version, b"existing").await?;
let listed = timeout(
Duration::from_secs(30),
dist.client(0)?.list_object_versions().bucket(bucket).prefix(prefix).send(),
)
.await??;
assert_eq!(listed.versions().len(), 1, "rejected PUT added a version");
assert!(listed.delete_markers().is_empty(), "seal added a delete marker");
Ok(())
}
@@ -0,0 +1,235 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
#![cfg(unix)]
use super::artifact_delete_test::physical_snapshot;
use super::artifact_repair_test::{RepairObject, finish_heal, start_heal};
use super::artifact_seal_test::{ArtifactAdmin, assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, s3_probe_client, unique_bucket, wait_for_ready, wait_until,
};
use crate::common::init_logging;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention,
ObjectLockRetentionMode,
};
use http::StatusCode;
use serde_json::{Value, json};
use std::path::Path;
use std::time::Duration;
const WRITES_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-writes";
const INTERNAL_MESSAGE: &str = "We encountered an internal error, please try again.";
async fn seal_writes(dist: &DistCluster, user: &ArtifactAdmin, target: &Value, confirmed: bool) -> TestResult {
wait_until(Duration::from_secs(45), || async {
let (status, response) = user.request(dist, 0, WRITES_ROUTE, json!({"version":1,"target":target}).to_string()).await?;
if status == StatusCode::SERVICE_UNAVAILABLE && response.contains("artifact operation unconfirmed; retry the same identity") {
return Ok(!confirmed);
}
assert!(confirmed, "offline configured receiver produced a complete receipt: {status} {response}");
assert_eq!(status, StatusCode::OK, "combined writes seal: {response}");
assert_eq!(serde_json::from_str::<Value>(&response)?, json!({"version":1,"target":target,"ordinary_put_sealed":true,"attempt_repairs_sealed":true,"all_writes_sealed":true}));
Ok(true)
}, "combined seal acknowledges both domains on every receiver").await
}
async fn assert_put_closed(dist: &DistCluster, bucket: &str, key: &str) -> TestResult {
let before = physical_snapshot(dist, bucket)?;
let client = s3_probe_client(&dist.client(2)?);
let error = client
.put_object()
.bucket(bucket)
.key(key)
.body(b"must-not-publish".to_vec().into())
.send()
.await
.expect_err("combined seal admitted a new ordinary PUT");
let service = error.as_service_error().ok_or("PUT was not a service rejection")?;
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some(INTERNAL_MESSAGE));
assert!(
physical_snapshot(dist, bucket)? == before,
"rejected PUT changed the managed physical namespace"
);
Ok(())
}
async fn lock_metadata_entrypoints(dist: &DistCluster, managed: &Value, object: &RepairObject, ordinary: &str) -> TestResult {
let bucket = managed["bucket"].as_str().ok_or("bucket identity")?;
let client = s3_probe_client(&dist.client(0)?);
for bucket in [bucket, ordinary] {
client
.put_object_lock_configuration()
.bucket(bucket)
.object_lock_configuration(
ObjectLockConfiguration::builder()
.object_lock_enabled(ObjectLockEnabled::Enabled)
.build(),
)
.send()
.await?;
wait_until(
Duration::from_secs(30),
|| async {
for node in 0..4 {
let output = match dist.client(node)?.get_object_lock_configuration().bucket(bucket).send().await {
Ok(output) => output,
Err(error)
if error.as_service_error().and_then(ProvideErrorMetadata::code)
== Some("ObjectLockConfigurationNotFoundError") =>
{
return Ok(false);
}
Err(error) => return Err(error.into()),
};
if output
.object_lock_configuration()
.and_then(|configuration| configuration.object_lock_enabled())
!= Some(&ObjectLockEnabled::Enabled)
{
return Ok(false);
}
}
Ok(true)
},
"actual Object Lock configuration is enabled on all request nodes",
)
.await?;
}
let control = RepairObject::put(dist, ordinary, "lock-control.bin".to_owned()).await?;
let retention = ObjectLockRetention::builder()
.mode(ObjectLockRetentionMode::Governance)
.retain_until_date(aws_sdk_s3::primitives::DateTime::from_secs(chrono::Utc::now().timestamp() + 86400))
.build();
let hold = ObjectLockLegalHold::builder().status(ObjectLockLegalHoldStatus::On).build();
client
.put_object_retention()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.retention(retention.clone())
.send()
.await?;
assert_eq!(
client
.get_object_retention()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.send()
.await?
.retention()
.and_then(|value| value.mode()),
Some(&ObjectLockRetentionMode::Governance)
);
client
.put_object_legal_hold()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.legal_hold(hold.clone())
.send()
.await?;
assert_eq!(
client
.get_object_legal_hold()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.send()
.await?
.legal_hold()
.and_then(|value| value.status()),
Some(&ObjectLockLegalHoldStatus::On)
);
let before = physical_snapshot(dist, bucket)?;
let error = client
.put_object_retention()
.bucket(bucket)
.key(&object.key)
.version_id(&object.version)
.retention(retention)
.send()
.await
.expect_err("managed Retention must reach and fail its unsupported metadata write");
let service = error.as_service_error().ok_or("retention was not a service rejection")?;
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some(INTERNAL_MESSAGE));
assert!(physical_snapshot(dist, bucket)? == before, "retention changed managed metadata");
let error = client
.put_object_legal_hold()
.bucket(bucket)
.key(&object.key)
.version_id(&object.version)
.legal_hold(hold)
.send()
.await
.expect_err("managed LegalHold must reach and fail its unsupported metadata write");
let service = error.as_service_error().ok_or("legal hold was not a service rejection")?;
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some("Io error: artifact bucket does not support this write operation"));
assert!(physical_snapshot(dist, bucket)? == before, "legal hold changed managed metadata");
Ok(())
}
#[tokio::test]
async fn four_node_artifact_writes_seal_first_partial_retry_offline_and_restart() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let ordinary = unique_bucket("writes-control");
dist.create_bucket(&ordinary).await?;
enable_versioning(&dist.client(0)?, &ordinary).await?;
let (fresh, fresh_user) = create_artifact_attempt(&dist).await?;
let fresh_bucket = fresh["bucket"].as_str().ok_or("fresh bucket")?;
let fresh_prefix = fresh["attempt_prefix"].as_str().ok_or("fresh prefix")?;
let first = RepairObject::put(&dist, fresh_bucket, format!("{fresh_prefix}existing.bin")).await?;
lock_metadata_entrypoints(&dist, &fresh, &first, &ordinary).await?;
seal_writes(&dist, &fresh_user, &fresh, true).await?;
seal_writes(&dist, &fresh_user, &fresh, true).await?;
assert_put_closed(&dist, fresh_bucket, &format!("{fresh_prefix}closed.bin")).await?;
let (partial, partial_user) = create_artifact_attempt(&dist).await?;
let bucket = partial["bucket"].as_str().ok_or("partial bucket")?;
let prefix = partial["attempt_prefix"].as_str().ok_or("partial prefix")?;
let repaired = RepairObject::put(&dist, bucket, format!("{prefix}repair.bin")).await?;
// Enter the combined operation with only its ordinary domain closed.
confirm_seal(&dist, &partial_user, &json!({"version":1,"target":partial}).to_string(), &partial).await?;
let part = repaired.part(Path::new(&dist.cluster.nodes[3].data_dirs[0]), bucket, 3)?;
std::fs::remove_file(&part)?;
let token = start_heal(&dist, bucket, &repaired.key, false).await?;
repaired.assert_repaired(&dist, bucket).await?;
finish_heal(&dist, &token, None).await?;
for _ in 0..2 {
dist.cluster.stop_node(3)?;
seal_writes(&dist, &partial_user, &partial, false).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
seal_writes(&dist, &partial_user, &partial, true).await?;
}
let ordinary_object = RepairObject::put(&dist, &ordinary, "repair-control.bin".to_owned()).await?;
for restarted in [false, true] {
if restarted {
dist.restart_with_current_binary().await?;
}
seal_writes(&dist, &partial_user, &partial, true).await?;
assert_put_closed(&dist, bucket, &format!("{prefix}closed-{restarted}.bin")).await?;
if part.exists() {
std::fs::remove_file(&part)?;
}
let before = physical_snapshot(&dist, bucket)?;
let token = start_heal(&dist, bucket, &repaired.key, false).await?;
finish_heal(&dist, &token, Some(&repaired.key)).await?;
assert!(!part.exists(), "combined seal admitted a remote repair publication");
assert!(physical_snapshot(&dist, bucket)? == before, "rejected repair changed metadata or shards");
assert_exact_version(&dist.client(2)?, bucket, &repaired.key, &repaired.version, &repaired.body).await?;
let ordinary_part = ordinary_object.part(Path::new(&dist.cluster.nodes[3].data_dirs[0]), &ordinary, 3)?;
std::fs::remove_file(ordinary_part)?;
let token = start_heal(&dist, &ordinary, &ordinary_object.key, false).await?;
ordinary_object.assert_repaired(&dist, &ordinary).await?;
finish_heal(&dist, &token, None).await?;
}
Ok(())
}
+3 -132
View File
@@ -13,20 +13,18 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, get_object_bytes, payload_for, put_object,
retrying_get_equals, retrying_put, s3_probe_client, s3_probe_error_code, unique_bucket, wait_for_ready, wait_until,
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
wait_for_ready, wait_until,
};
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
use crate::common::{build_test_s3_config, init_logging};
use crate::fault_proxy::FaultMode;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Barrier, mpsc};
use tokio::time::{Instant, timeout};
use tokio::time::timeout;
#[tokio::test]
async fn kill_and_restart_node_preserves_objects() -> TestResult {
@@ -46,133 +44,6 @@ async fn kill_and_restart_node_preserves_objects() -> TestResult {
Ok(())
}
#[tokio::test]
async fn ec4_partial_node_return_restores_read_write_quorum() -> TestResult {
init_logging();
let mut dist = DistCluster::start_with_env(
DistLayout::FourByFour,
&[
("RUSTFS_ERASURE_SET_DRIVE_COUNT", "16"),
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
],
)
.await?;
let storage = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
let storage: serde_json::Value = serde_json::from_str(&storage)?;
let storage: rustfs_madmin::StorageInfo = serde_json::from_value(storage["info"].clone())?;
assert_eq!(storage.disks.len(), 16);
assert_eq!(storage.backend.total_sets, vec![1]);
assert_eq!(storage.backend.drives_per_set, vec![16]);
assert_eq!(storage.backend.standard_sc_parity, Some(4));
let bucket = unique_bucket("partial-ec4");
dist.create_bucket(&bucket).await?;
let client = s3_probe_client(&dist.client(0)?);
let baseline = payload_for("partial-recovery/baseline", 8 * 1024 * 1024);
put_object(&client, &bucket, "baseline.bin", baseline.clone()).await?;
wait_until(
Duration::from_secs(30),
|| async {
for node in &dist.cluster.nodes {
for directory in &node.data_dirs {
let census = census_object_version_on_disk(std::path::Path::new(directory), &bucket, "baseline.bin", None)?;
if !census.is_complete() {
return Ok(false);
}
assert_eq!(census.data_blocks, Some(12), "the object must require twelve data shards");
assert_eq!(census.parity_blocks, Some(4), "the object must have four parity shards");
assert!(!census.expected_part_numbers.is_empty(), "the baseline must use physical shards");
}
}
Ok(true)
},
"the baseline is complete on all sixteen EC:4 drives",
)
.await?;
dist.cluster.stop_node(2)?;
dist.cluster.stop_node(3)?;
let read_error = timeout(Duration::from_secs(60), get_object_bytes(&client, &bucket, "baseline.bin"))
.await?
.expect_err("eight unavailable drives must prevent the baseline GET");
assert!(
matches!(
s3_probe_error_code(read_error.as_ref()),
Some("SlowDown" | "SlowDownRead" | "ServiceUnavailable")
),
"quorum loss must produce an availability error: {read_error:?}"
);
assert_eq!(
read_error
.downcast_ref::<SdkError<GetObjectError>>()
.and_then(SdkError::raw_response)
.map(|response| response.status().as_u16()),
Some(503),
"read quorum loss must remain a service availability failure"
);
let write_error = timeout(Duration::from_secs(60), put_object(&client, &bucket, "unavailable.bin", baseline.clone()))
.await?
.expect_err("eight unavailable drives must prevent a successful PUT");
assert!(
matches!(s3_probe_error_code(write_error.as_ref()), Some("SlowDown" | "ServiceUnavailable")),
"quorum loss must produce an availability error: {write_error:?}"
);
assert_eq!(
write_error
.downcast_ref::<SdkError<PutObjectError>>()
.and_then(SdkError::raw_response)
.map(|response| response.status().as_u16()),
Some(503),
"write quorum loss must remain a service availability failure"
);
// Recover only four drives. The remaining node must stay stopped while
// every live coordinator serves old data and acknowledges new writes.
dist.cluster.start_node_process(2).await?;
let recovery_deadline = Instant::now() + Duration::from_secs(90);
let mut recovered_objects = Vec::new();
for node in 0..3 {
assert!(dist.cluster.nodes[3].process.is_none(), "the fourth node must remain unavailable");
let client = s3_probe_client(&dist.client(node)?);
retrying_get_equals(
&client,
&bucket,
"baseline.bin",
&baseline,
recovery_deadline.saturating_duration_since(Instant::now()),
)
.await?;
let key = format!("partial-{node}.bin");
let body = payload_for(&key, 1024 * 1024);
retrying_put(
&client,
&bucket,
&key,
body.clone(),
recovery_deadline.saturating_duration_since(Instant::now()),
)
.await?;
recovered_objects.push((key, body));
}
for node in 0..3 {
assert!(dist.cluster.nodes[3].process.is_none(), "reads must complete before all drives return");
let client = s3_probe_client(&dist.client(node)?);
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
for (key, body) in &recovered_objects {
assert_object_bytes(&client, &bucket, key, body).await?;
}
}
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
let client = s3_probe_client(&dist.client(3)?);
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
for (key, body) in &recovered_objects {
assert_object_bytes(&client, &bucket, key, body).await?;
}
Ok(())
}
#[tokio::test]
async fn full_cluster_restart_preserves_objects() -> TestResult {
init_logging();
+5 -69
View File
@@ -347,9 +347,6 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
}
pub(super) fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
if let Ok(filter) = std::env::var("RUSTFS_E2E_LOG_FILTER") {
cluster.set_env("RUST_LOG", filter);
}
if let Some(directory) = capture_dir {
std::fs::create_dir_all(directory)?;
for node_idx in 0..cluster.nodes.len() {
@@ -1181,16 +1178,11 @@ pub(crate) async fn retrying_get_equals(
)
.into()),
Err(error) => {
let retryable = match s3_probe_error_code(error.as_ref()) {
Some("SlowDown" | "ServiceUnavailable") => true,
// GET preserves this compatibility code for a lost data quorum.
Some("SlowDownRead") => error
.downcast_ref::<SdkError<GetObjectError>>()
.and_then(SdkError::raw_response)
.is_some_and(|response| response.status().as_u16() == 503),
_ => false,
};
if retryable { Ok(false) } else { Err(error) }
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
Ok(false)
} else {
Err(error)
}
}
}
},
@@ -1329,62 +1321,6 @@ mod retry_tests {
Ok(())
}
#[tokio::test(start_paused = true)]
async fn retrying_get_allows_read_quorum_recovery() -> TestResult {
use aws_sdk_s3::config::Region;
use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
use aws_smithy_types::body::SdkBody;
let endpoint = "http://read-quorum.example.invalid";
let event = |status: u16, body: &'static str| {
ReplayEvent::new(
http::Request::builder()
.method(Method::GET)
.uri(format!("{endpoint}/retry-probe/key?x-id=GetObject"))
.body(SdkBody::empty())
.expect("build replay request"),
http::Response::builder()
.status(status)
.header("content-type", "application/xml")
.header("content-length", body.len())
.body(SdkBody::from(body))
.expect("build replay response"),
)
};
for status in [503, 403, 500] {
let transport = StaticReplayClient::new(vec![
event(status, "<Error><Code>SlowDownRead</Code></Error>"),
event(200, "expected bytes"),
]);
let client = Client::from_conf(
aws_sdk_s3::Config::builder()
.behavior_version_latest()
.credentials_provider(Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "read-quorum-test"))
.region(Region::new("us-east-1"))
.endpoint_url(endpoint)
.force_path_style(true)
.retry_config(RetryConfig::disabled())
.http_client(transport.clone())
.build(),
);
let result = retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await;
if status == 503 {
result?;
assert_eq!(
transport.actual_requests().count(),
2,
"the probe must retry the read-quorum error exactly once"
);
transport.assert_requests_match(&[]);
} else {
let error = result.expect_err("the compatibility code must not hide a non-503 failure");
assert_eq!(s3_probe_error_code(error.as_ref()), Some("SlowDownRead"));
assert_eq!(transport.actual_requests().count(), 1, "non-availability errors must fail immediately");
}
}
Ok(())
}
#[tokio::test]
async fn object_polls_distinguish_committed_and_replicated_keys() -> TestResult {
let target = FakeS3Target::start().await?;
@@ -104,12 +104,7 @@ async fn verified_get(
stage = "response body";
let mut digest = Sha256::new();
while let Some(chunk) = output.body.next().await {
let chunk = chunk.inspect_err(|error| {
eprintln!(
"TLS acceptance: GET body failed: node={node}, key={}, range={:?}, concurrent={concurrent}, received={received}/{}, elapsed={:?}, error={error}",
object.key, expected.range, expected.length, started.elapsed()
);
})?;
let chunk = chunk?;
received += u64::try_from(chunk.len())?;
assert!(received <= expected.length, "node {node}: GET exceeded the requested length");
digest.update(&chunk);
+7 -3
View File
@@ -18,6 +18,13 @@
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
//! each case starts four real `rustfs` processes.
mod artifact_delete_test;
mod artifact_inventory_test;
mod artifact_put_tail_test;
mod artifact_repair_tail_test;
mod artifact_repair_test;
mod artifact_seal_test;
mod artifact_writes_test;
mod chaos_test;
mod concurrency_stability_test;
mod concurrent_data_movement_test;
@@ -28,17 +35,14 @@ mod harness;
mod heal_test;
mod internode_tls_test;
mod multipart_fence_acceptance_test;
mod multipart_rpc_delay_test;
mod object_lock_test;
mod observability_test;
mod publication_log;
mod replication_delete_marker_test;
mod replication_quota_test;
mod s3_basic_test;
mod s3_during_data_movement_test;
mod scanner_heal_topology_fixture;
mod scanner_heal_topology_test;
mod scanner_heal_topology_tier_test;
mod site_replication_test;
mod upgrade_test;
mod versioning_test;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,413 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
//! Follow the same append-only log files without rescanning previous JSON rows.
use same_file::Handle;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use std::fs::File;
use std::io::{self, Read};
use std::path::PathBuf;
// Captured RPC spans already produce valid rows above 2 MiB. Keep complete
// rows bounded independently from the much smaller selected-event history.
const MAX_LINE_BYTES: usize = 4 << 20;
const MAX_EVENTS: usize = 128;
const MAX_EVENT_BYTES: usize = 1 << 20;
fn invalid(reason: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, reason)
}
#[derive(Default, Deserialize)]
struct Selector {
#[serde(default)]
event: Value,
#[serde(default)]
op: Value,
#[serde(default)]
bucket: Value,
#[serde(default)]
object: Value,
}
impl Selector {
fn matches(&self, bucket: &str, object: &str) -> bool {
self.event.as_str() == Some("object_namespace_lock")
&& self.op.as_str() == Some("data_movement_publication")
&& self.bucket.as_str() == Some(bucket)
&& self.object.as_str() == Some(object)
}
}
#[derive(Deserialize)]
struct Probe {
#[serde(default)]
event: Value,
#[serde(default)]
op: Value,
#[serde(default)]
bucket: Value,
#[serde(default)]
object: Value,
// A present null is not a missing fields object. The old reader uses the
// root only when this key is absent, including for non-object JSON values.
#[serde(default, deserialize_with = "present_fields")]
fields: Option<Option<Selector>>,
}
fn present_fields<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<Selector>>, D::Error> {
Option::<Selector>::deserialize(d).map(Some)
}
impl Probe {
fn matches(&self, bucket: &str, object: &str) -> bool {
match &self.fields {
Some(Some(fields)) => fields.matches(bucket, object),
Some(None) => false,
None => {
self.event.as_str() == Some("object_namespace_lock")
&& self.op.as_str() == Some("data_movement_publication")
&& self.bucket.as_str() == Some(bucket)
&& self.object.as_str() == Some(object)
}
}
}
}
fn select(line: &[u8], bucket: &str, object: &str, full_decodes: &mut u64) -> io::Result<Option<Value>> {
let text = std::str::from_utf8(line).map_err(|_| invalid("invalid UTF-8 in a completed log row"))?;
// Skip unknown fields without allocating a Value tree for every RPC trace.
// A shape/duplicate-field error falls back to the original Value parser;
// it must not discard valid escaped strings or last-key-wins JSON.
if let Ok(probe) = serde_json::from_str::<Probe>(text)
&& !probe.matches(bucket, object)
{
return Ok(None);
}
*full_decodes += 1;
let Ok(row) = serde_json::from_str::<Value>(text) else {
return Ok(None);
};
let fields = row.get("fields").unwrap_or(&row);
if fields.get("event").and_then(Value::as_str) != Some("object_namespace_lock")
|| fields.get("op").and_then(Value::as_str) != Some("data_movement_publication")
|| fields.get("bucket").and_then(Value::as_str) != Some(bucket)
|| fields.get("object").and_then(Value::as_str) != Some(object)
{
return Ok(None);
}
let mut event = fields.clone();
event["timestamp"] = row["timestamp"].clone();
Ok(Some(event))
}
struct Tail {
path: PathBuf,
handle: Handle,
offset: u64,
pending: Vec<u8>,
}
impl Tail {
fn open(path: PathBuf) -> io::Result<Self> {
let file = File::open(&path)?;
if !file.metadata()?.is_file() {
return Err(invalid("publication log is not a regular file"));
}
Ok(Self {
path,
handle: Handle::from_file(file)?,
offset: 0,
pending: Vec::new(),
})
}
fn check_identity(&self) -> io::Result<()> {
if Handle::from_path(&self.path)? != self.handle {
return Err(invalid("publication log file was replaced"));
}
Ok(())
}
fn poll(&mut self, bytes_read: &mut u64, mut accept: impl FnMut(&[u8]) -> io::Result<()>) -> io::Result<()> {
self.check_identity()?;
let end = self.handle.as_file().metadata()?.len();
if end < self.offset {
return Err(invalid("publication log file was truncated"));
}
// A fixed high-water snapshot bounds each poll even if writers append
// faster than the reader. Bytes after it remain for the next poll.
let mut chunk = [0_u8; 64 * 1024];
while self.offset < end {
let size = usize::try_from((end - self.offset).min(64 * 1024)).expect("a log chunk fits usize");
self.handle.as_file_mut().read_exact(&mut chunk[..size])?;
self.offset += u64::try_from(size).expect("a bounded log read fits u64");
*bytes_read += u64::try_from(size).expect("a bounded log read fits u64");
let appended_at = self.pending.len();
self.pending.extend_from_slice(&chunk[..size]);
let mut start = 0;
for end in self.pending[appended_at..]
.iter()
.enumerate()
.filter_map(|(i, byte)| (*byte == b'\n').then_some(appended_at + i + 1))
{
if end - start > MAX_LINE_BYTES {
return Err(invalid("publication log row exceeds its fixed bound"));
}
accept(&self.pending[start..end])?;
start = end;
}
self.pending.drain(..start);
if self.pending.len() > MAX_LINE_BYTES {
return Err(invalid("unfinished publication log row exceeds its fixed bound"));
}
}
self.check_identity()
}
}
pub(super) struct PublicationEvents {
tails: Vec<Tail>,
bucket: String,
object: String,
events: Vec<Value>,
per_file: Vec<Vec<Value>>,
event_count: usize,
event_bytes: usize,
bytes_read: u64,
full_decodes: u64,
failed: bool,
}
impl PublicationEvents {
pub(super) fn new(paths: Vec<PathBuf>, bucket: &str, object: &str) -> io::Result<Self> {
if paths.is_empty() || paths.len() > 4 {
return Err(invalid("publication capture requires one to four log files"));
}
let tails: Vec<Tail> = paths.into_iter().map(Tail::open).collect::<io::Result<_>>()?;
let mut reader = Self {
per_file: vec![Vec::new(); tails.len()],
tails,
bucket: bucket.to_owned(),
object: object.to_owned(),
events: Vec::new(),
event_bytes: 0,
event_count: 0,
bytes_read: 0,
full_decodes: 0,
failed: false,
};
// Prime before holding remote DELETE or starting a movement attempt.
// Retain earlier matching events too: the caller still applies its
// original timestamp boundary, and evidence keeps the complete history.
reader.poll()?;
Ok(reader)
}
pub(super) fn poll(&mut self) -> io::Result<&[Value]> {
if self.failed {
return Err(invalid("publication log reader was already invalidated"));
}
let result = (|| {
for (tail, history) in self.tails.iter_mut().zip(&mut self.per_file) {
tail.poll(&mut self.bytes_read, |line| {
if let Some(event) = select(line, &self.bucket, &self.object, &mut self.full_decodes)? {
let bytes = serde_json::to_vec(&event).map_err(io::Error::other)?.len();
if self.event_count >= MAX_EVENTS || bytes > MAX_EVENT_BYTES - self.event_bytes {
return Err(invalid("publication event history exceeds its fixed bound"));
}
self.event_bytes += bytes;
self.event_count += 1;
history.push(event);
}
Ok(())
})?;
}
Ok(())
})();
// Preserve the old file-then-row order, even when a later poll appends
// to an earlier file. Only bounded selected history is copied here.
self.events = self.per_file.iter().flatten().cloned().collect();
if let Err(error) = result {
self.failed = true;
return Err(error);
}
Ok(&self.events)
}
/// Diagnostics only: after a read error this history can be incomplete.
pub(super) fn cached(&self) -> &[Value] {
&self.events
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
fn event(id: &str) -> Value {
json!({"timestamp":"2026-10-26T00:00:00Z","event":"object_namespace_lock","op":"data_movement_publication","bucket":"bucket","object":"object","lock_attempt_id":id,"state":"acquiring","extra":{"kept":[1,2]}})
}
fn append(path: &std::path::Path, raw: &[u8]) {
std::fs::OpenOptions::new()
.append(true)
.open(path)
.expect("open append log")
.write_all(raw)
.expect("append log bytes");
}
fn legacy(raw: &[u8]) -> Vec<Value> {
std::str::from_utf8(raw)
.expect("fixture UTF-8")
.lines()
.filter_map(|line| {
let row: Value = serde_json::from_str(line).ok()?;
let fields = row.get("fields").unwrap_or(&row);
if fields.get("event").and_then(Value::as_str) == Some("object_namespace_lock")
&& fields.get("op").and_then(Value::as_str) == Some("data_movement_publication")
&& fields.get("bucket").and_then(Value::as_str) == Some("bucket")
&& fields.get("object").and_then(Value::as_str) == Some("object")
{
let mut result = fields.clone();
result["timestamp"] = row["timestamp"].clone();
Some(result)
} else {
None
}
})
.collect()
}
#[test]
fn appended_events_keep_full_fields_without_history_rescans() {
let file = tempfile::NamedTempFile::new().expect("log file");
let mut raw = (json!({"message":"unrelated trace","payload":"x".repeat(4096)}).to_string() + "\n")
.repeat(64)
.into_bytes();
raw.extend_from_slice(
format!("{}\n", json!({"event":"object_namespace_lock","op":"get_object","bucket":"bucket","object":"object","spans":"x".repeat(2401832)})).as_bytes(),
);
raw.extend_from_slice(format!("{}\n", event("first")).as_bytes());
std::fs::write(file.path(), &raw).expect("seed log");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("prime log");
assert_eq!(reader.cached(), legacy(&raw));
assert_eq!(reader.full_decodes, 1);
let bytes = reader.bytes_read;
assert_eq!(reader.poll().expect("unchanged poll"), legacy(&raw));
assert_eq!(reader.bytes_read, bytes);
let next = format!("{}\n", event("second"));
append(file.path(), next.as_bytes());
raw.extend_from_slice(next.as_bytes());
assert_eq!(reader.poll().expect("new rows"), legacy(&raw));
assert_eq!(reader.bytes_read, u64::try_from(raw.len()).expect("fixture length"));
assert_eq!(reader.full_decodes, 2);
}
#[test]
fn later_appends_preserve_the_old_file_then_row_order() {
let first = tempfile::NamedTempFile::new().expect("first log");
let second = tempfile::NamedTempFile::new().expect("second log");
std::fs::write(first.path(), format!("{}\n", event("a1"))).expect("seed first");
std::fs::write(second.path(), format!("{}\n", event("b1"))).expect("seed second");
let mut reader = PublicationEvents::new(vec![first.path().to_owned(), second.path().to_owned()], "bucket", "object")
.expect("prime both");
append(first.path(), format!("{}\n", event("a2")).as_bytes());
let mut expected = legacy(&std::fs::read(first.path()).expect("first bytes"));
expected.extend(legacy(&std::fs::read(second.path()).expect("second bytes")));
assert_eq!(reader.poll().expect("append first after second was read"), expected);
}
#[test]
fn root_nested_escaped_and_duplicate_fields_match_the_old_reader() {
let file = tempfile::NamedTempFile::new().expect("log file");
let root = event("root");
let mut nested = json!({"timestamp":root["timestamp"],"fields":root});
nested["fields"]["extra"] = json!({"unicode":"é"});
let null = json!({"fields":null,"event":"object_namespace_lock","op":"data_movement_publication","bucket":"bucket","object":"object"});
let escaped = root
.to_string()
.replace("object_namespace_lock", "\\u006fbject_namespace_lock");
let duplicate = root.to_string().replacen("{", "{\"event\":\"earlier-value\",", 1);
let raw = format!("{root}\n{nested}\n{null}\n{escaped}\n{duplicate}\nnot json\n{{\"fields\":[]}}\n");
std::fs::write(file.path(), &raw).expect("mixed log");
let reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("probe and fallback");
assert_eq!(reader.cached(), legacy(raw.as_bytes()));
assert_eq!(reader.cached().len(), 4);
}
#[test]
fn incomplete_rows_and_split_utf8_wait_for_the_newline() {
let file = tempfile::NamedTempFile::new().expect("log file");
let mut row = event("partial");
row["extra"] = json!("é");
let raw = format!("{row}\n").into_bytes();
let cut = raw.iter().position(|b| *b == 0xc3).expect("UTF-8 prefix") + 1;
std::fs::write(file.path(), &raw[..cut]).expect("partial code point");
let mut reader =
PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("incomplete row is pending");
assert!(reader.cached().is_empty());
append(file.path(), &raw[cut..raw.len() - 1]);
assert!(reader.poll().expect("complete JSON without delimiter").is_empty());
append(file.path(), b"\n");
assert_eq!(reader.poll().expect("published row"), legacy(&raw));
}
#[test]
fn truncation_is_sticky_even_after_the_file_grows_again() {
let file = tempfile::NamedTempFile::new().expect("log file");
std::fs::write(file.path(), format!("{}\n", event("first"))).expect("seed log");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("prime log");
file.as_file().set_len(0).expect("truncate");
assert!(reader.poll().is_err());
append(file.path(), format!("{}\n", event("longer replacement")).as_bytes());
assert!(reader.poll().is_err());
assert_eq!(reader.cached().len(), 1);
}
#[cfg(unix)]
#[test]
fn a_replaced_path_does_not_switch_to_the_new_log() {
let dir = tempfile::tempdir().expect("log dir");
let path = dir.path().join("node.log");
std::fs::write(&path, b"").expect("log file");
let mut reader = PublicationEvents::new(vec![path.clone()], "bucket", "object").expect("open log");
std::fs::rename(&path, dir.path().join("retired.log")).expect("replace log");
std::fs::write(&path, format!("{}\n", event("other-generation"))).expect("replacement");
assert!(reader.poll().is_err());
assert!(reader.cached().is_empty());
}
#[test]
fn invalid_utf8_and_oversized_or_excessive_records_fail_closed() {
let file = tempfile::NamedTempFile::new().expect("log file");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
append(file.path(), b"\xff\n");
assert!(reader.poll().is_err());
let file = tempfile::NamedTempFile::new().expect("log file");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
append(file.path(), &vec![b'x'; MAX_LINE_BYTES + 1]);
assert!(reader.poll().is_err());
let file = tempfile::NamedTempFile::new().expect("log file");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
for _ in 0..=MAX_EVENTS {
append(file.path(), format!("{}\n", event("attempt")).as_bytes());
}
assert!(reader.poll().is_err());
assert_eq!(reader.cached().len(), MAX_EVENTS);
assert!(reader.poll().is_err());
let file = tempfile::NamedTempFile::new().expect("log file");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
let mut large = event("large");
large["extra"] = json!("x".repeat(600 * 1024));
for _ in 0..2 {
append(file.path(), format!("{large}\n").as_bytes());
}
assert!(reader.poll().is_err());
assert_eq!(reader.cached().len(), 1);
assert!(reader.poll().is_err());
}
}
@@ -216,10 +216,6 @@ pub(crate) struct TopologyRun {
impl TopologyRun {
pub(crate) async fn start(label: &str) -> TestResult<Self> {
Self::start_with_env(label, &[]).await
}
pub(crate) async fn start_with_env(label: &str, extra_env: &[(&str, &str)]) -> TestResult<Self> {
let run_name = format!("scanner-heal-topology-{label}-{}", Uuid::new_v4().simple());
// CI uploads RUSTFS_E2E_LOG_DIR; keep node logs, barrier observations
// and evidence there so a failed lane can be diagnosed.
@@ -240,7 +236,6 @@ impl TopologyRun {
let mut env: Vec<(&str, &str)> = TOPOLOGY_ENV.to_vec();
env.extend_from_slice(FAST_DATA_USAGE_SCANNER_ENV);
env.push((MOVEMENT_BARRIER_DIR_ENV, movement_dir_value.as_str()));
env.extend_from_slice(extra_env);
let mut dist = DistCluster::new_stopped_with_env(DistLayout::SingleNodeFourDrive, &env).await?;
dist.capture_logs_under(&log_dir)?;
dist.cluster.start().await?;
@@ -334,11 +329,6 @@ impl FileBarrier {
&self.nonce
}
/// Keep the previous phase's release handle while arming a fresh nonce.
pub(crate) fn next_phase(&self) -> Self {
Self::new(self.dir.clone())
}
fn write_atomic(&self, name: &str, body: &[u8]) -> TestResult {
let tmp = self.dir.join(format!(".{name}.{}.tmp", Uuid::new_v4().simple()));
std::fs::write(&tmp, body)?;
@@ -370,10 +360,6 @@ impl FileBarrier {
self.write_atomic(&format!("release.{}", self.nonce), b"io_error")
}
pub(crate) fn release_movement(&self) -> TestResult {
self.write_atomic(&format!("release.{}", self.nonce), b"continue")
}
pub(crate) fn reached_observations(&self) -> TestResult<Vec<Value>> {
let prefix = format!("reached.{}.", self.nonce);
let mut observations = Vec::new();
@@ -528,7 +528,7 @@ async fn scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_w
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the killed leader resumed decommission";
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
let outcome = settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
@@ -1,599 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! G-TIER: real mover processes contend with a held remote DELETE while a
//! free version owns the old remote tuple beside a live transitioned version.
//! The remote is the HTTP fake S3 target, not a provider interoperability test.
use super::harness::{
DECOMMISSION_POOL_ID, DRIVES_PER_NODE, TestResult, cluster_admin_ok, decommission_status_json, enable_versioning,
rebalance_status_json, sha256_hex, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
wait_for_rebalance_complete,
};
use super::publication_log::PublicationEvents;
use super::scanner_heal_topology_fixture::{
MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL, STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP,
TopologyRun, assert_bucket_durability_strict, assert_process_durability_strict, require_test_hooks,
};
use crate::common::init_logging;
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation, RequestRecord};
use crate::storage_api::OBJECT_LOCK_TRACE_FILTER;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketLifecycleConfiguration, ExpirationStatus, LifecycleRule, LifecycleRuleFilter, Transition, TransitionStorageClass,
};
use http::Method;
use rustfs_filemeta::{FileMeta, TransitionVersionState};
use rustfs_utils::http::{SUFFIX_TRANSITION_TIER_DESTINATION_ID, get_consistent_str};
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeSet;
use std::path::Path;
use std::time::Duration;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::time::{Instant, sleep, timeout, timeout_at};
const TIER: &str = "TOPOLOGYCOLD";
const KEY: &str = "000-tier-owner.bin";
const TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
// Free-version cleanup holds its locks for at most 30 s during remote DELETE.
// Fail before that deadline instead of confusing a retry with the held attempt.
const DELETE_OVERLAP_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct RemoteTuple {
bucket: String,
key: String,
version: String,
tier: String,
destination: String,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
struct Owner {
pool: usize,
drive: usize,
version: String,
free: bool,
remote: RemoteTuple,
}
fn owners(run: &TopologyRun, bucket: &str, remote_bucket: &str) -> TestResult<Vec<Owner>> {
let mut owners = Vec::new();
for node in &run.dist.cluster.nodes {
for (drive, root) in node.data_dirs.iter().enumerate() {
let bytes = match std::fs::read(Path::new(root).join(bucket).join(KEY).join("xl.meta")) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
for version in FileMeta::load(&bytes)?.get_file_info_versions(bucket, KEY, true)?.versions {
if version.transition_status != "complete" && !version.tier_free_version() {
continue;
}
assert_eq!(version.transition_version_state, TransitionVersionState::Exact);
owners.push(Owner {
pool: node.pool_idx,
drive,
version: version.version_id.ok_or("tier owner omitted its local version")?.to_string(),
free: version.tier_free_version(),
remote: RemoteTuple {
bucket: remote_bucket.to_string(),
key: version.transitioned_objname.clone(),
version: version
.transition_version
.clone()
.ok_or("tier owner omitted its remote version")?,
tier: version.transition_tier.clone(),
destination: get_consistent_str(&version.metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID)
.ok_or("tier owner omitted its destination identity")?
.to_string(),
},
});
}
}
}
Ok(owners)
}
fn assert_remote(target: &FakeS3Target, remote: &RemoteTuple, expected: &[u8]) -> TestResult {
assert_eq!(target.stored_versions(&remote.bucket, &remote.key), vec![(remote.version.clone(), false)]);
let (body, metadata) = target
.stored_object(&remote.bucket, &remote.key)
.ok_or("remote bytes are missing")?;
assert_eq!(body.as_ref(), expected, "remote bytes changed for {remote:?}");
assert_eq!(
get_consistent_str(&metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID),
Some(remote.destination.as_str())
);
assert_eq!(remote.tier, TIER);
Ok(())
}
async fn transition_version(
run: &TopologyRun,
target: &FakeS3Target,
bucket: &str,
remote_bucket: &str,
body: &[u8],
) -> TestResult<(String, RemoteTuple)> {
let client = run.dist.client(0)?;
let put = client
.put_object()
.bucket(bucket)
.key(KEY)
.body(ByteStream::from(body.to_vec()))
.send()
.await?;
let version = put.version_id().ok_or("versioned PUT omitted its version")?.to_string();
cluster_admin_ok(
&run.dist.cluster,
Method::POST,
&format!("/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&prefix={KEY}&tier={TIER}&dryRun=false"),
None,
)
.await?;
let deadline = Instant::now() + TRANSITION_TIMEOUT;
loop {
let current = owners(run, bucket, remote_bucket)?;
let shards: Vec<_> = current
.iter()
.filter(|owner| owner.version == version && !owner.free)
.collect();
if let Some(first) = shards.first()
&& shards.len() == DRIVES_PER_NODE
&& shards
.iter()
.all(|owner| owner.pool == SEED_POOL && owner.remote == first.remote)
{
let head = client
.head_object()
.bucket(bucket)
.key(KEY)
.version_id(&version)
.send()
.await?;
assert_eq!(head.storage_class().map(|class| class.as_str()), Some(TIER));
assert_remote(target, &first.remote, body)?;
return Ok((version, first.remote.clone()));
}
if Instant::now() >= deadline {
return Err(format!("transition of {bucket}/{KEY}@{version} did not reach every seed drive: {current:?}").into());
}
sleep(Duration::from_millis(200)).await;
}
}
fn event_time(event: &Value) -> TestResult<OffsetDateTime> {
Ok(OffsetDateTime::parse(
event["timestamp"].as_str().ok_or("lock event omitted its timestamp")?,
&Rfc3339,
)?)
}
async fn pending_publication(events: &mut PublicationEvents, started: OffsetDateTime) -> TestResult<Value> {
loop {
let observed = events.poll()?;
for event in observed {
if event_time(event)? < started {
continue;
}
assert_ne!(
event["state"], "acquired",
"mover acquired its publication lock while remote DELETE was held"
);
if event["state"] == "acquiring"
&& !observed
.iter()
.any(|later| later["lock_attempt_id"] == event["lock_attempt_id"] && later["state"] != "acquiring")
{
assert!(event["lock_attempt_id"].as_str().is_some_and(|id| !id.is_empty()));
return Ok(event.clone());
}
}
sleep(Duration::from_millis(50)).await;
}
}
async fn acquired_publication(events: &mut PublicationEvents, pending: &Value) -> TestResult<Value> {
loop {
for event in events.poll()? {
if event["lock_attempt_id"] != pending["lock_attempt_id"] {
continue;
}
match event["state"].as_str() {
Some("acquired") => return Ok(event.clone()),
Some("failed" | "cancelled") => return Err(format!("held publication attempt did not acquire: {event}").into()),
_ => {}
}
}
sleep(Duration::from_millis(50)).await;
}
}
fn deletes(target: &FakeS3Target, remote: &RemoteTuple) -> Vec<RequestRecord> {
target
.requests()
.into_iter()
.filter(|request| {
request.operation == Operation::DeleteObject
&& request.bucket == remote.bucket
&& request.key.as_deref() == Some(remote.key.as_str())
})
.collect()
}
async fn verify_live(run: &TopologyRun, bucket: &str, live_version: &str, old_version: &str, body: &[u8]) -> TestResult {
for client in run.dist.clients()? {
let got = client
.get_object()
.bucket(bucket)
.key(KEY)
.version_id(live_version)
.send()
.await?;
assert_eq!(got.body.collect().await?.into_bytes().as_ref(), body);
let latest = client.head_object().bucket(bucket).key(KEY).send().await?;
assert_eq!(latest.version_id(), Some(live_version));
assert_eq!(latest.storage_class().map(|class| class.as_str()), Some(TIER));
let deleted = client
.get_object()
.bucket(bucket)
.key(KEY)
.version_id(old_version)
.send()
.await;
let error = deleted.expect_err("deleted source version must not be visible");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(404));
let listed = client.list_object_versions().bucket(bucket).prefix(KEY).send().await?;
assert_eq!(listed.versions().len(), 1, "free owners must not appear as object versions");
assert_eq!(listed.versions()[0].version_id(), Some(live_version));
assert!(
listed.delete_markers().is_empty(),
"moving the free owner must not create a delete marker"
);
}
Ok(())
}
async fn tier_delete_during_movement(decommission: bool) -> TestResult {
init_logging();
require_test_hooks();
let operation = if decommission { "decommission" } else { "rebalance" };
let target = FakeS3Target::start().await?;
let remote_bucket = unique_bucket("sht-cold");
target.create_bucket(&remote_bucket);
let mut run = TopologyRun::start_with_env(
&format!("tier-{operation}"),
&[
("RUSTFS_TIER_RUSTFS_ALLOW_LOOPBACK_ENDPOINT", "true"),
("RUSTFS_OBJECT_LOCK_DIAG_ENABLE", "true"),
("RUST_LOG", OBJECT_LOCK_TRACE_FILTER),
],
)
.await?;
let tier = json!({
"type": "rustfs",
"rustfs": {
"name": TIER, "endpoint": target.endpoint(), "accessKey": FAKE_ACCESS_KEY,
"secretKey": FAKE_SECRET_KEY, "bucket": remote_bucket, "prefix": "",
"region": "us-east-1", "storageClass": ""
}
});
let deadline = Instant::now() + Duration::from_secs(30);
loop {
match cluster_admin_ok(&run.dist.cluster, Method::PUT, "/rustfs/admin/v3/tier", Some(tier.to_string())).await {
Ok(_) => break,
Err(_) if Instant::now() < deadline => sleep(Duration::from_millis(250)).await,
Err(error) => return Err(error),
}
}
let bucket = unique_bucket("sht-tier");
run.dist.create_bucket(&bucket).await?;
let client = run.dist.client(0)?;
enable_versioning(&client, &bucket).await?;
let rule = LifecycleRule::builder()
.id("tier-owner")
.status(ExpirationStatus::Enabled)
.filter(LifecycleRuleFilter::builder().prefix(KEY).build())
.transitions(
Transition::builder()
.days(0)
.storage_class(TransitionStorageClass::from(TIER))
.build(),
)
.build()?;
client
.put_bucket_lifecycle_configuration()
.bucket(&bucket)
.lifecycle_configuration(BucketLifecycleConfiguration::builder().rules(rule).build()?)
.send()
.await?;
let old_body = vec![0x31; 4096];
let live_body = vec![0x92; 8192];
let (old_version, old_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &old_body).await?;
let (live_version, live_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &live_body).await?;
assert_ne!(
old_remote.key, live_remote.key,
"the obsolete and live versions need distinct remote tuples"
);
// Keep the seed pool above the rebalance goal until the first key is visited.
// These local objects are outside the transition rule's prefix.
for index in 0..16 {
client
.put_object()
.bucket(&bucket)
.key(format!("zz-load/{index:02}.bin"))
.body(ByteStream::from(vec![index as u8; 1024 * 1024]))
.send()
.await?;
}
let bucket_durability = assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
run.dist.expand_to_four_pools().await?;
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
let seeded = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(seeded.len(), 2 * DRIVES_PER_NODE);
assert!(seeded.iter().all(|owner| owner.pool == SEED_POOL && !owner.free));
let mut decommission_start = Box::pin(start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID));
let mut startup = Value::Null;
if decommission {
run.movement_barrier.arm_movement(
"decommission_start",
".rustfs.sys",
"pool.bin",
STAGE_AFTER_RESPONSIBILITY_PERSISTED,
)?;
let requested_at = OffsetDateTime::now_utc();
let preparation_started = Instant::now();
let reached = tokio::select! {
reached = run.movement_barrier.wait_reached(MOVEMENT_COMPLETE_TIMEOUT) => {
reached.map_err(|error| format!(
"G-TIER decommission start preparation failed before DELETE: {error}"
))?
}
result = &mut decommission_start => {
return Err(format!(
"G-TIER decommission start preparation returned before durable-start barrier {}: {result:?}",
run.movement_barrier.nonce()
).into());
}
};
startup = json!({
"requested_at": requested_at.format(&Rfc3339)?,
"parked_at": OffsetDateTime::now_utc().format(&Rfc3339)?,
"preparation_elapsed_ms": preparation_started.elapsed().as_millis(),
"barrier": reached,
});
}
// The durable-start barrier has released the movement writer but has not
// started a worker. Install a separate release nonce before holding DELETE.
let cleanup_barrier = run.movement_barrier.next_phase();
cleanup_barrier.arm_movement(operation, &bucket, KEY, STAGE_BEFORE_SOURCE_CLEANUP)?;
let mut publication_events = PublicationEvents::new(run.node_log_paths(), &bucket, KEY)?;
let mut delete_gate = target.hold_delete_object(&remote_bucket, &old_remote.key);
// The public DELETE persists a free owner and enqueues its remote cleanup.
let client = run.dist.client(0)?;
client
.delete_object()
.bucket(&bucket)
.key(KEY)
.version_id(&old_version)
.send()
.await
.map_err(|error| format!("G-TIER {operation} public DELETE before remote gate failed: {error}; startup={startup}"))?;
timeout(TRANSITION_TIMEOUT, delete_gate.wait_until_entered())
.await
.map_err(|error| format!("G-TIER {operation} DELETE gate entry timed out: {error}; startup={startup}"))?
.map_err(|error| format!("G-TIER {operation} DELETE gate entry failed: {error}; startup={startup}"))?;
let delete_gate_entered_at = OffsetDateTime::now_utc();
let overlap_started = Instant::now();
let overlap_deadline = overlap_started + DELETE_OVERLAP_TIMEOUT;
let held = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(held.len(), 2 * DRIVES_PER_NODE);
let old_owners: Vec<_> = held.iter().filter(|owner| owner.remote == old_remote).collect();
assert_eq!(old_owners.len(), DRIVES_PER_NODE);
assert!(
old_owners
.iter()
.all(|owner| owner.pool == SEED_POOL && owner.free && owner.version != old_version)
);
assert_eq!(old_owners.iter().map(|owner| &owner.version).collect::<BTreeSet<_>>().len(), 1);
assert_eq!(
held.iter()
.filter(|owner| {
owner.pool == SEED_POOL && !owner.free && owner.version == live_version && owner.remote == live_remote
})
.count(),
DRIVES_PER_NODE
);
assert_remote(&target, &old_remote, &old_body)?;
assert_remote(&target, &live_remote, &live_body)?;
let held_deletes = deletes(&target, &old_remote);
assert!(!held_deletes.is_empty());
assert!(held_deletes.iter().all(|request| {
request.prepared_response.is_none() && request.version_id.as_deref() == Some(old_remote.version.as_str())
}));
assert!(deletes(&target, &live_remote).is_empty());
let movement_started = OffsetDateTime::now_utc();
if decommission {
startup["released_at"] = json!(movement_started.format(&Rfc3339)?);
run.movement_barrier.release_movement()?;
}
let rebalance_id = timeout_at(overlap_deadline, async {
if decommission {
decommission_start.as_mut().await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(None)
} else {
Ok(Some(start_rebalance(&run.dist.cluster).await?))
}
})
.await
.map_err(|error| {
format!("G-TIER {operation} start acknowledgment timed out within DELETE overlap: {error}; startup={startup}")
})?
.map_err(|error| {
format!("G-TIER {operation} start acknowledgment failed within DELETE overlap: {error}; startup={startup}")
})?;
drop(decommission_start);
let movement_acknowledged_at = OffsetDateTime::now_utc();
let pending = timeout_at(overlap_deadline, pending_publication(&mut publication_events, movement_started))
.await
.map_err(|error| format!(
"G-TIER {operation} publication pending timed out within DELETE overlap: {error}; startup={startup}; lock_events={:?}; cleanup_barrier={:?}",
publication_events.cached(), cleanup_barrier.reached_observations()
))?
.map_err(|error| format!("G-TIER {operation} publication pending failed within DELETE overlap: {error}"))?;
// Positive gate entry, exact physical ownership and the pending lock attempt
// establish the overlap; an absent log alone is not an ordering assertion.
assert_eq!(owners(&run, &bucket, &remote_bucket)?, held);
assert_remote(&target, &old_remote, &old_body)?;
let released_at = OffsetDateTime::now_utc();
let delete_overlap_elapsed_ms = overlap_started.elapsed().as_millis();
drop(delete_gate);
let acquired = timeout(DELETE_OVERLAP_TIMEOUT, acquired_publication(&mut publication_events, &pending))
.await
.map_err(|error| format!(
"G-TIER {operation} publication acquired after DELETE release timed out: {error}; pending={pending}; lock_events={:?}; cleanup_barrier={:?}",
publication_events.cached(), cleanup_barrier.reached_observations()
))?
.map_err(|error| format!("G-TIER {operation} publication acquired after DELETE release failed: {error}"))?;
assert!(event_time(&pending)? >= movement_started && event_time(&pending)? < released_at);
assert!(
event_time(&acquired)? >= released_at,
"the same publication attempt must acquire after DELETE is released"
);
drop(publication_events);
let reached = cleanup_barrier
.wait_reached_during(
&run.dist.cluster,
match rebalance_id.as_deref() {
Some(id) => MovementOperation::Rebalance(id),
None => MovementOperation::Decommission(DECOMMISSION_POOL_ID),
},
MOVEMENT_COMPLETE_TIMEOUT,
)
.await
.map_err(|error| format!("G-TIER {operation} before-source-cleanup barrier failed: {error}"))?;
let published = owners(&run, &bucket, &remote_bucket)?;
assert!(
published
.iter()
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
);
let publication_pools: BTreeSet<_> = published.iter().map(|owner| owner.pool).collect();
assert!(publication_pools.contains(&SEED_POOL) && publication_pools.iter().any(|pool| *pool != SEED_POOL));
assert_eq!(published.len(), publication_pools.len() * DRIVES_PER_NODE);
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
assert_remote(&target, &live_remote, &live_body)?;
cleanup_barrier.release_movement()?;
cleanup_barrier.disarm()?;
let completion = match rebalance_id.as_deref() {
Some(id) => {
wait_for_rebalance_complete(&run.dist.cluster, id, MOVEMENT_COMPLETE_TIMEOUT).await?;
rebalance_status_json(&run.dist.cluster).await?
}
None => {
wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_COMPLETE_TIMEOUT).await?;
decommission_status_json(&run.dist.cluster).await?
}
};
let settled = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(settled.len(), DRIVES_PER_NODE);
assert_eq!(settled.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
assert!(
settled
.iter()
.all(|owner| owner.pool != SEED_POOL && !owner.free && owner.remote == live_remote)
);
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
if decommission {
run.dist.remove_retired_pools_and_restart(&[SEED_POOL]).await?;
} else {
run.dist.restart_current_binary_gracefully().await?;
}
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
let restarted = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(restarted.len(), DRIVES_PER_NODE);
assert_eq!(restarted.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
assert!(
restarted
.iter()
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
);
assert_remote(&target, &live_remote, &live_body)?;
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
assert!(
deletes(&target, &live_remote).is_empty(),
"the live transitioned tuple must never receive DELETE"
);
let remote_deletes = deletes(&target, &old_remote);
assert!(!remote_deletes.is_empty());
assert!(
remote_deletes
.iter()
.all(|request| request.version_id.as_deref() == Some(old_remote.version.as_str()))
);
let successful = remote_deletes
.iter()
.filter_map(|request| request.prepared_response.as_ref())
.find(|response| response.status == 204)
.ok_or("old remote tuple never completed DELETE")?;
let deleted_at_ms = i128::from(
successful
.prepared_at_unix_millis
.ok_or("DELETE response omitted its timestamp")?,
);
assert!(deleted_at_ms >= released_at.unix_timestamp_nanos() / 1_000_000);
assert!(event_time(&acquired)?.unix_timestamp_nanos() / 1_000_000 >= deleted_at_ms);
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
&format!("g-tier-{operation}"),
&json!({
"gate": "G-TIER", "operation": operation, "rebalance_id": rebalance_id,
"startup": startup,
"delete_gate_entered_at": delete_gate_entered_at.format(&Rfc3339)?,
"movement_overlap_started_at": movement_started.format(&Rfc3339)?,
"movement_acknowledged_at": movement_acknowledged_at.format(&Rfc3339)?,
"delete_overlap_timeout_ms": DELETE_OVERLAP_TIMEOUT.as_millis(),
"delete_overlap_elapsed_ms": delete_overlap_elapsed_ms,
"remote_backend": "HTTP fake_s3_target with versioning enabled",
"ordering": "remote DELETE held, mover publication waiting, DELETE released, same publication acquired",
"bucket": bucket, "key": KEY, "bucket_durability": bucket_durability,
"process_durability_strict_nodes": strict_nodes,
"old_version": old_version, "old_remote": old_remote, "old_sha256": sha256_hex(&old_body),
"live_version": live_version, "live_remote": live_remote, "live_sha256": sha256_hex(&live_body),
"owners_while_delete_held": held, "publication_waiting": pending, "publication_acquired": acquired,
"held_delete_sequences": held_deletes.iter().map(|request| request.sequence).collect::<Vec<_>>(),
"delete_released_at": released_at.format(&Rfc3339)?, "remote_delete_completed_at_unix_millis": deleted_at_ms,
"barrier": reached, "owners_before_source_cleanup": published, "owners_after_movement": settled,
"owners_after_restart": restarted, "completion": completion,
"old_remote_delete_attempts": remote_deletes.len(), "live_remote_delete_attempts": 0,
"source_readback_nodes": run.dist.cluster.nodes.len(),
}),
)?;
Ok(())
}
#[tokio::test]
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_rebalance() -> TestResult {
tier_delete_during_movement(false).await
}
#[tokio::test]
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_decommission() -> TestResult {
tier_delete_during_movement(true).await
}
@@ -60,7 +60,7 @@ struct UpgradeSeed {
iam_secret: &'static str,
}
pub(super) fn source_binary() -> TestResult<PathBuf> {
fn source_binary() -> TestResult<PathBuf> {
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
format!(
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
+14 -108
View File
@@ -487,43 +487,36 @@ impl JournaledHeaders {
struct ControlState {
scripts: HashMap<Operation, VecDeque<FaultAction>>,
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
held_get: Option<HeldObject>,
held_delete: Option<HeldObject>,
held_get: Option<HeldGetObject>,
requests: VecDeque<RequestRecord>,
next_sequence: u64,
}
#[derive(Clone)]
struct HeldObject {
struct HeldGetObject {
bucket: String,
key: String,
entered: watch::Sender<usize>,
released: watch::Receiver<bool>,
}
/// Holds every matching operation, including retries, until this guard is dropped.
#[must_use = "dropping the guard releases the held requests"]
pub struct ObjectGate {
/// Holds every GET of one object, including retries, until this guard is dropped.
#[must_use = "dropping the guard releases the held GET requests"]
pub struct GetObjectGate {
control: Arc<Mutex<ControlState>>,
operation: Operation,
entered: watch::Receiver<usize>,
released: watch::Sender<bool>,
}
impl ObjectGate {
impl GetObjectGate {
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
}
}
impl Drop for ObjectGate {
impl Drop for GetObjectGate {
fn drop(&mut self) {
let mut state = lock(&self.control);
match self.operation {
Operation::GetObject => state.held_get = None,
Operation::DeleteObject => state.held_delete = None,
_ => unreachable!("only GET and DELETE have object gates"),
}
lock(&self.control).held_get = None;
self.released.send_replace(true);
}
}
@@ -1115,40 +1108,24 @@ impl FakeS3Target {
}
/// Hold one exact bucket/key before any GET response can reach the client.
/// The fixture supports one live GET gate; request and connection deadlines still apply.
pub fn hold_get_object(&self, bucket: &str, key: &str) -> ObjectGate {
self.hold_object_operation(Operation::GetObject, bucket, key)
}
/// Hold one exact bucket/key before DELETE changes any stored version.
/// The fixture supports one live DELETE gate; request deadlines still apply.
pub fn hold_delete_object(&self, bucket: &str, key: &str) -> ObjectGate {
self.hold_object_operation(Operation::DeleteObject, bucket, key)
}
fn hold_object_operation(&self, operation: Operation, bucket: &str, key: &str) -> ObjectGate {
/// The fixture supports one live gate; request and connection deadlines still apply.
pub fn hold_get_object(&self, bucket: &str, key: &str) -> GetObjectGate {
assert!(
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
"held object identifiers exceed the fixture limit"
"held GET identifiers exceed the fixture limit"
);
let mut state = lock(&self.control);
let slot = match operation {
Operation::GetObject => &mut state.held_get,
Operation::DeleteObject => &mut state.held_delete,
_ => unreachable!("only GET and DELETE have object gates"),
};
assert!(slot.is_none(), "fake target already holds a {operation:?} gate");
assert!(state.held_get.is_none(), "fake target already holds a GET gate");
let (entered, entered_rx) = watch::channel(0);
let (released, released_rx) = watch::channel(false);
*slot = Some(HeldObject {
state.held_get = Some(HeldGetObject {
bucket: bucket.to_string(),
key: key.to_string(),
entered,
released: released_rx,
});
ObjectGate {
GetObjectGate {
control: Arc::clone(&self.control),
operation,
entered: entered_rx,
released,
}
@@ -2818,15 +2795,6 @@ impl S3 for FakeBackend {
apply_non_body_fault(fault.as_ref(), &self.control).await?;
let headers = req.headers;
let input = req.input;
let held_delete = lock(&self.control)
.held_delete
.as_ref()
.filter(|held| held.bucket == input.bucket && held.key == input.key)
.cloned();
if let Some(mut held) = held_delete {
held.entered.send_modify(|count| *count += 1);
let _ = held.released.wait_for(|released| *released).await;
}
let mut state = lock(&self.store);
let Some(versioned) = state.buckets.get(&input.bucket).map(|bucket| bucket.versioned) else {
return Err(s3s::s3_error!(NoSuchBucket, "bucket does not exist"));
@@ -3468,68 +3436,6 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn delete_object_gate_retains_exact_version_until_release() -> Result<(), BoxError> {
let target = FakeS3Target::start().await?;
let bucket = "delete-gated-target";
target.create_bucket(bucket);
for key in ["held", "unrelated"] {
target.put_seed_object(bucket, key, Bytes::from_static(b"payload"), &SeedMetadata::default());
}
let versions = target.stored_versions(bucket, "held");
let mut gate = target.hold_delete_object(bucket, "held");
let client = client(&target);
let held_client = client.clone();
let version = versions[0].0.clone();
let mut requests = tokio::task::JoinSet::new();
requests.spawn(async move {
held_client
.delete_object()
.bucket(bucket)
.key("held")
.version_id(version)
.send()
.await
});
timeout(Duration::from_secs(2), gate.wait_until_entered()).await??;
assert_eq!(
target.stored_versions(bucket, "held"),
versions,
"the gate must precede the storage mutation"
);
assert!(requests.try_join_next().is_none());
let held = target
.requests()
.into_iter()
.find(|record| record.operation == Operation::DeleteObject && record.key.as_deref() == Some("held"))
.expect("the held DELETE must be journaled");
assert_eq!(held.version_id.as_deref(), Some(versions[0].0.as_str()));
assert_eq!(held.prepared_response, None);
client
.delete_object()
.bucket(bucket)
.key("unrelated")
.version_id(&target.stored_versions(bucket, "unrelated")[0].0)
.send()
.await?;
assert!(target.stored_versions(bucket, "unrelated").is_empty());
assert_eq!(target.stored_versions(bucket, "held"), versions);
drop(gate);
timeout(Duration::from_secs(2), requests.join_next())
.await?
.expect("held DELETE task")??;
assert!(target.stored_versions(bucket, "held").is_empty());
let released = target
.requests()
.into_iter()
.find(|record| record.sequence == held.sequence)
.and_then(|record| record.prepared_response)
.expect("released DELETE response");
assert_eq!(released.status, 204);
assert_eq!(released.version_id.as_deref(), Some(versions[0].0.as_str()));
Ok(())
}
#[tokio::test]
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
use aws_sdk_s3::error::ProvideErrorMetadata;
@@ -21,7 +21,7 @@ mod tests {
};
use crate::common::{
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
init_logging, local_http_client, rustfs_binary_path,
init_logging, rustfs_binary_path,
};
use crate::storage_api::RUSTFS_META_BUCKET;
use aws_sdk_s3::{
@@ -53,196 +53,6 @@ mod tests {
expected_pool_metadata: Option<VersionShardCensus>,
}
fn pool_metadata_matches_republished_manifest(actual: &VersionShardCensus, expected: &VersionShardCensus) -> bool {
for data_dir in [&actual.data_dir, &expected.data_dir] {
if data_dir
.as_deref()
.and_then(|value| uuid::Uuid::parse_str(value).ok())
.is_none_or(|value| value.is_nil())
{
return false;
}
}
// The owning CAS republishes the same canonical payload with a fresh
// physical identity. Every shard and layout field must still match.
let mut republished = actual.clone();
republished.data_dir = expected.data_dir.clone();
republished.matches_manifest(expected)
}
fn pool_metadata_node_health_is_ready(status: u16, payload: &serde_json::Value) -> bool {
(200..300).contains(&status)
&& payload["ready"] == true
&& payload["details"]["poolMetadata"]["ready"] == true
&& payload["details"]["storage"]["readQuorum"] == true
&& payload["details"]["storage"]["writeQuorum"] == true
}
async fn all_nodes_pool_metadata_readiness(
cluster: &RustFSTestClusterEnvironment,
deadline: Instant,
) -> (bool, serde_json::Value) {
let http = local_http_client();
let mut ready = !cluster.nodes.is_empty();
let mut observations = Vec::with_capacity(cluster.nodes.len());
for (node, environment) in cluster.nodes.iter().enumerate() {
let response = tokio::time::timeout_at(deadline, async {
let response = http
.get(format!("{}/health/ready", environment.url))
.timeout(Duration::from_secs(3))
.send()
.await?;
let status = response.status().as_u16();
let payload = response.json::<serde_json::Value>().await?;
Ok::<_, reqwest::Error>((status, payload))
})
.await;
let observation = match response {
Ok(Ok((status, payload))) => {
let node_ready = pool_metadata_node_health_is_ready(status, &payload);
ready &= node_ready;
serde_json::json!({"node": node, "status": status, "ready": node_ready, "payload": payload})
}
Ok(Err(error)) => {
ready = false;
serde_json::json!({"node": node, "error": error.to_string()})
}
Err(_) => {
ready = false;
serde_json::json!({"node": node, "error": "pool metadata health deadline elapsed"})
}
};
observations.push(observation);
}
let deadline_elapsed = Instant::now() >= deadline;
(
ready && !deadline_elapsed,
serde_json::json!({"deadline_elapsed": deadline_elapsed, "nodes": observations}),
)
}
fn pool_metadata_manifest_fixture() -> VersionShardCensus {
VersionShardCensus {
version_id: None,
has_xl_meta: true,
data_dir: Some(uuid::Uuid::from_u128(1).to_string()),
erasure_index: Some(1),
erasure_distribution: Some(vec![1, 2, 3, 4]),
data_blocks: Some(2),
parity_blocks: Some(2),
expected_part_numbers: Default::default(),
present_part_fingerprints: Default::default(),
inline_data_fingerprint: Some(crate::chaos::PartShardFingerprint {
size: 7,
sha256: sha256_hex(b"payload"),
}),
}
}
#[test]
fn pool_metadata_republication_allows_only_valid_data_dir_change() {
let expected = pool_metadata_manifest_fixture();
let mut actual = expected.clone();
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
assert!(
!actual.matches_manifest(&expected),
"ordinary object manifests must retain their original identity"
);
for invalid in [
None,
Some(String::new()),
Some("invalid".to_string()),
Some(uuid::Uuid::nil().to_string()),
] {
let mut changed = actual.clone();
changed.data_dir = invalid.clone();
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed_expected = expected.clone();
changed_expected.data_dir = invalid;
assert!(!pool_metadata_matches_republished_manifest(&actual, &changed_expected));
}
let mut changed = actual.clone();
changed
.inline_data_fingerprint
.as_mut()
.expect("fixture has inline payload")
.sha256 = sha256_hex(b"corrupt");
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.erasure_index = Some(2);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.erasure_distribution = Some(vec![2, 1, 3, 4]);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.data_blocks = Some(1);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.parity_blocks = Some(3);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.version_id = Some(uuid::Uuid::from_u128(3).to_string());
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual;
changed.has_xl_meta = false;
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
}
#[test]
fn pool_metadata_republication_requires_exact_complete_part_fingerprints() {
let mut expected = pool_metadata_manifest_fixture();
expected.inline_data_fingerprint = None;
expected.expected_part_numbers.insert(1);
expected.present_part_fingerprints.insert(
1,
crate::chaos::PartShardFingerprint {
size: 7,
sha256: sha256_hex(b"payload"),
},
);
let mut actual = expected.clone();
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
let mut changed = actual.clone();
changed.present_part_fingerprints.remove(&1);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual;
changed
.present_part_fingerprints
.get_mut(&1)
.expect("fixture has part 1")
.sha256 = sha256_hex(b"corrupt");
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
}
#[test]
fn pool_metadata_node_health_requires_explicit_metadata_and_both_quorums() {
let expected = serde_json::json!({"ready": true, "details": {
"poolMetadata": {"ready": true},
"storage": {"readQuorum": true, "writeQuorum": true}
}});
assert!(pool_metadata_node_health_is_ready(200, &expected));
assert!(!pool_metadata_node_health_is_ready(503, &expected));
assert!(!pool_metadata_node_health_is_ready(200, &serde_json::json!({"ready": true})));
for path in [
"/ready",
"/details/poolMetadata/ready",
"/details/storage/readQuorum",
"/details/storage/writeQuorum",
] {
for value in [
serde_json::Value::Bool(false),
serde_json::Value::Null,
serde_json::json!("true"),
] {
let mut changed = expected.clone();
*changed.pointer_mut(path).expect("fixture contains required health field") = value;
assert!(!pool_metadata_node_health_is_ready(200, &changed), "required health field {path}");
}
}
}
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
@@ -2422,7 +2232,6 @@ mod tests {
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(180);
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
let mut last_pool_metadata_readiness = serde_json::Value::Null;
loop {
if restart_recovery_admin_after_failure && !recovery_admin_task_restarted {
let task_state = timeout(
@@ -2464,23 +2273,14 @@ mod tests {
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
let pool_metadata_matches = match &expected_pool_metadata {
Some(expected) => {
let actual =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
pool_metadata_matches_republished_manifest(&actual, expected)
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
.matches_manifest(expected)
}
None => true,
};
let pool_metadata_ready = if expected_pool_metadata.is_some() && pool_metadata_matches {
let (ready, observation) = all_nodes_pool_metadata_readiness(&cluster, heal_deadline).await;
last_pool_metadata_readiness = observation;
ready
} else {
expected_pool_metadata.is_none()
};
if matching == expected_manifests.len()
&& (!outage_target_manifest_required || outage_census.is_complete())
&& pool_metadata_matches
&& pool_metadata_ready
{
break;
}
@@ -2490,15 +2290,6 @@ mod tests {
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
let pool_metadata =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
// This post-deadline observation is diagnostic only. It cannot
// make an expired recovery attempt satisfy the original budget.
let pool_metadata_readiness = if expected_pool_metadata.is_some() {
let (_, observation) =
all_nodes_pool_metadata_readiness(&cluster, Instant::now() + Duration::from_secs(3)).await;
serde_json::json!({"last_eligible_check": last_pool_metadata_readiness, "timeout_check": observation})
} else {
serde_json::Value::Null
};
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
.await
.unwrap_or_else(|err| format!("status request failed: {err}"));
@@ -2522,7 +2313,7 @@ mod tests {
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
};
return Err(format!(
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata_expected={expected_pool_metadata:?}, pool_metadata={pool_metadata:?}, pool_metadata_readiness={pool_metadata_readiness}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
expected_manifests.len()
)
.into());
@@ -1129,14 +1129,12 @@ mod tests {
/// and the marker plus the cursor tag.
///
/// Bug Reference: backlog#1047
/// Earlier releases appended a `[rustfs_cache:...]` cursor tag to the last
/// returned key inside the V2 continuation token. The orchestration layer
/// passed that raw string to `forward_past`, so any key whose byte after the
/// shared stem sorts below `[` (0x5B) — `.`, `/`, `-`, digits, uppercase —
/// was silently dropped on the next page: with keys `a`, `a.txt`, `zz` and
/// max_keys=1, page 2 returned `zz` and `a.txt` was never listed. The token
/// is now base64 of the plain key, and this test keeps guarding the
/// continuation boundary.
/// The V2 continuation token appends a `[rustfs_cache:...]` cursor tag to the
/// last returned key. The orchestration layer passed that raw string to
/// `forward_past`, so any key whose byte after the shared stem sorts below
/// `[` (0x5B) — `.`, `/`, `-`, digits, uppercase — was silently dropped on
/// the next page: with keys `a`, `a.txt`, `zz` and max_keys=1, page 2
/// returned `zz` and `a.txt` was never listed.
#[tokio::test]
async fn test_list_objects_v2_continuation_keeps_keys_after_marker_stem() {
init_logging();
+87 -201
View File
@@ -19,19 +19,6 @@ use http::{Method, StatusCode};
use tokio::time::{Duration, sleep, timeout};
use tracing::{debug, info};
/// A successful reporting response can still have unknown scanner usage.
fn reported_quota_usage(response: &serde_json::Value) -> std::io::Result<Option<u64>> {
let value = response
.get("current_usage")
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "quota response is missing current_usage"))?;
if value.is_null() {
return Ok(None);
}
value.as_u64().map(Some).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "quota current_usage is not an unsigned integer or null")
})
}
/// Test environment setup for quota tests
pub struct QuotaTestEnv {
pub env: RustFSTestEnvironment,
@@ -100,69 +87,35 @@ impl QuotaTestEnv {
}
}
async fn wait_for_usage_response(
&self,
method: Method,
path: &str,
body: Option<String>,
expected_usage: u64,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let convergence = async {
loop {
let (status, response) = admin_request(
&self.env.url,
method.clone(),
path,
body.clone(),
&self.env.access_key,
&self.env.secret_key,
)
.await?;
if status.is_success() {
let response: serde_json::Value = serde_json::from_str(&response)?;
if let Some(usage) = reported_quota_usage(&response)?
&& usage == expected_usage
{
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(response);
}
} else if status != StatusCode::SERVICE_UNAVAILABLE {
return Err(format!("quota usage request failed for {path}: {status} {response}").into());
}
sleep(Duration::from_millis(100)).await;
}
};
match timeout(Duration::from_secs(30), convergence).await {
Ok(result) => result,
Err(_) => Err(format!("quota usage did not converge to {expected_usage} for {path} within 30 seconds").into()),
pub async fn get_bucket_quota_stats(&self) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, self.bucket_name);
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
if response.contains("error") {
Err(format!("Failed to get quota stats: {}", response).into())
} else {
Ok(serde_json::from_str(&response)?)
}
}
async fn wait_for_bucket_quota_stats(
&self,
bucket: &str,
expected_usage: u64,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
self.wait_for_usage_response(Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None, expected_usage)
.await
}
async fn wait_for_bucket_quota_check(
pub async fn check_bucket_quota(
&self,
operation_type: &str,
operation_size: u64,
expected_usage: u64,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/quota-check/{}", self.env.url, self.bucket_name);
let check_request = serde_json::json!({
"operation_type": operation_type,
"operation_size": operation_size
});
self.wait_for_usage_response(
Method::POST,
&format!("/rustfs/admin/v3/quota-check/{}", self.bucket_name),
Some(check_request.to_string()),
expected_usage,
)
.await
let response = awscurl_post(&url, &check_request.to_string(), &self.env.access_key, &self.env.secret_key).await?;
if response.contains("error") {
Err(format!("Failed to check quota: {}", response).into())
} else {
Ok(serde_json::from_str(&response)?)
}
}
pub async fn upload_object(&self, key: &str, size_bytes: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@@ -192,9 +145,25 @@ impl QuotaTestEnv {
}
}
pub async fn get_bucket_usage(&self) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let stats = self.get_bucket_quota_stats().await?;
Ok(stats.get("current_usage").and_then(|v| v.as_u64()).unwrap_or(0))
}
async fn wait_for_bucket_usage(&self, expected: u64) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let stats = self.wait_for_bucket_quota_stats(&self.bucket_name, expected).await?;
reported_quota_usage(&stats)?.ok_or_else(|| "converged quota usage is missing".into())
let convergence = async {
loop {
let usage = self.get_bucket_usage().await?;
if usage == expected {
return Ok::<u64, Box<dyn std::error::Error + Send + Sync>>(usage);
}
sleep(Duration::from_millis(100)).await;
}
};
match timeout(Duration::from_secs(30), convergence).await {
Ok(result) => result,
Err(_) => Err(format!("bucket usage did not converge to {expected} bytes within 30 seconds").into()),
}
}
pub async fn set_bucket_quota_for(
@@ -202,6 +171,8 @@ impl QuotaTestEnv {
bucket: &str,
quota_bytes: u64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.wait_for_quota_usage_for(bucket).await?;
let quota_path = format!("/rustfs/admin/v3/quota/{bucket}");
let quota_config = serde_json::json!({
"quota": quota_bytes,
@@ -235,6 +206,46 @@ impl QuotaTestEnv {
}
}
pub async fn wait_for_quota_usage_for(&self, bucket: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let stats_path = format!("/rustfs/admin/v3/quota-stats/{bucket}");
let readiness = async {
loop {
let (status, response) =
admin_request(&self.env.url, Method::GET, &stats_path, None, &self.env.access_key, &self.env.secret_key)
.await?;
if status.is_success() {
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
}
if status != StatusCode::SERVICE_UNAVAILABLE {
return Err(format!("quota usage readiness failed for {bucket}: {status} {response}").into());
}
sleep(Duration::from_secs(1)).await;
}
};
match timeout(Duration::from_secs(30), readiness).await {
Ok(result) => result,
Err(_) => Err(format!("quota usage did not become authoritative for {bucket} within 30 seconds").into()),
}
}
/// Get bucket quota statistics for specific bucket
pub async fn get_bucket_quota_stats_for(
&self,
bucket: &str,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
debug!("Getting quota stats for bucket: {}", bucket);
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, bucket);
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
if response.contains("error") {
Err(format!("Failed to get quota stats: {}", response).into())
} else {
let stats: serde_json::Value = serde_json::from_str(&response)?;
Ok(stats)
}
}
/// Upload an object to specific bucket
pub async fn upload_object_to_bucket(
&self,
@@ -259,132 +270,6 @@ impl QuotaTestEnv {
}
}
#[cfg(test)]
mod usage_response_tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::task::JoinSet;
#[test]
fn unknown_usage_is_not_zero() {
assert_eq!(
reported_quota_usage(&serde_json::json!({"current_usage": null})).expect("valid unknown usage"),
None
);
assert_eq!(
reported_quota_usage(&serde_json::json!({"current_usage": 0})).expect("known zero usage"),
Some(0)
);
}
#[test]
fn missing_or_invalid_usage_is_not_readiness() {
for response in [
serde_json::json!({}),
serde_json::json!({"current_usage": -1}),
serde_json::json!({"current_usage": "0"}),
serde_json::json!({"current_usage": 1.5}),
serde_json::json!({"current_usage": false}),
] {
let error = reported_quota_usage(&response).expect_err("malformed usage must fail");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
}
}
async fn mock_environment(
responses: Vec<(StatusCode, &'static str)>,
) -> Result<(QuotaTestEnv, JoinSet<()>), Box<dyn std::error::Error + Send + Sync>> {
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
let address = listener.local_addr()?;
let mut tasks = JoinSet::new();
tasks.spawn(async move {
for (status, body) in responses {
let (mut stream, _) = listener.accept().await.expect("accept quota reporting request");
let mut request = Vec::new();
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
let mut buffer = [0_u8; 1024];
let read = stream.read(&mut buffer).await.expect("read reporting request");
assert!(read > 0 && request.len() + read <= 8192, "bounded HTTP request headers");
request.extend_from_slice(&buffer[..read]);
}
let response = format!(
"HTTP/1.1 {} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status.as_u16(),
body.len(),
body
);
stream
.write_all(response.as_bytes())
.await
.expect("write quota reporting response");
stream.shutdown().await.expect("close reporting response");
}
});
let mut env = RustFSTestEnvironment::new().await?;
env.url = format!("http://{address}");
let client = env.create_s3_client();
Ok((
QuotaTestEnv {
env,
client,
bucket_name: "quota-reporting-fixture".to_owned(),
},
tasks,
))
}
#[tokio::test]
async fn reporting_waits_for_known_matching_usage_without_selecting_allowed()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (env, mut tasks) = mock_environment(vec![
(StatusCode::SERVICE_UNAVAILABLE, "unavailable"),
(StatusCode::OK, r#"{"current_usage":null}"#),
(StatusCode::OK, r#"{"current_usage":1}"#),
(StatusCode::OK, r#"{"current_usage":0,"allowed":false}"#),
])
.await?;
let response = env.wait_for_usage_response(Method::GET, "/reporting", None, 0).await?;
assert_eq!(response["current_usage"], 0);
assert_eq!(response["allowed"], false, "readiness must not select a desired admission outcome");
timeout(Duration::from_secs(1), tasks.join_next())
.await?
.expect("reporting mock task")
.expect("all responses consumed");
Ok(())
}
#[tokio::test]
async fn reporting_rejects_malformed_success_instead_of_retrying() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (env, mut tasks) = mock_environment(vec![(StatusCode::OK, r#"{"current_usage":"unknown"}"#)]).await?;
let error = env
.wait_for_usage_response(Method::GET, "/reporting", None, 0)
.await
.expect_err("malformed usage must fail");
assert!(error.to_string().contains("not an unsigned integer or null"));
timeout(Duration::from_secs(1), tasks.join_next())
.await?
.expect("reporting mock task")
.expect("response consumed");
Ok(())
}
#[tokio::test]
async fn reporting_does_not_retry_authorization_failure() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (env, mut tasks) = mock_environment(vec![(StatusCode::FORBIDDEN, "denied")]).await?;
let error = env
.wait_for_usage_response(Method::GET, "/reporting", None, 0)
.await
.expect_err("authorization error must fail");
assert!(error.to_string().contains("403"));
timeout(Duration::from_secs(1), tasks.join_next())
.await?
.expect("reporting mock task")
.expect("response consumed");
Ok(())
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
@@ -597,7 +482,7 @@ mod integration_tests {
env.upload_object("file2.txt", 256 * 1024).await?;
// Check usage
let usage = env.wait_for_bucket_usage((512 + 256) * 1024).await?;
let usage = env.get_bucket_usage().await?;
assert_eq!(usage, (512 + 256) * 1024);
// Delete a file
@@ -632,7 +517,7 @@ mod integration_tests {
env.upload_object("file2.txt", 512 * 1024).await?;
// Get detailed quota statistics
let stats = env.wait_for_bucket_quota_stats(&env.bucket_name, (1024 + 512) * 1024).await?;
let stats = env.get_bucket_quota_stats().await?;
assert_eq!(stats.get("bucket").unwrap().as_str().unwrap(), env.bucket_name);
assert_eq!(stats.get("quota_limit").unwrap().as_u64().unwrap(), 2 * 1024 * 1024);
@@ -661,22 +546,22 @@ mod integration_tests {
env.upload_object("existing_file.txt", 512 * 1024).await?;
// Check if we can upload another 512KB (should succeed, exactly fill quota)
let check_result = env.wait_for_bucket_quota_check("PUT", 512 * 1024, 512 * 1024).await?;
let check_result = env.check_bucket_quota("PUT", 512 * 1024).await?;
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 0);
// Note: we haven't actually uploaded the second file yet, so current_usage is still 512KB
// Check if we can upload 1KB (should succeed - we haven't used the full quota yet)
let check_result = env.wait_for_bucket_quota_check("PUT", 1024, 512 * 1024).await?;
let check_result = env.check_bucket_quota("PUT", 1024).await?;
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 523264); // 511 * 1024
// Check if we can upload 600KB (should fail - would exceed quota)
let check_result = env.wait_for_bucket_quota_check("PUT", 600 * 1024, 512 * 1024).await?;
let check_result = env.check_bucket_quota("PUT", 600 * 1024).await?;
assert!(!check_result.get("allowed").unwrap().as_bool().unwrap());
// Check delete operation (should always be allowed)
let check_result = env.wait_for_bucket_quota_check("DELETE", 512 * 1024, 512 * 1024).await?;
let check_result = env.check_bucket_quota("DELETE", 512 * 1024).await?;
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
env.cleanup_bucket().await?;
@@ -708,8 +593,8 @@ mod integration_tests {
env.upload_object_to_bucket(&bucket2, "another_file.txt", 512 * 1024).await?;
// Verify statistics are independent
let stats1 = env.wait_for_bucket_quota_stats(&bucket1, 1024 * 1024).await?;
let stats2 = env.wait_for_bucket_quota_stats(&bucket2, (1024 + 512) * 1024).await?;
let stats1 = env.get_bucket_quota_stats_for(&bucket1).await?;
let stats2 = env.get_bucket_quota_stats_for(&bucket2).await?;
assert_eq!(stats1.get("current_usage").unwrap().as_u64().unwrap(), 1024 * 1024);
assert_eq!(stats2.get("current_usage").unwrap().as_u64().unwrap(), (1024 + 512) * 1024);
@@ -770,6 +655,7 @@ mod integration_tests {
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
env.wait_for_quota_usage_for(&env.bucket_name).await?;
// Test 1: GET quota for bucket without quota config
let url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
+44 -33
View File
@@ -541,6 +541,50 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn artifact_rename_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn artifact_repair_rename_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactRepairRenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn artifact_repair_write_metadata(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactRepairWriteMetadataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::WriteMetadataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
type InventoryArtifactAttemptStream =
Pin<Box<dyn Stream<Item = Result<rustfs_protos::proto_gen::node_service::ArtifactInventoryFrame, Status>> + Send>>;
async fn inventory_artifact_attempt(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactInventoryRequest>,
) -> Result<Response<Self::InventoryArtifactAttemptStream>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn seal_artifact_attempt_repairs(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn seal_artifact_attempt_puts(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
@@ -548,20 +592,6 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_multipart_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn recover_multipart_source(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_data_at_incarnation(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
@@ -576,25 +606,6 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_data_with_pool_meta_cas(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn undo_pool_meta_write(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVersionRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVersionResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn pool_meta_native_recovery(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn make_volumes(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumesRequest>,
File diff suppressed because it is too large Load Diff
+8 -24
View File
@@ -15,16 +15,14 @@
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
#[cfg(test)]
pub(crate) const OBJECT_LOCK_TRACE_FILTER: &str = "info,rustfs_ecstore::object_lock_diag=trace";
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{
DiskOption, Endpoint, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, VolumeInfo, WalkDirOptions, new_disk,
DiskError, DiskOption, Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions, new_disk,
};
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
verify_tonic_boot_epoch_response,
set_tonic_canonical_body_digest, verify_tonic_boot_epoch_response,
};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{gen_tonic_signature_interceptor, node_service_time_out_client};
@@ -40,6 +38,11 @@ pub(crate) mod grpc_lock {
pub(crate) use super::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
}
#[cfg(test)]
pub(crate) mod artifact_rpc {
pub(crate) use super::{DiskError, TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest};
}
/// Signing/transport surface used by the cross-process internode RPC signature
/// acceptance tests (backlog#1327). The signing helpers are what let a test mint
/// the one legitimately signed request an on-path attacker is assumed to have
@@ -59,24 +62,5 @@ pub(crate) mod replication_extension {
#[cfg(test)]
pub(crate) mod upgrade_compatibility {
pub(crate) use super::{DiskOption, Endpoint, RUSTFS_META_MULTIPART_BUCKET, new_disk};
}
#[cfg(test)]
pub(crate) mod multipart_rpc_delay {
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
}
#[cfg(test)]
pub(crate) mod multipart_fence_acceptance {
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
}
#[cfg(test)]
pub(crate) mod multipart_source_rpc {
pub(crate) use rustfs_ecstore::api::disk::{
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceSnapshot,
PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage,
};
pub(crate) use super::{DiskOption, Endpoint, new_disk};
}
+65 -512
View File
@@ -21,7 +21,6 @@ use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject,
use crate::replication_extension_test::{
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, put_bucket_replication, set_replication_target_with_options,
};
use crate::storage_api::upgrade_compatibility::RUSTFS_META_MULTIPART_BUCKET;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
@@ -53,8 +52,6 @@ const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
const PLAIN_BUCKET: &str = "upgrade-plain-data";
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
const MIXED_BUCKET: &str = "upgrade-mixed-version-data";
const PENDING_MULTIPART_BUCKET: &str = "upgrade-mpu-continuity";
const PENDING_MULTIPART_KEY: &str = "pending/legacy-multipart.bin";
const MIXED_NODE_COUNT: usize = 4;
const UPGRADE_READINESS_BODY: &[u8] = b"upgrade write readiness";
// Written by the "previous-seed" readiness probe through node 0 before any
@@ -304,27 +301,18 @@ async fn read_object(
Ok((encryption, body))
}
struct PendingMultipart {
upload_id: String,
completed_parts: Vec<CompletedPart>,
part_sizes: Vec<i64>,
}
async fn stage_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> Result<PendingMultipart, BoxError> {
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
let created = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = created
.upload_id()
.ok_or("CreateMultipartUpload omitted upload ID")?
.to_owned();
let upload_id = created.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
let mut completed_parts = Vec::with_capacity(parts.len());
let mut part_sizes = Vec::with_capacity(parts.len());
for (index, part) in parts.iter().enumerate() {
let part_number = i32::try_from(index + 1)?;
let uploaded = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.upload_id(upload_id)
.part_number(part_number)
.body(ByteStream::from(part.clone()))
.send()
@@ -335,38 +323,16 @@ async fn stage_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<
.e_tag(uploaded.e_tag().ok_or("UploadPart omitted ETag")?)
.build(),
);
part_sizes.push(i64::try_from(part.len())?);
}
Ok(PendingMultipart {
upload_id,
completed_parts,
part_sizes,
})
}
async fn complete_pending_multipart(
client: &Client,
bucket: &str,
key: &str,
pending: &PendingMultipart,
) -> Result<aws_sdk_s3::operation::complete_multipart_upload::CompleteMultipartUploadOutput, BoxError> {
Ok(client
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&pending.upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.set_parts(Some(pending.completed_parts.clone()))
.build(),
)
.upload_id(upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
.send()
.await?)
}
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
let pending = stage_multipart(client, bucket, key, parts).await?;
complete_pending_multipart(client, bucket, key, &pending).await?;
.await?;
Ok(())
}
@@ -413,330 +379,6 @@ async fn write_multipart_load(clients: &[Client], phase: &str) -> Result<Vec<Str
Ok(keys)
}
async fn write_mixed_put_load(clients: &[Client], phase: &str) -> Result<Vec<String>, BoxError> {
let mut tasks = JoinSet::new();
for worker in 0..MULTIPART_WORKERS {
let node = worker % clients.len();
let client = clients[node].clone();
let phase = phase.to_owned();
tasks.spawn(async move {
let mut keys = Vec::with_capacity(MULTIPART_UPLOADS_PER_WORKER);
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
let key = format!("{phase}/put/{worker:02}/{upload:02}");
tokio::time::timeout(
MIXED_MULTIPART_UPLOAD_TIMEOUT,
client
.put_object()
.bucket(MIXED_BUCKET)
.key(&key)
.body(ByteStream::from(vec![u8::try_from(worker)?; 64 * 1024]))
.send(),
)
.await
.map_err(|_| format!("node {node}: PUT {key} acknowledgement deadline exceeded"))??;
keys.push(key);
}
Ok::<_, BoxError>(keys)
});
}
let mut keys = Vec::with_capacity(MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
while let Some(result) = tasks.join_next().await {
keys.extend(result??);
}
Ok(keys)
}
async fn verify_load_bodies(clients: &[Client], phase: &str, operation: &str) -> TestResult {
for worker in 0..MULTIPART_WORKERS {
let reader = &clients[(worker + 1) % clients.len()];
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
let key = format!("{phase}/{operation}/{worker:02}/{upload:02}");
assert_eq!(
read_object(reader, MIXED_BUCKET, &key, None).await?.1,
vec![u8::try_from(worker)?; 64 * 1024],
"{phase}: acknowledged {key} body"
);
}
}
Ok(())
}
async fn exercise_homogeneous_multipart(cluster: &RustFSTestClusterEnvironment, phase: &str) -> TestResult {
let clients = cluster.create_all_clients()?;
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
wait_for_drive_readmission(&clients, MIXED_BUCKET, phase, DRIVE_READMISSION_TIMEOUT).await?;
let keys = match write_multipart_load(&clients, phase).await {
Ok(keys) => keys,
Err(error) => {
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
}
};
assert_eq!(keys.len(), MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
verify_load_bodies(&clients, phase, "multipart").await?;
for (reader, client) in clients.iter().enumerate() {
wait_for_phase_listing(client, phase, keys.len(), &format!("homogeneous reader {reader}")).await?;
}
Ok(())
}
struct PendingSourceBytes {
root: PathBuf,
files: Vec<(PathBuf, Vec<u8>)>,
}
fn pending_source_bytes(
cluster: &RustFSTestClusterEnvironment,
pending: &PendingMultipart,
) -> Result<Vec<PendingSourceBytes>, BoxError> {
let source_hash =
rustfs_utils::hex_sha256(format!("{PENDING_MULTIPART_BUCKET}/{PENDING_MULTIPART_KEY}").as_bytes(), str::to_owned);
let mut sources = Vec::new();
for volume in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
let hash_root = Path::new(volume).join(RUSTFS_META_MULTIPART_BUCKET).join(&source_hash);
let roots = std::fs::read_dir(&hash_root)?
.map(|entry| {
let entry = entry?;
Ok(entry.file_type()?.is_dir().then(|| entry.path()))
})
.collect::<Result<Vec<_>, std::io::Error>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>();
if roots.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "pending upload source has not settled").into());
}
assert_eq!(roots.len(), 1, "the dedicated key has exactly one real pending upload");
let root = roots.into_iter().next().ok_or("pending upload source missing")?;
let mut files = vec![(PathBuf::from("xl.meta"), std::fs::read(root.join("xl.meta"))?)];
let data_dirs = std::fs::read_dir(&root)?
.map(|entry| {
let entry = entry?;
Ok((entry.file_type()?.is_dir() && entry.path().join("part.1").is_file()).then(|| entry.path()))
})
.collect::<Result<Vec<_>, std::io::Error>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>();
if data_dirs.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "selected part publication has not settled").into());
}
assert_eq!(data_dirs.len(), 1, "the upload has one actual selected-part directory");
let data_dir = data_dirs.into_iter().next().ok_or("pending upload parts missing")?;
for part in &pending.completed_parts {
let number = part.part_number().ok_or("acknowledged part omitted its number")?;
for name in [format!("part.{number}"), format!("part.{number}.meta")] {
let path = data_dir.join(name);
files.push((path.strip_prefix(&root)?.to_owned(), std::fs::read(path)?));
}
}
sources.push(PendingSourceBytes { root, files });
}
assert_eq!(
sources.len(),
MIXED_NODE_COUNT,
"the controlled legacy fixture requires every physical source"
);
Ok(sources)
}
async fn wait_for_pending_source_bytes(
cluster: &RustFSTestClusterEnvironment,
pending: &PendingMultipart,
) -> Result<Vec<PendingSourceBytes>, BoxError> {
let deadline = Instant::now() + DRIVE_READMISSION_TIMEOUT;
loop {
match pending_source_bytes(cluster, pending) {
Ok(sources) => return Ok(sources),
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound)
&& Instant::now() < deadline =>
{
sleep(Duration::from_millis(500)).await
}
Err(error) => return Err(error),
}
}
}
async fn assert_pending_parts(client: &Client, pending: &PendingMultipart) -> TestResult {
let listed = client
.list_parts()
.bucket(PENDING_MULTIPART_BUCKET)
.key(PENDING_MULTIPART_KEY)
.upload_id(&pending.upload_id)
.send()
.await?;
assert_eq!(listed.upload_id(), Some(pending.upload_id.as_str()));
assert_ne!(listed.is_truncated(), Some(true));
assert_eq!(listed.parts().len(), pending.completed_parts.len());
for ((actual, expected), expected_size) in listed.parts().iter().zip(&pending.completed_parts).zip(&pending.part_sizes) {
assert_eq!(actual.part_number(), expected.part_number());
assert_eq!(actual.e_tag(), expected.e_tag());
assert_eq!(actual.size(), Some(*expected_size));
}
Ok(())
}
async fn assert_mixed_pending_refusal(
cluster: &RustFSTestClusterEnvironment,
pending: &PendingMultipart,
sources: &[PendingSourceBytes],
) -> TestResult {
let clients = cluster.create_all_clients()?;
assert_pending_parts(&clients[0], pending).await?;
let error = upgrade_probe_client(&clients[0])
.complete_multipart_upload()
.bucket(PENDING_MULTIPART_BUCKET)
.key(PENDING_MULTIPART_KEY)
.upload_id(&pending.upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.set_parts(Some(pending.completed_parts.clone()))
.build(),
)
.send()
.await
.expect_err("one native participant cannot authorize a legacy MPU completion");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503), "{error:?}");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("ServiceUnavailable"));
for client in &clients {
assert_pending_parts(client, pending).await?;
let error = client
.get_object()
.bucket(PENDING_MULTIPART_BUCKET)
.key(PENDING_MULTIPART_KEY)
.send()
.await
.expect_err("the rejected Complete must not publish a target");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
}
for source in sources {
for entry in std::fs::read_dir(&source.root)? {
assert!(
!entry?.file_name().to_string_lossy().starts_with(".mpu-"),
"the failed preflight must not upgrade one physical legacy source"
);
}
for (relative, expected) in &source.files {
let actual = std::fs::read(source.root.join(relative))?;
assert!(actual == *expected, "rejected Complete changed actual source {}", relative.display());
}
}
Ok(())
}
struct CompletedPendingMultipart {
version_id: String,
etag: String,
body: Vec<u8>,
}
async fn assert_resumed_multipart(clients: &[Client], completed: &CompletedPendingMultipart) -> TestResult {
for (node, client) in clients.iter().enumerate() {
let response = client
.get_object()
.bucket(PENDING_MULTIPART_BUCKET)
.key(PENDING_MULTIPART_KEY)
.version_id(&completed.version_id)
.send()
.await?;
assert_eq!(response.version_id(), Some(completed.version_id.as_str()), "reader {node}");
assert_eq!(response.e_tag(), Some(completed.etag.as_str()), "reader {node}");
assert_eq!(
response.body.collect().await?.into_bytes().as_ref(),
completed.body.as_slice(),
"reader {node}"
);
assert_eq!(
read_object(client, PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, None)
.await?
.1,
completed.body
);
let versions = client
.list_object_versions()
.bucket(PENDING_MULTIPART_BUCKET)
.prefix(PENDING_MULTIPART_KEY)
.send()
.await?;
assert_eq!(versions.versions().len(), 1, "same-upload replay must not add a version");
assert_eq!(versions.versions()[0].version_id(), Some(completed.version_id.as_str()));
assert!(versions.delete_markers().is_empty());
}
Ok(())
}
async fn finish_pending_multipart(
clients: &[Client],
pending: &PendingMultipart,
body: Vec<u8>,
) -> Result<CompletedPendingMultipart, BoxError> {
let complete = complete_pending_multipart(&clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, pending).await?;
let version_id = complete
.version_id()
.filter(|version| !version.is_empty() && *version != "null")
.ok_or("versioned legacy upload completion omitted a concrete version")?
.to_owned();
let etag = complete.e_tag().ok_or("CompleteMultipartUpload omitted ETag")?.to_owned();
let replay = complete_pending_multipart(&clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, pending).await?;
assert_eq!(replay.version_id(), Some(version_id.as_str()));
assert_eq!(replay.e_tag(), Some(etag.as_str()));
let completed = CompletedPendingMultipart { version_id, etag, body };
assert_resumed_multipart(clients, &completed).await?;
Ok(completed)
}
async fn wait_for_multipart_quiescence(cluster: &RustFSTestClusterEnvironment, sources: &[PendingSourceBytes]) -> TestResult {
let deadline = Instant::now() + LISTING_CONVERGENCE_TIMEOUT;
loop {
let pending_roots = sources.iter().filter(|source| source.root.exists()).count();
let mut native_controls = 0;
let mut remaining_upload_roots = 0;
for volume in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
let root = Path::new(volume).join(RUSTFS_META_MULTIPART_BUCKET);
if !root.exists() {
continue;
}
for entry in walkdir::WalkDir::new(root) {
let entry = match entry {
Ok(entry) => entry,
Err(error)
if error
.io_error()
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
{
native_controls += 1;
continue;
}
Err(error) => return Err(error.into()),
};
// Every fixture upload lives at MPI/<SHA>/<upload>. A born-only
// source can contain just xl.meta and its data directory, with
// no .mpu-* record. Only empty SHA parents may survive rollback.
if entry.depth() == 2 || (entry.depth() == 1 && !entry.file_type().is_dir()) {
remaining_upload_roots += 1;
}
if entry.file_name().to_string_lossy().starts_with(".mpu-") {
native_controls += 1;
}
}
}
if pending_roots == 0 && remaining_upload_roots == 0 && native_controls == 0 {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"downgrade requires settled native MPU state: {pending_roots} fixed pending roots, {remaining_upload_roots} inventoried upload roots, {native_controls} native controls"
)
.into());
}
sleep(Duration::from_millis(500)).await;
}
}
async fn mixed_multipart_before(
deadline: Instant,
node: usize,
@@ -983,11 +625,14 @@ async fn exercise_mixed_cluster(
.await?;
assert_eq!(read_object(current_client, MIXED_BUCKET, &previous_key, None).await?.1, previous_body);
// Native MPU mutations are operationally paused while RPC generations
// differ. Keep the same mixed traffic volume with ordinary PUT instead.
let written_keys = write_mixed_put_load(&clients, phase).await?;
let expected_count = written_keys.len() + 2;
verify_load_bodies(&clients, phase, "put").await?;
let multipart_keys = match write_multipart_load(&clients, phase).await {
Ok(keys) => keys,
Err(error) => {
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
}
};
let expected_count = multipart_keys.len() + 2;
for (label, client) in [("current", current_client), ("previous", previous_client)] {
assert_eq!(
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
@@ -1003,9 +648,9 @@ async fn exercise_mixed_cluster(
.await?;
}
let last_put_key = format!("{phase}/put/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
let last_multipart_key = format!("{phase}/multipart/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
assert_eq!(
read_object(previous_client, MIXED_BUCKET, &last_put_key, None).await?.1,
read_object(previous_client, MIXED_BUCKET, &last_multipart_key, None).await?.1,
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
);
@@ -1145,20 +790,12 @@ fn previous_baseline_writers(clients: &[Client], restarted_node: usize) -> &[Cli
}
async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnvironment, previous_binary: &Path) -> TestResult {
prepare_release_baseline(cluster, previous_binary, "previous").await
}
async fn prepare_release_baseline(
cluster: &mut RustFSTestClusterEnvironment,
previous_binary: &Path,
prefix: &str,
) -> TestResult {
let clients: Vec<_> = cluster.create_all_clients()?.iter().map(upgrade_probe_client).collect();
// rc.5 can latch a non-elected node's write fence while its first startup
// waits for pool metadata (#7473). First prove the elected writer can
// persist data, then restart each old process once with three peers still
// readable. The same preparation is reused only after a quiescent homogeneous rollback.
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, &format!("{prefix}-seed"), LISTING_CONVERGENCE_TIMEOUT).await?;
// readable. This preparation ends before any current binary is started.
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
// A node that just came back is still replaying its startup traffic against
// every peer. Killing the next node during that burst gives the fresh node
// two failures in a row and it drops that peer's drive to Offline, where
@@ -1168,7 +805,7 @@ async fn prepare_release_baseline(
for node in [1, 2, 3, 0] {
cluster.stop_node(node)?;
cluster.start_node_from_binary(node, previous_binary).await?;
let context = format!("{prefix}-restart-{node}");
let context = format!("previous-restart-{node}");
wait_for_upgrade_write_readiness(
std::slice::from_ref(&clients[node]),
MIXED_BUCKET,
@@ -1185,17 +822,15 @@ async fn prepare_release_baseline(
.await?;
}
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, &format!("{prefix}-baseline"), LISTING_CONVERGENCE_TIMEOUT).await?;
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
for (reader, client) in clients.iter().enumerate() {
assert_eq!(
read_object(client, MIXED_BUCKET, &format!(".upgrade-readiness/{prefix}-seed/node-0"), None)
.await?
.1,
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
UPGRADE_READINESS_BODY,
"previous-release node {reader} must retain the seed across its rolling restart"
);
for writer in 0..clients.len() {
let key = format!(".upgrade-readiness/{prefix}-baseline/node-{writer}");
let key = format!(".upgrade-readiness/previous-baseline/node-{writer}");
assert_eq!(
read_object(client, MIXED_BUCKET, &key, None).await?.1,
UPGRADE_READINESS_BODY,
@@ -2248,21 +1883,10 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
cluster.start_with_binary(&previous_binary).await?;
cluster.create_test_bucket(MIXED_BUCKET).await?;
prepare_previous_release_baseline(&mut cluster, &previous_binary).await?;
exercise_homogeneous_multipart(&cluster, "homogeneous-previous").await?;
cluster.create_test_bucket(PENDING_MULTIPART_BUCKET).await?;
let previous_clients = cluster.create_all_clients()?;
enable_versioning(&previous_clients[0], PENDING_MULTIPART_BUCKET).await?;
let pending_parts = vec![vec![0x63; 5 * 1024 * 1024], vec![0x7d; 4096]];
let pending = stage_multipart(&previous_clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, &pending_parts).await?;
let sources = wait_for_pending_source_bytes(&cluster, &pending).await?;
assert_pending_parts(&previous_clients[0], &pending).await?;
cluster.stop_node(0)?;
cluster.start_node_from_binary(0, &current_binary).await?;
exercise_mixed_cluster(&cluster, "one-current-node", 0, 1).await?;
// One controlled out-of-policy attempt proves fail-closed behavior below
// native write quorum; positive MPU writers remain paused while mixed.
assert_mixed_pending_refusal(&cluster, &pending, &sources).await?;
for (node_idx, phase) in [(1, "two-current-nodes"), (2, "one-previous-node")] {
cluster.stop_node(node_idx)?;
@@ -2274,9 +1898,6 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
cluster.start_node_from_binary(3, &current_binary).await?;
let clients = cluster.create_all_clients()?;
wait_for_drive_readmission(&clients, MIXED_BUCKET, "homogeneous-current", DRIVE_READMISSION_TIMEOUT).await?;
let completed_pending = finish_pending_multipart(&clients, &pending, pending_parts.concat()).await?;
exercise_homogeneous_multipart(&cluster, "homogeneous-current").await?;
verify_load_bodies(&clients, "homogeneous-previous", "multipart").await?;
for (node_idx, client) in clients.iter().enumerate() {
assert_eq!(
@@ -2295,6 +1916,47 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
}
}
if let Some(context) = evidence_context.as_ref() {
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
write_g09_evidence(
context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"assertions": [
"current node reads objects written through previous-release client",
"previous-release node reads objects written through current client",
"all nodes list every mixed-version object after homogeneous-current convergence",
"the previous-release seed survives the baseline restart and every upgrade phase"
],
}),
)?;
write_g09_evidence(
context,
"mixed_version_writer_evidence",
"mixed-version-writer",
&["old-reader-new-writer", "new-reader-old-writer"],
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"assertions": [
"current writer publishes objects readable by previous-release node",
"previous-release writer publishes objects readable by current node",
"multipart writers continue at every individual node upgrade",
"modern DELETE fails closed and preserves every byte while old peers remain"
],
}),
)?;
}
let clients = cluster.create_all_clients()?;
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "all-current", LISTING_CONVERGENCE_TIMEOUT).await?;
clients[0]
@@ -2318,115 +1980,6 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
);
}
wait_for_multipart_quiescence(&cluster, &sources).await?;
// No MPU mutation runs during a heterogeneous downgrade. All current
// callbacks and source controls settle before old binaries reopen volumes.
cluster.stop();
cluster.start_with_binary(&previous_binary).await?;
prepare_release_baseline(&mut cluster, &previous_binary, "rollback").await?;
let old_clients = cluster.create_all_clients()?;
assert_resumed_multipart(&old_clients, &completed_pending).await?;
for phase in ["homogeneous-previous", "homogeneous-current"] {
verify_load_bodies(&old_clients, phase, "multipart").await?;
for client in &old_clients {
wait_for_phase_listing(
client,
phase,
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
"previous reader after rollback",
)
.await?;
}
}
for phase in ["one-current-node", "two-current-nodes", "one-previous-node"] {
verify_load_bodies(&old_clients, phase, "put").await?;
for client in &old_clients {
wait_for_phase_listing(
client,
phase,
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2,
"previous reader after rollback",
)
.await?;
for (key, body) in [
("written-by-current", format!("{phase}: current RustFS build")),
("written-by-previous", format!("{phase}: previous RustFS release")),
] {
assert_eq!(
read_object(client, MIXED_BUCKET, &format!("{phase}/{key}"), None).await?.1,
body.as_bytes()
);
}
}
}
for (node, client) in old_clients.iter().enumerate() {
let error = client
.get_object()
.bucket(MIXED_BUCKET)
.key(PREVIOUS_RELEASE_SEED_KEY)
.send()
.await
.expect_err("current DELETE must remain visible to previous readers after rollback");
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("NoSuchKey"),
"reader {node}"
);
}
exercise_homogeneous_multipart(&cluster, "homogeneous-rollback").await?;
if let Some(context) = evidence_context.as_ref() {
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
write_g09_evidence(
context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"mixed_load": "plain-put",
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
"assertions": [
"current node reads objects written through previous-release client",
"previous-release node reads objects written through current client",
"all nodes list every mixed-version object after homogeneous-current convergence",
"the previous-release seed survives the baseline restart and every upgrade phase",
"previous readers read every current-native MPU body after quiescent rollback",
"same legacy upload resumes with identical body, ETag and version after full upgrade"
],
}),
)?;
write_g09_evidence(
context,
"mixed_version_writer_evidence",
"mixed-version-writer",
&["old-reader-new-writer", "new-reader-old-writer"],
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"mixed_load": "plain-put",
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
"assertions": [
"current writer publishes objects readable by previous-release node",
"previous-release writer publishes objects readable by current node",
"mixed phases retain 16x16 plain PUT writers while positive multipart writers are paused",
"16x16 multipart load runs in homogeneous previous, current and rollback phases",
"one-current-node Complete returns typed 503 and preserves actual source authority and parts",
"the same legacy upload completes and replays once fully upgraded without adding a version",
"modern DELETE fails closed and preserves every byte while old peers remain"
],
}),
)?;
}
Ok(())
}
-3
View File
@@ -12,9 +12,6 @@ Applies to `crates/ecstore/`.
- Be careful with allocations and locking in hot paths.
- Keep network and disk operations async-friendly; avoid introducing unnecessary blocking.
- Coalesce related `open`, `read`/`write`, `fsync`, and `close` filesystem steps into a single `blocking_queue` or `run_blocking_namespace_operation` closure per disk rather than chaining multiple `tokio::fs` or `spawn_blocking` dispatches.
- Pin a parent directory once (`openat2(BENEATH | NO_SYMLINKS)` on Linux) and reuse the pinned guard across sibling metadata reads (`read_optional_in_pinned`, `part.N.meta` loops) instead of re-walking ancestor components per file or re-opening when `source_parent == destination_parent`.
- After `file.metadata()`, pre-size read buffers (`Vec::with_capacity(len + 1)`) before `read_to_end` to avoid `std::io::read_to_end`'s 32-byte exponential growth (`6–7` `read()` syscalls per small metadata file).
- Benchmark-sensitive changes should include measurable rationale.
## Allocation Discipline in Hot Paths
+5
View File
@@ -33,6 +33,11 @@ workspace = true
default = []
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
# Compiles the controlled list-objects namespace-journal chaos injector into a
# production binary (it is always available to tests). Off by default so the
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
# state in a stock build (backlog#1832).
list-chaos = []
rio-v2 = ["dep:rustfs-rio-v2"]
hotpath = [
"hotpath/hotpath",
+52 -27
View File
@@ -382,6 +382,43 @@ pub mod config {
with_config_object_read_lock, with_config_object_write_lock, with_server_config_read_lock,
with_server_config_write_lock,
};
/// Observe persisted config bytes and their reader-associated metadata within a byte limit.
pub async fn read_config_observation(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
max_bytes: usize,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_observation(api, file, max_bytes).await
}
/// Observe bounded config bytes while the caller retains the object's write owner.
pub async fn read_config_observation_no_lock(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
max_bytes: usize,
guard: &crate::object_api::WriteCommitGuard,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_observation_no_lock(api, file, max_bytes, guard).await
}
/// Read config bytes and metadata without treating an empty payload as absent.
pub async fn read_config_preserve_empty_with_metadata_opts(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
opts: &crate::object_api::ObjectOptions,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_preserve_empty_with_metadata_opts(api, file, opts).await
}
pub async fn read_config_limited_preserve_empty_with_metadata_opts(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
opts: &crate::object_api::ObjectOptions,
max_bytes: usize,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_limited_preserve_empty_with_metadata_opts(api, file, opts, max_bytes).await
}
}
pub mod storageclass {
@@ -429,31 +466,21 @@ pub mod data_usage {
pub mod disk {
pub use crate::disk::disk_store::get_object_disk_read_timeout;
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
#[cfg(feature = "test-util")]
pub use crate::disk::local::{local_durability_modes, set_local_rename_data_fail_after_metadata_commit};
pub use crate::disk::multipart_source::{
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceResponse,
MultipartSourceSnapshot, PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage, capture_source_condition,
require_source_condition, strip_native_controls,
};
#[cfg(all(feature = "test-util", unix))]
pub use crate::disk::os::set_local_dir_fsync_failure;
#[cfg(all(feature = "test-util", not(windows)))]
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
pub use crate::disk::{ArtifactInventoryFrame, ArtifactInventoryVersion};
pub use crate::disk::{
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration, CurrentDeleteTransition,
DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest,
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
POOL_META_NATIVE_CAS_METADATA_SUFFIX, PartTransactionAction, PoolMetaNativeBackup, PoolMetaNativeInspection,
PoolMetaNativeObject, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse,
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET,
ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken,
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path, new_disk,
validate_batch_read_version_item_count,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, LegacyFileStream, LegacyFileWrite,
LegacyMetadataWrite, LegacyPartMutation, LegacyPartOperation, LegacyPathDelete, LegacyPathDeleteOperation,
LegacyRenameData, LegacyRenameFile, NsScannerOpenRequest, OldCurrentSize, PartTransactionAction,
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path,
new_disk, validate_batch_read_version_item_count,
};
pub use bytes::Bytes;
pub use endpoint::Endpoint;
@@ -526,16 +553,13 @@ pub mod metrics {
pub mod notification {
pub use crate::services::notification_sys::{
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
LegacyTransitionStateReconcileFleetProofToken, MrfLifecycleWriterFleetProofToken, NotificationPeerErr, NotificationSys,
ScannerPublicationLeaseGrant, acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
acquire_legacy_transition_state_reconcile_fleet_proof, acquire_mrf_lifecycle_writer_fleet_proof,
configured_fleet_topology_fingerprint, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_local_process_epoch,
ilm_recovery_export_member_epochs_sha256, ilm_recovery_export_topology_generation,
legacy_transition_state_reconcile_fleet_proof_matches, mrf_lifecycle_reader_local_process_epoch,
mrf_lifecycle_writer_fleet_proof_current, mrf_lifecycle_writer_fleet_proof_matches, new_global_notification_sys,
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
with_mrf_lifecycle_writer_fleet_proof,
LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
acquire_legacy_transition_state_reconcile_fleet_proof, configured_fleet_topology_fingerprint,
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, ilm_recovery_export_fleet_proof_matches,
ilm_recovery_export_local_process_epoch, ilm_recovery_export_member_epochs_sha256,
ilm_recovery_export_topology_generation, legacy_transition_state_reconcile_fleet_proof_matches,
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
};
#[cfg(any(test, feature = "test-util"))]
pub use crate::services::notification_sys::{
@@ -651,6 +675,7 @@ pub mod store_list {
}
pub mod storage {
pub use crate::bucket::artifact::inventory::ArtifactAttemptInventoryFrame;
pub use crate::core::pools::{HealLifecycleExpiryContext, POOL_META_NAME};
pub use crate::store::HealWalkVersion;
pub use crate::store::{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,120 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::bucket::metadata::BUCKET_INCARNATION_FILE;
use crate::config::com::read_config_observation;
use crate::object_api::WriteCompletion;
impl ECStore {
/// Permanently retain this managed bucket's versions for historical restores.
/// Lifecycle WRITE drains earlier exact deletes. The detached owner keeps
/// lifecycle -> metadata transaction locks until every metadata write drains.
pub async fn retain_artifact_backups(self: Arc<Self>, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<()> {
let bucket = bucket.to_owned();
let base_prefix = base_prefix.to_owned();
let ctx = Arc::clone(&self.ctx);
ctx.run_detached_mutation(async move {
require_put_seal_topology(&self).await?;
if self.ctx.lock_manager().is_disabled() {
return Err(Error::other("artifact backup retention requires namespace locking"));
}
let lifecycle = self.acquire_bucket_lifecycle_write_lock(&bucket).await?;
let transaction = metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, &bucket).await?;
let mut metadata = self.backup_retention_metadata(&bucket, incarnation, &base_prefix).await?;
let check = || {
if lifecycle.is_lock_lost() || transaction.is_lock_lost() {
Err(Error::other("artifact backup retention fence was lost"))
} else {
Ok(())
}
};
check()?;
// Even an idempotent retry rewrites with TailDrained: an earlier
// interrupted call may have reached only some metadata replicas.
metadata.artifact_backup_retained = true;
let mut opts = ObjectOptions {
max_parity: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
};
opts.add_bucket_lifecycle_lock_guard(&lifecycle);
opts.add_namespace_lock_guard(&transaction);
metadata.save_with_store_options(self.clone(), &opts).await?;
check()?;
let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?;
let sys = sys.read().await.clone();
sys.set_validated(metadata).await?;
check()
})
.await
.map_err(|_| Error::other("artifact backup retention owner failed"))?
}
/// Observe the immutable bucket identity and retention in one locked read.
pub async fn inspect_artifact_backup_retention(&self, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<bool> {
require_put_seal_topology(self).await?;
if self.ctx.lock_manager().is_disabled() {
return Err(Error::other("artifact backup retention requires namespace locking"));
}
let lifecycle = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
let metadata = self.backup_retention_metadata(bucket, incarnation, base_prefix).await?;
if lifecycle.is_lock_lost() {
return Err(Error::other("artifact backup retention observation fence was lost"));
}
Ok(metadata.artifact_backup_retained)
}
async fn backup_retention_metadata(&self, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<BucketMetadata> {
if incarnation.is_nil() || !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(base_prefix) {
return Err(Error::other("artifact backup retention identity is invalid"));
}
let mut metadata = self.read_backup_retention_metadata(bucket).await?;
let store = metadata_sys::object_store_in(&self.ctx).await?;
let (sidecar, _) =
read_config_observation(store, &format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}"), 16).await?;
metadata.parse_all_configs()?;
if metadata.bucket_incarnation_id != incarnation
|| Uuid::from_slice(&sidecar).ok() != Some(incarnation)
|| metadata.artifact_base_prefix.as_deref() != Some(base_prefix)
|| !metadata.bucket_creation_committed
|| !metadata.versioning_config.as_ref().is_some_and(|versioning| {
versioning.enabled()
&& !versioning.exclude_folders.unwrap_or(false)
&& versioning.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
})
{
return Err(Error::other("artifact backup retention identity mismatch"));
}
metadata.bucket_incarnation_sidecar = true;
Ok(metadata)
}
/// Caller must hold the bucket lifecycle fence. An unreadable or missing
/// record cannot prove that historical backup protection was never enabled.
pub(crate) async fn require_no_artifact_backup_retention(&self, bucket: &str) -> Result<()> {
if self.read_backup_retention_metadata(bucket).await?.artifact_backup_retained {
return Err(Error::other("artifact versions are retained for backups"));
}
Ok(())
}
async fn read_backup_retention_metadata(&self, bucket: &str) -> Result<BucketMetadata> {
if bucket.is_empty()
|| super::super::utils::has_bad_path_component(bucket)
|| super::super::utils::is_meta_bucketname(bucket)
{
return Err(Error::other("artifact backup retention bucket is invalid"));
}
let store = metadata_sys::object_store_in(&self.ctx).await?;
if store.id != self.id {
return Err(Error::other("artifact backup retention belongs to another store"));
}
let (bytes, _) = read_config_observation(store, &BucketMetadata::new(bucket).save_file_path(), 64 * 1024 * 1024).await?;
let metadata = BucketMetadata::decode_file(&bytes)?;
if metadata.name != bucket {
return Err(Error::other("artifact backup retention bucket mismatch"));
}
Ok(metadata)
}
}
@@ -0,0 +1,154 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::storage_api_contracts::object::ObjectOperations as _;
/// In-process authority for exactly one drained managed version. No caller can
/// construct this capability or serialize it into a disk/RPC request.
#[doc(hidden)]
pub struct ArtifactDeleteTarget {
store: Uuid,
record: ArtifactVersionRetirementRecord,
lifecycle: Arc<rustfs_lock::NamespaceLockGuard>,
attempt: WriteCommitGuard,
repair: WriteCommitGuard,
}
impl ArtifactDeleteTarget {
fn check_live(&self) -> Result<()> {
if self.lifecycle.is_lock_lost() || self.attempt.is_lock_lost() || self.repair.is_lock_lost() {
return Err(Error::other("artifact exact delete owned fence was lost"));
}
Ok(())
}
pub(crate) fn lifecycle(&self) -> Arc<rustfs_lock::NamespaceLockGuard> {
Arc::clone(&self.lifecycle)
}
pub(crate) fn validate(&self, store: &ECStore, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<()> {
if self.store != store.id
|| bucket != self.record.bucket
|| rustfs_utils::path::encode_dir_object(object) != self.record.object
|| opts.expected_bucket_incarnation_id != Some(self.record.incarnation_id)
|| opts.version_id.as_deref() != Some(self.record.version_id.to_string().as_str())
|| !opts.versioned
|| opts.version_suspended
|| opts.delete_prefix
|| opts.delete_marker
|| opts.data_movement
|| opts.lifecycle_delete_all.is_some()
|| self.lifecycle.is_lock_lost()
|| self.attempt.is_lock_lost()
|| self.repair.is_lock_lost()
{
return Err(Error::other("artifact exact delete target or owned fence mismatch"));
}
Ok(())
}
}
impl ECStore {
/// Read-only capability observation; each mutation revalidates the topology.
pub async fn supports_artifact_exact_delete(&self) -> bool {
require_local_put_seal_topology(self).await.is_ok() && !self.ctx.lock_manager().is_disabled()
}
/// Delete an exact version in a fully sealed attempt. This authenticates the
/// storage identity and drains writers; the caller still owns GC eligibility
/// and cleanup IAM. Distributed receiver deletion is not yet supported.
pub async fn delete_artifact_object_version(
self: Arc<Self>,
bucket: &str,
expected_incarnation: Uuid,
attempt_prefix: &str,
object: &str,
version: Uuid,
) -> Result<()> {
require_local_put_seal_topology(&self).await?;
if self.ctx.lock_manager().is_disabled() {
return Err(Error::other("artifact exact deletion requires namespace locking"));
}
let lifecycle = Arc::new(self.acquire_bucket_lifecycle_read_lock(bucket).await?);
self.require_no_artifact_backup_retention(bucket).await?;
let put = self
.inventory_artifact_record(bucket, expected_incarnation, attempt_prefix)
.await?;
let repair = ArtifactRepairSealRecord::new(&put);
let record = ArtifactVersionRetirementRecord::new(&put, object, version)?;
let object = object.to_owned();
let store = Arc::clone(&self);
// Lifecycle -> attempt -> repair seal -> metadata transaction -> object.
// The spawned owner, and the disk deletion tail's typed capability, keep
// these locks after cancellation of the HTTP caller.
with_config_object_write_lock(self, put.path(), move |attempt| async move {
if !put.exists(Arc::clone(&store), &attempt).await? {
return Err(Error::other("artifact exact deletion requires sealed PUTs"));
}
let lock = store.new_ns_lock(RUSTFS_META_BUCKET, &repair.path()).await?;
let repair_owner = WriteCommitGuard::acquire(&lock, get_lock_acquire_timeout()).await?;
if !repair.exists(Arc::clone(&store), &repair_owner).await? {
return Err(Error::other("artifact exact deletion requires sealed repairs"));
}
let receivers = store.capture_artifact_receivers().await?;
store
.close_artifact_receiver_snapshot(&put, crate::disk::local::ArtifactLeaseDomain::Put, &receivers)
.await?;
store
.close_artifact_receiver_snapshot(&put, crate::disk::local::ArtifactLeaseDomain::Repair, &receivers)
.await?;
store.verify_artifact_receiver_snapshot(&put, &receivers).await?;
let target = Arc::new(ArtifactDeleteTarget {
store: store.id,
record,
lifecycle,
attempt,
repair: repair_owner,
});
let mut opts = ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
expected_bucket_incarnation_id: Some(expected_incarnation),
artifact_delete_target: Some(Arc::clone(&target)),
delete_mutation_owner: Some(target.clone()),
..Default::default()
};
opts.add_write_commit_guard(&target.attempt);
opts.add_write_commit_guard(&target.repair);
// These owners fence seal records, not the object. Ordinary DELETE
// must still acquire and retain its exact object WRITE owner.
opts.no_lock = false;
target.check_live()?;
// Preserve the normal exact DELETE's Object Lock, quorum, journal,
// cache and capacity behavior. Its success is logical deletion, not
// a claim that every offline shard has been physically erased.
match store.delete_object(&put.bucket, &object, opts).await {
Ok(info) => {
// Match the S3 request layer's committed-generation quota
// delta. A strict absence retry must not subtract twice.
if !info.name.is_empty()
&& !info.delete_marker
&& let Ok(size) = crate::data_usage::quota_object_size(&info)
{
crate::data_usage::record_bucket_object_delete_memory(&put.bucket, size, false).await;
}
}
Err(Error::VersionNotFound(bucket, object, version))
if bucket == target.record.bucket
&& rustfs_utils::path::encode_dir_object(&object) == target.record.object
&& version == target.record.version_id.to_string() => {}
Err(Error::ObjectNotFound(bucket, object))
if bucket == target.record.bucket
&& rustfs_utils::path::encode_dir_object(&object) == target.record.object => {}
Err(error) => return Err(error),
}
store.verify_artifact_receiver_snapshot(&put, &receivers).await?;
store
.inventory_artifact_record(&put.bucket, expected_incarnation, &put.attempt_prefix)
.await?;
target.check_live()
})
.await?
}
}
@@ -0,0 +1,207 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::disk::{ArtifactInventoryFrame, ArtifactInventoryVersion, Disk};
use rustfs_protos::proto_gen::node_service::ArtifactInventoryPhase;
use tokio::{sync::mpsc, time::Instant};
/// Physical observations retain their member. Replicas are not extra business
/// versions and this stream makes no quorum-readability or GC claim.
#[derive(Debug)]
pub enum ArtifactAttemptInventoryFrame {
Observations {
endpoint: String,
disk_id: Uuid,
versions: Vec<ArtifactInventoryVersion>,
},
Complete {
members: usize,
observations: u64,
},
}
impl ECStore {
/// Inventory one fully sealed attempt across every configured receiver.
/// Earlier output is staging only; only the final frame plus Ok is complete.
pub async fn inventory_artifact_attempt(
self: Arc<Self>,
bucket: &str,
incarnation: Uuid,
prefix: &str,
deadline: Instant,
output: mpsc::Sender<ArtifactAttemptInventoryFrame>,
) -> Result<()> {
tokio::time::timeout_at(deadline, Box::pin(async move {
require_put_seal_topology(&self).await?;
if self.ctx.lock_manager().is_disabled() { return Err(Error::other("artifact inventory requires namespace locking")); }
let lifecycle = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
let put = self.inventory_artifact_record(bucket, incarnation, prefix).await?;
let repair = ArtifactRepairSealRecord::new(&put);
// Lifecycle -> attempt -> repair record. Receivers do not reenter
// distributed locks; actual two-domain disk owners guard their reads.
let store = self.clone();
let bucket = bucket.to_owned();
let prefix = prefix.to_owned();
with_config_object_write_lock(self, put.path(), move |attempt| async move {
tokio::time::timeout_at(deadline, async {
if !put.exists(store.clone(), &attempt).await? { return Err(Error::other("artifact inventory requires PUT seal record")); }
let lock = store.new_ns_lock(RUSTFS_META_BUCKET, &repair.path()).await?;
let repair_owner = WriteCommitGuard::acquire(&lock, get_lock_acquire_timeout()).await?;
if !repair.exists(store.clone(), &repair_owner).await? { return Err(Error::other("artifact inventory requires repair seal record")); }
let receivers = store.capture_artifact_receivers().await?;
let mut observations = 0u64;
for (endpoint, (disk, expected)) in &receivers {
if lifecycle.is_lock_lost() || attempt.is_lock_lost() || repair_owner.is_lock_lost() {
return Err(Error::other("artifact inventory fence was lost"));
}
let (sender, mut frames) = mpsc::channel(2);
let scan = async {
match disk.as_ref() {
Disk::Local(local) => local.get_disk().inventory_artifact_attempt(*expected, &bucket, &prefix, put.identity(), deadline, sender).await,
Disk::Remote(remote) => remote.inventory_artifact_attempt(*expected, (&bucket, incarnation), &prefix, ArtifactInventoryPhase::ArtifactInventoryScan, deadline, sender).await,
}
};
tokio::pin!(scan);
let mut sequence = 0;
let mut count = 0u64;
let mut terminal = false;
let mut ended = false;
let mut done = false;
while !ended || !done {
tokio::select! {
result = &mut scan, if !done => { result.map_err(Error::other)?; done = true; },
frame = frames.recv(), if !ended => match frame {
Some(ArtifactInventoryFrame::Entries { sequence: actual, versions }) if !terminal && actual == sequence => {
count = count.checked_add(u64::try_from(versions.len()).map_err(Error::other)?).ok_or_else(|| Error::other("artifact inventory count overflow"))?;
output.send(ArtifactAttemptInventoryFrame::Observations { endpoint: endpoint.clone(), disk_id: *expected, versions }).await
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
sequence += 1;
}
Some(ArtifactInventoryFrame::Complete { disk_id, incarnation: actual, attempt_prefix, entries })
if !terminal && disk_id == *expected && actual == incarnation && attempt_prefix == prefix && entries == count => terminal = true,
Some(_) => return Err(Error::other("artifact inventory member stream is invalid")),
None => ended = true,
},
}
}
if !terminal { return Err(Error::other("artifact inventory member is incomplete")); }
observations = observations.checked_add(count).ok_or_else(|| Error::other("artifact inventory count overflow"))?;
}
store.verify_inventory_members(&put, &receivers, deadline).await?;
let current = store.artifact_inventory_receiver_identity(&bucket, incarnation, &prefix).await?;
if current.receiver_key != put.identity().receiver_key || lifecycle.is_lock_lost() || attempt.is_lock_lost() || repair_owner.is_lock_lost() {
return Err(Error::other("artifact inventory authority changed"));
}
output.send(ArtifactAttemptInventoryFrame::Complete { members: receivers.len(), observations }).await
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
Ok(())
}).await.map_err(|_| Error::other("artifact inventory deadline exceeded"))?
}).await?
})).await.map_err(|_| Error::other("artifact inventory deadline exceeded"))?
}
pub(super) async fn inventory_artifact_record(&self, bucket: &str, expected: Uuid, prefix: &str) -> Result<PutSealRecord> {
use crate::bucket::metadata::BUCKET_INCARNATION_FILE;
use crate::config::com::read_config_observation;
if expected.is_nil()
|| crate::bucket::utils::has_bad_path_component(bucket)
|| bucket.is_empty()
|| crate::bucket::utils::is_meta_bucketname(bucket)
{
return Err(Error::other("artifact inventory authority is invalid"));
}
let store = metadata_sys::object_store_in(&self.ctx).await?;
if store.id != self.id {
return Err(Error::other("artifact inventory authority belongs to another store"));
}
// Do not enter metadata_sys's lazy bucket READ/migration path: the
// coordinator already owns lifecycle READ and a writer may be queued.
// Observation reads bypass cache and suppress physical read repair.
let path = BucketMetadata::new(bucket).save_file_path();
let (bytes, _) = read_config_observation(store.clone(), &path, 64 * 1024 * 1024).await?;
let mut metadata = BucketMetadata::decode_file(&bytes)?;
let (sidecar, _) =
read_config_observation(store, &format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}"), 16).await?;
if metadata.name != bucket
|| !metadata.bucket_creation_committed
|| metadata.bucket_incarnation_id != expected
|| Uuid::from_slice(&sidecar).ok() != Some(expected)
{
return Err(Error::other("artifact inventory incarnation mismatch"));
}
metadata.parse_all_configs()?;
if !metadata.versioning_config.as_ref().is_some_and(|config| {
config.enabled()
&& !config.exclude_folders.unwrap_or(false)
&& config.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
}) {
return Err(Error::other("artifact inventory requires managed versioning"));
}
PutSealRecord::new(&metadata, prefix)
}
pub(crate) async fn artifact_inventory_receiver_identity(
&self,
bucket: &str,
expected: Uuid,
prefix: &str,
) -> Result<ArtifactPutIdentity> {
Ok(self.inventory_artifact_record(bucket, expected, prefix).await?.identity())
}
async fn verify_inventory_members(
&self,
record: &PutSealRecord,
captured: &ArtifactReceiverSnapshot,
deadline: Instant,
) -> Result<()> {
for final_pass in [false, true] {
let current = self.artifact_receiver_inventory().await?;
if current.len() != captured.len()
|| current
.iter()
.any(|(endpoint, disk)| !captured.get(endpoint).is_some_and(|(old, _)| Arc::ptr_eq(old, disk)))
{
return Err(Error::other("artifact inventory membership changed"));
}
if final_pass {
for (disk, expected) in captured.values() {
if disk.cached_disk_id().await != Some(*expected) {
return Err(Error::other("artifact inventory expected identity changed"));
}
}
break;
}
for (disk, expected) in captured.values() {
if disk.cached_disk_id().await != Some(*expected) {
return Err(Error::other("artifact inventory expected identity changed"));
}
match disk.as_ref() {
Disk::Local(local) => {
let _verification = local
.get_disk()
.verify_artifact_inventory_closed(*expected, record.identity(), deadline)
.await
.map_err(Error::other)?;
}
Disk::Remote(remote) => {
let (output, _unused) = mpsc::channel(1);
remote
.inventory_artifact_attempt(
*expected,
(&record.bucket, record.incarnation_id),
&record.attempt_prefix,
ArtifactInventoryPhase::ArtifactInventoryVerify,
deadline,
output,
)
.await
.map_err(Error::other)?;
}
}
}
}
Ok(())
}
}
@@ -0,0 +1,287 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, Weak};
/// Shares only reads started after a caller arrives; retains no idle results.
#[derive(Default)]
pub(crate) struct OrdinaryClassificationRegistry {
gates: Mutex<HashMap<String, Weak<Gate>>>,
#[cfg(test)]
after_unregister: Option<std::sync::Barrier>,
}
#[derive(Default)]
struct Gate {
started_generation: AtomicU64,
completed: tokio::sync::Mutex<Option<CompletedRead>>,
}
struct CompletedRead {
generation: u64,
allowed: bool,
}
struct Registration<'a> {
registry: &'a OrdinaryClassificationRegistry,
bucket: &'a str,
gate: Option<Arc<Gate>>,
observed_generation: u64,
}
impl OrdinaryClassificationRegistry {
pub(crate) async fn classify(&self, bucket: &str, read: impl Future<Output = bool>) -> bool {
self.register(bucket).classify(read).await
}
fn register<'a>(&'a self, bucket: &'a str) -> Registration<'a> {
// Callers requiring physical admission must register after acquiring it.
// Release the registry lock before awaiting the gate, then acquire the
// existing metadata locks.
let mut gates = self.gates.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let gate = gates.get(bucket).and_then(Weak::upgrade).unwrap_or_else(|| {
let gate = Arc::new(Gate::default());
gates.insert(bucket.to_owned(), Arc::downgrade(&gate));
gate
});
let observed_generation = gate.started_generation.load(Ordering::Acquire);
Registration {
registry: self,
bucket,
gate: Some(gate),
observed_generation,
}
}
}
impl Registration<'_> {
async fn classify(self, read: impl Future<Output = bool>) -> bool {
let Some(gate) = self.gate.as_ref() else {
return false;
};
let mut completed = gate.completed.lock().await;
// Only a read started after this caller registered can authorize it.
// Arrivals during a read therefore wait for the next fresh read.
if let Some(result) = completed
.as_ref()
.filter(|result| result.generation > self.observed_generation)
{
return result.allowed;
}
let Ok(previous) = gate
.started_generation
.try_update(Ordering::AcqRel, Ordering::Acquire, |generation| generation.checked_add(1))
else {
return false;
};
// Keep the gate through the original bounded metadata read. Cancellation
// releases it without publishing a result; another caller can read again.
let allowed = read.await;
*completed = Some(CompletedRead {
generation: previous + 1,
allowed,
});
allowed
}
}
impl Drop for Registration<'_> {
fn drop(&mut self) {
let Some(gate) = self.gate.take() else {
return;
};
let mut gates = self.registry.gates.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Strong references are acquired and released under this registry lock,
// so concurrent final callers cannot both miss the last reference.
if Arc::strong_count(&gate) == 1
&& gates
.get(self.bucket)
.is_some_and(|registered| registered.ptr_eq(&Arc::downgrade(&gate)))
{
gates.remove(self.bucket);
}
drop(gate);
drop(gates);
#[cfg(test)]
if let Some(barrier) = &self.registry.after_unregister {
barrier.wait();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::poll;
use std::task::Poll;
use tokio::sync::oneshot;
fn assert_empty(registry: &OrdinaryClassificationRegistry) {
assert!(registry.gates.lock().expect("classification registry").is_empty());
}
#[tokio::test]
async fn late_arrivals_share_only_the_next_fail_closed_read() {
let registry = OrdinaryClassificationRegistry::default();
let reads = AtomicU64::new(0);
let (release, waiting) = oneshot::channel();
let mut first = Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
waiting.await.expect("release first read");
true
}));
assert!(poll!(first.as_mut()).is_pending());
let mut followers = (0..16)
.map(|_| {
Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
false
}))
})
.collect::<Vec<_>>();
for follower in &mut followers {
assert!(poll!(follower.as_mut()).is_pending());
}
release.send(()).expect("first reader still alive");
assert_eq!(poll!(first.as_mut()), Poll::Ready(true));
for follower in &mut followers {
assert_eq!(poll!(follower.as_mut()), Poll::Ready(false));
}
assert_eq!(reads.load(Ordering::Relaxed), 2);
assert_empty(&registry);
}
#[tokio::test]
async fn successive_waves_always_read_again() {
let registry = OrdinaryClassificationRegistry::default();
let reads = AtomicU64::new(0);
for expected in [true, false, true] {
assert_eq!(
registry
.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
expected
})
.await,
expected
);
assert_empty(&registry);
}
assert_eq!(reads.load(Ordering::Relaxed), 3);
}
#[tokio::test]
async fn canceled_leader_does_not_publish_or_strand_waiters() {
let registry = OrdinaryClassificationRegistry::default();
let reads = AtomicU64::new(0);
let old_registration = registry.register("bucket");
assert!(
registry
.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
true
})
.await
);
let mut leader = Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
std::future::pending::<bool>().await
}));
assert!(poll!(leader.as_mut()).is_pending());
let mut old_waiter =
Box::pin(old_registration.classify(async { panic!("old waiter must reuse the completed post-registration read") }));
assert!(poll!(old_waiter.as_mut()).is_pending());
let mut late_waiter = Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
false
}));
assert!(poll!(late_waiter.as_mut()).is_pending());
drop(leader);
assert_eq!(poll!(old_waiter.as_mut()), Poll::Ready(true));
assert_eq!(poll!(late_waiter.as_mut()), Poll::Ready(false));
assert_eq!(reads.load(Ordering::Relaxed), 3);
assert_empty(&registry);
}
#[tokio::test]
async fn canceled_follower_does_not_retain_the_gate() {
let registry = OrdinaryClassificationRegistry::default();
let (release, waiting) = oneshot::channel();
let mut leader = Box::pin(registry.classify("bucket", async {
waiting.await.expect("release leader");
true
}));
assert!(poll!(leader.as_mut()).is_pending());
let mut follower = Box::pin(registry.classify("bucket", async { panic!("canceled follower must not read") }));
assert!(poll!(follower.as_mut()).is_pending());
drop(follower);
assert_eq!(registry.gates.lock().expect("classification registry").len(), 1);
release.send(()).expect("leader still alive");
assert_eq!(poll!(leader.as_mut()), Poll::Ready(true));
assert_empty(&registry);
}
#[test]
fn concurrent_final_registrations_remove_the_gate() {
let registry = OrdinaryClassificationRegistry {
after_unregister: Some(std::sync::Barrier::new(2)),
..Default::default()
};
let first = registry.register("bucket");
let second = registry.register("bucket");
// Both destructors reach the unlocked barrier before either returns.
// Moving the Arc release after this barrier leaves a stale registry key.
std::thread::scope(|scope| {
scope.spawn(move || drop(first));
scope.spawn(move || drop(second));
});
assert_empty(&registry);
}
#[tokio::test]
async fn buckets_and_stores_do_not_share_gates() {
let first_store = OrdinaryClassificationRegistry::default();
let second_store = OrdinaryClassificationRegistry::default();
let mut blocked = Box::pin(first_store.classify("first", std::future::pending()));
assert!(poll!(blocked.as_mut()).is_pending());
assert_eq!(poll!(Box::pin(first_store.classify("second", async { true }))), Poll::Ready(true));
assert_eq!(poll!(Box::pin(second_store.classify("first", async { false }))), Poll::Ready(false));
assert_empty(&second_store);
drop(blocked);
assert_empty(&first_store);
}
#[tokio::test]
async fn generation_exhaustion_fails_closed_without_reading() {
let registry = OrdinaryClassificationRegistry::default();
let registration = registry.register("bucket");
let gate = registration.gate.as_ref().expect("registered gate");
gate.started_generation.store(u64::MAX, Ordering::Release);
*gate.completed.lock().await = Some(CompletedRead {
generation: u64::MAX,
allowed: true,
});
assert!(
!registry
.classify("bucket", async { panic!("exhausted generation must deny without reading") })
.await
);
assert_eq!(gate.started_generation.load(Ordering::Acquire), u64::MAX);
drop(registration);
assert_empty(&registry);
}
}
@@ -890,6 +890,7 @@ pub(crate) async fn cleanup_free_version_exact(
let bucket_guard = api
.acquire_bucket_lifecycle_read_lock(&oi.bucket)
.await
.map(Arc::new)
.map_err(std::io::Error::other)?;
let (lease, delete_plan) = acquire_free_version_tier_lease(oi, &api.tier_config_mgr()).await?;
let local_object = encode_dir_object(&oi.name);
@@ -958,7 +959,7 @@ pub(crate) async fn cleanup_free_version_exact(
};
delete_request.set_tier_free_version();
if let Err(err) = set
.delete_object_version(&oi.bucket, &local_object, &delete_request, false)
.delete_object_version_with_lifecycle_owner(&oi.bucket, &local_object, &delete_request, Arc::clone(&bucket_guard))
.await
&& first_error.is_none()
{
@@ -8496,6 +8497,104 @@ mod tests {
}
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial]
async fn artifact_delete_entrypoints_free_version_cleaner_completes_with_queued_lifecycle_writer() {
let (disk_paths, ecstore) = setup_test_env().await;
let bucket = format!("free-version-queued-writer-{}", Uuid::new_v4());
let object = "free-version";
create_test_bucket(&ecstore, &bucket).await;
let (backend, identity) = register_recovery_mock_tier(&ecstore).await;
seed_recoverable_free_version(&disk_paths, &bucket, object, None, Some(identity)).await;
let oi = list_tier_free_versions(Arc::clone(&ecstore), 100, None, None, CancellationToken::new())
.await
.expect("list real persisted free version")
.items
.into_iter()
.find(|oi| oi.bucket == bucket)
.expect("free-version task must address this fixture");
assert!(oi.transitioned_object.free_version);
backend
.set_put_remote_version(Some(oi.transitioned_object.version_id.clone()))
.await;
let remote = TierConfigMgr::acquire_operation_lease(&ecstore.tier_config_mgr(), "WARM")
.await
.expect("remote fixture lease");
remote
.put(&oi.transitioned_object.name, ReaderImpl::Body(Bytes::from_static(b"body")), 4)
.await
.expect("seed exact remote object");
drop(remote);
let barrier = Arc::new(super::FreeVersionPostRemoteDeleteTestBarrier::default());
let cleanup_barrier = Arc::clone(&barrier);
let cleanup_store = Arc::clone(&ecstore);
let cleanup_oi = oi.clone();
let mut cleanup = tokio::spawn(async move {
super::FREE_VERSION_POST_REMOTE_DELETE_TEST_BARRIER
.scope(cleanup_barrier, async move {
super::cleanup_free_version_exact(cleanup_store, &cleanup_oi, &CancellationToken::new()).await
})
.await
});
tokio::time::timeout(StdDuration::from_secs(10), barrier.arrived.notified())
.await
.expect("cleaner owns lifecycle READ and has completed remote DELETE");
assert!(!backend.contains(&oi.transitioned_object.name).await);
for disk in &disk_paths {
assert!(
disk.join(&bucket).join(object).join(STORAGE_FORMAT_FILE).is_file(),
"local exact cleanup must still be pending at the boundary"
);
}
// Resolve first so the pending poll is the actual local lock wait.
// A later READ would normally be admitted alongside the cleaner's READ;
// its pending state independently proves this WRITE is now queued.
let lifecycle = ecstore
.new_ns_lock(&bucket, crate::storage_api_contracts::bucket::BUCKET_LIFECYCLE_LOCK_OBJECT)
.await
.expect("lifecycle namespace");
let mut writer = Box::pin(lifecycle.get_write_lock(StdDuration::from_secs(30)));
let writer_queued = futures::poll!(&mut writer).is_pending();
let mut later_reader = Box::pin(lifecycle.get_read_lock(StdDuration::from_secs(30)));
let later_reader_blocked = futures::poll!(&mut later_reader).is_pending();
drop(later_reader);
barrier.release.notify_one();
assert!(writer_queued, "cleaner must retain the real lifecycle READ owner");
assert!(later_reader_blocked, "queued lifecycle WRITE must block a later READ");
// Keep the same WRITE future queued, without cancelling/retrying it.
// Re-entering READ here would deadlock behind this waiting writer.
let finished = tokio::time::timeout(StdDuration::from_secs(10), &mut cleanup).await;
if finished.is_err() {
cleanup.abort();
drop(writer);
let _ = tokio::time::timeout(StdDuration::from_secs(5), &mut cleanup).await;
panic!("exact local free-version cleanup re-entered lifecycle READ behind the queued writer");
}
assert!(
finished
.expect("bounded cleaner completion")
.expect("cleaner task")
.expect("exact local cleanup")
);
let writer = tokio::time::timeout(StdDuration::from_secs(10), writer)
.await
.expect("queued lifecycle WRITE proceeds after real cleanup drains")
.expect("lifecycle WRITE acquired");
for disk in &disk_paths {
assert!(!disk.join(&bucket).join(object).exists(), "free version remains on a real local disk");
}
assert_eq!(backend.remove_count().await, 1, "cleaner must delete the exact remote tuple once");
drop(writer);
ecstore
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("remove empty fixture bucket");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial]
@@ -11314,7 +11413,13 @@ mod tests {
let marker = marker
.as_deref()
.expect("production checkpoint should persist an object marker");
assert_eq!(marker, expected_marker, "production checkpoint should stop at the first page boundary");
let marker_suffix = marker
.strip_prefix(&expected_marker)
.expect("production checkpoint should stop at the first page boundary");
assert!(
marker_suffix.is_empty() || marker_suffix.starts_with("[rustfs_cache:v2,"),
"unexpected production checkpoint marker suffix: {marker_suffix}"
);
assert_eq!(version_marker.as_deref(), Some("null"));
}
@@ -23,7 +23,6 @@ use std::{
};
use futures::StreamExt;
use rand::RngExt as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
@@ -71,7 +70,6 @@ pub(crate) const EVENT_LIFECYCLE_TIER_DELETE_JOURNAL: &str = "lifecycle_tier_del
// successful pages while its wall-clock budget remains.
pub const DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT: usize = 8;
const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
const TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
const TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET: Duration = Duration::from_secs(240);
const TIER_DELETE_REMOTE_DEADLINE: Duration = Duration::from_secs(30);
@@ -6106,114 +6104,57 @@ pub(crate) async fn recover_test_tier_delete_journal_pass_with_budget(
recover_tier_delete_journal_pass(api, cancel_token, manifest_marker, journal_marker, budget).await
}
fn tier_delete_journal_recovery_pass_is_idle(stats: &TierDeleteJournalRecoveryPassStats) -> bool {
stats.manifest_pages > 0
&& stats.journal_pages > 0
&& stats.manifest_errors == 0
&& stats.journal_errors == 0
&& !stats.deadline_exhausted
&& !stats.canceled
&& stats.manifests == TierDeleteDispatchManifestRecoveryStats::default()
&& stats.journals == TierDeleteJournalRecoveryStats::default()
}
fn bounded_tier_delete_journal_recovery_delay(delay: Duration, percent: u32) -> Duration {
(delay.saturating_mul(percent) / 100)
.clamp(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL)
}
fn jitter_tier_delete_journal_recovery_delay(delay: Duration) -> Duration {
bounded_tier_delete_journal_recovery_delay(delay, rand::rng().random_range(90..=110))
}
async fn drive_tier_delete_journal_recovery(
cancel_token: &CancellationToken,
wait_for_wakeup: impl AsyncFn(),
mut recover: impl AsyncFnMut() -> TierDeleteJournalRecoveryPassStats,
jitter_delay: fn(Duration) -> Duration,
) {
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
let mut marker: Option<String> = None;
let mut manifest_marker: Option<String> = None;
loop {
tokio::select! {
biased;
_ = cancel_token.cancelled() => return,
_ = wait_for_wakeup() => {
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
interval.reset();
},
_ = interval.tick() => {},
_ = api.ctx.wait_for_tier_delete_journal_recovery() => {},
}
let stats = recover().await;
let stats = recover_tier_delete_journal_pass(
api.clone(),
&cancel_token,
&mut manifest_marker,
&mut marker,
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
)
.await;
if stats.canceled {
return;
}
if tier_delete_journal_recovery_pass_is_idle(&stats) {
// Only a completed empty sweep may defer discovery. Retained,
// failed, or paginated work keeps the original retry tick budget.
interval.reset_after(jitter_delay(idle_interval));
idle_interval = idle_interval
.saturating_mul(2)
.min(TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
} else {
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
}
debug!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
manifest_pages = stats.manifest_pages,
manifest_scanned = stats.manifests.scanned,
manifest_advanced = stats.manifests.advanced,
manifest_deleted = stats.manifests.deleted,
manifest_retained = stats.manifests.retained,
manifest_failed = stats.manifests.failed,
manifest_errors = stats.manifest_errors,
manifest_truncated = stats.manifests.truncated,
manifest_next_marker = ?manifest_marker,
journal_pages = stats.journal_pages,
journal_scanned = stats.journals.scanned,
journal_deleted = stats.journals.deleted,
journal_failed = stats.journals.failed,
journal_errors = stats.journal_errors,
journal_truncated = stats.journals.truncated,
journal_next_marker = ?marker,
deadline_exhausted = stats.deadline_exhausted,
"Recovered tier delete journal pass"
);
}
}
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
let mut marker: Option<String> = None;
let mut manifest_marker: Option<String> = None;
let wake_context = api.ctx.clone();
let recovery_cancel_token = cancel_token.clone();
drive_tier_delete_journal_recovery(
&cancel_token,
async move || wake_context.wait_for_tier_delete_journal_recovery().await,
async move || {
let stats = recover_tier_delete_journal_pass(
api.clone(),
&recovery_cancel_token,
&mut manifest_marker,
&mut marker,
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
)
.await;
if stats.canceled {
return stats;
}
debug!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
manifest_pages = stats.manifest_pages,
manifest_scanned = stats.manifests.scanned,
manifest_advanced = stats.manifests.advanced,
manifest_deleted = stats.manifests.deleted,
manifest_retained = stats.manifests.retained,
manifest_failed = stats.manifests.failed,
manifest_errors = stats.manifest_errors,
manifest_truncated = stats.manifests.truncated,
manifest_next_marker = ?manifest_marker,
journal_pages = stats.journal_pages,
journal_scanned = stats.journals.scanned,
journal_deleted = stats.journals.deleted,
journal_failed = stats.journals.failed,
journal_errors = stats.journal_errors,
journal_truncated = stats.journals.truncated,
journal_next_marker = ?marker,
deadline_exhausted = stats.deadline_exhausted,
"Recovered tier delete journal pass"
);
stats
},
jitter_tier_delete_journal_recovery_delay,
)
.await;
}
async fn await_tier_delete_journal_recovery<T, F>(
cancel_token: &CancellationToken,
timeout: Duration,
@@ -6239,20 +6180,16 @@ mod tests {
use super::{
PersistedTierDeleteJournalEntry, TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE,
TIER_DELETE_DISPATCH_PARENT_VERSION, TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX,
TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL,
TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION,
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V6_PREFIX,
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest,
TierDeleteDispatchManifestRecoveryStats, TierDeleteDispatchManifestState, TierDeleteDispatchParent,
TierDeleteDispatchParentState, TierDeleteDispatchRecord, TierDeleteJournalRecoveryPassStats,
await_tier_delete_journal_recovery, bounded_tier_delete_journal_recovery_delay, decode_tier_delete_dispatch_record,
decode_tier_delete_journal_entry, drive_tier_delete_journal_recovery, encode_tier_delete_dispatch_manifest,
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest, TierDeleteDispatchManifestState,
TierDeleteDispatchParent, TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery,
decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest,
encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete,
record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity,
tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name,
tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name,
tier_delete_journal_recovery_pass_is_idle, tier_delete_source_matches_dispatch_scope,
validate_legacy_tier_delete_recovery_source,
tier_delete_source_matches_dispatch_scope, validate_legacy_tier_delete_recovery_source,
};
use crate::bucket::lifecycle::tier_sweeper::{
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
@@ -6896,213 +6833,6 @@ mod tests {
assert!(err.to_string().contains("decode tier delete journal failed"));
}
fn empty_recovery_pass() -> TierDeleteJournalRecoveryPassStats {
TierDeleteJournalRecoveryPassStats {
manifest_pages: 1,
journal_pages: 1,
..Default::default()
}
}
#[test]
fn tier_delete_journal_recovery_requires_both_completed_empty_queues_for_idle() {
assert!(tier_delete_journal_recovery_pass_is_idle(&empty_recovery_pass()));
let mut cases = Vec::new();
cases.push(TierDeleteJournalRecoveryPassStats::default());
for change in [
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_pages = 0,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_pages = 0,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_errors = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_errors = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.deadline_exhausted = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.canceled = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.scanned = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.retained = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.failed = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.truncated = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.next_marker = Some("next".to_owned()),
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.scanned = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.failed = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.truncated = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.next_marker = Some("next".to_owned()),
] {
let mut stats = empty_recovery_pass();
change(&mut stats);
cases.push(stats);
}
for stats in cases {
assert!(
!tier_delete_journal_recovery_pass_is_idle(&stats),
"unfinished recovery was considered idle: {stats:?}"
);
}
}
#[test]
fn tier_delete_journal_recovery_jitter_preserves_discovery_bounds() {
for delay in [
Duration::ZERO,
Duration::from_secs(60),
Duration::from_secs(120),
Duration::from_secs(300),
Duration::MAX,
] {
for percent in [90, 100, 110] {
let jittered = bounded_tier_delete_journal_recovery_delay(delay, percent);
assert!(jittered >= TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
assert!(jittered <= TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
if delay == Duration::from_secs(120) {
assert!((Duration::from_secs(108)..=Duration::from_secs(132)).contains(&jittered));
}
}
}
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_starts_immediately_backs_off_and_wakes() {
let cancel = CancellationToken::new();
let wake = tokio::sync::Notify::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let started = tokio::time::Instant::now();
let recovery = drive_tier_delete_journal_recovery(
&cancel,
async || wake.notified().await,
async || {
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
empty_recovery_pass()
},
|delay| bounded_tier_delete_journal_recovery_delay(delay, 110),
);
let exercise = async {
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
let mut elapsed = Duration::ZERO;
for delay in [66, 132, 264, 300, 300] {
tokio::time::advance(Duration::from_secs(delay - 1)).await;
tokio::task::yield_now().await;
assert!(rx.try_recv().is_err(), "idle pass ran before its backoff");
tokio::time::advance(Duration::from_secs(1)).await;
elapsed += Duration::from_secs(delay);
assert_eq!(rx.recv().await.expect("bounded idle recovery must run"), started + elapsed);
}
wake.notify_one();
assert_eq!(rx.recv().await.expect("notification must bypass idle delay"), started + elapsed);
let first_idle_delay = Duration::from_secs(66);
tokio::time::advance(first_idle_delay).await;
assert_eq!(
rx.recv().await.expect("wakeup must reset idle backoff"),
started + elapsed + first_idle_delay
);
cancel.cancel();
};
tokio::join!(recovery, exercise);
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_preserves_known_work_tick_deadline() {
let cancel = CancellationToken::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let started = tokio::time::Instant::now();
let mut pass = 0;
let recovery = drive_tier_delete_journal_recovery(
&cancel,
async || std::future::pending::<()>().await,
async || {
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
pass += 1;
if pass <= 3 {
return empty_recovery_pass();
}
if pass == 4 {
tokio::time::sleep(Duration::from_secs(40)).await;
}
let mut stats = empty_recovery_pass();
match pass {
4 => {
stats.manifests = TierDeleteDispatchManifestRecoveryStats {
retained: 1,
scanned: 1,
..Default::default()
}
}
5 => stats.manifest_errors = 1,
6 => stats.journal_errors = 1,
7 => stats.manifests.truncated = true,
8 => stats.journals.failed = 1,
9 => stats.deadline_exhausted = true,
_ => stats.journals.next_marker = Some("next-page".to_owned()),
}
stats
},
std::convert::identity,
);
let exercise = async {
for expected in [0, 60, 180, 420] {
assert_eq!(
rx.recv().await.expect("idle and work passes must run"),
started + Duration::from_secs(expected)
);
}
tokio::time::advance(Duration::from_secs(59)).await;
tokio::task::yield_now().await;
assert!(rx.try_recv().is_err(), "known work ran before the next tick");
tokio::time::advance(Duration::from_secs(1)).await;
for expected in [480, 540, 600, 660, 720, 780, 840] {
assert_eq!(
rx.recv().await.expect("unfinished work must retain the base tick deadline"),
started + Duration::from_secs(expected)
);
}
cancel.cancel();
};
tokio::join!(recovery, exercise);
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_keeps_wakeup_received_during_a_pass() {
let cancel = CancellationToken::new();
let wake = tokio::sync::Notify::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let started = tokio::time::Instant::now();
let mut first = true;
let recovery = drive_tier_delete_journal_recovery(
&cancel,
async || wake.notified().await,
async || {
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
if first {
first = false;
tokio::time::sleep(Duration::from_secs(40)).await;
}
empty_recovery_pass()
},
std::convert::identity,
);
let exercise = async {
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
wake.notify_one();
tokio::time::advance(Duration::from_secs(40)).await;
assert_eq!(
rx.recv().await.expect("a notification during recovery must remain pending"),
started + Duration::from_secs(40)
);
cancel.cancel();
};
tokio::join!(recovery, exercise);
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_cancellation_wins_over_ready_wakeup() {
let cancel = CancellationToken::new();
cancel.cancel();
drive_tier_delete_journal_recovery(
&cancel,
async || {},
async || panic!("shutdown must prevent admission even when a wakeup and startup tick are ready"),
std::convert::identity,
)
.await;
}
#[tokio::test]
async fn tier_delete_journal_recovery_has_a_hard_outer_timeout() {
let result = await_tier_delete_journal_recovery(
+212 -18
View File
@@ -437,6 +437,8 @@ pub struct BucketMetadata {
pub created: OffsetDateTime,
pub lock_enabled: bool, // While marked as unused, it may need to be retained
pub bucket_incarnation_id: Uuid,
pub artifact_base_prefix: Option<String>,
pub artifact_backup_retained: bool,
pub(crate) bucket_incarnation_sidecar: bool,
pub(crate) bucket_creation_committed: bool,
pub policy_config_json: Vec<u8>,
@@ -512,6 +514,8 @@ impl Default for BucketMetadata {
created: OffsetDateTime::UNIX_EPOCH,
lock_enabled: Default::default(),
bucket_incarnation_id: Uuid::nil(),
artifact_base_prefix: None,
artifact_backup_retained: false,
bucket_incarnation_sidecar: false,
bucket_creation_committed: false,
policy_config_json: Default::default(),
@@ -607,7 +611,7 @@ impl BucketMetadata {
/// legacy migration path, which mints the sidecar first; it must not be
/// rewritten by this migration.
pub(crate) fn needs_bucket_creation_commit(&self) -> bool {
self.lock_enabled
(self.lock_enabled || self.artifact_base_prefix.is_some())
&& !self.bucket_creation_committed
&& self.bucket_incarnation_sidecar
&& !self.bucket_incarnation_id.is_nil()
@@ -704,6 +708,22 @@ impl BucketMetadata {
self.bucket_incarnation_id =
Uuid::from_slice(&bytes).map_err(|err| Error::other(format!("invalid BucketIncarnationID: {err}")))?;
}
"ArtifactBackupRetained" => {
if self.artifact_backup_retained || !rmp::decode::read_bool(rd)? {
return Err(Error::other("invalid artifact backup retention marker"));
}
self.artifact_backup_retained = true;
}
"ArtifactBasePrefix" => {
if self.artifact_base_prefix.is_some() {
return Err(Error::other("duplicate artifact namespace"));
}
let prefix = read_msgp_str(rd)?;
if !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(&prefix) {
return Err(Error::other("invalid artifact namespace"));
}
self.artifact_base_prefix = Some(prefix);
}
"PolicyConfigJSON" | "PolicyConfigJson" => self.policy_config_json = read_msgp_bin(rd)?,
"NotificationConfigXML" | "NotificationConfigXml" => self.notification_config_xml = read_msgp_bin(rd)?,
"LifecycleConfigXML" | "LifecycleConfigXml" => self.lifecycle_config_xml = read_msgp_bin(rd)?,
@@ -766,7 +786,7 @@ impl BucketMetadata {
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
// Map size: MinIO fields (25) + RustFS extensions (22)
let map_len: u32 = 47;
let map_len: u32 = 47 + u32::from(self.artifact_base_prefix.is_some()) + u32::from(self.artifact_backup_retained);
rmp::encode::write_map_len(wr, map_len)?;
// MinIO field order (same as Go struct)
@@ -780,6 +800,15 @@ impl BucketMetadata {
rmp::encode::write_bool(wr, self.lock_enabled)?;
write_bin_field(wr, "BucketIncarnationID", self.bucket_incarnation_id.as_bytes())?;
if let Some(prefix) = &self.artifact_base_prefix {
rmp::encode::write_str(wr, "ArtifactBasePrefix")?;
rmp::encode::write_str(wr, prefix)?;
}
if self.artifact_backup_retained {
rmp::encode::write_str(wr, "ArtifactBackupRetained")?;
rmp::encode::write_bool(wr, true)?;
}
write_bin_field(wr, "PolicyConfigJSON", &self.policy_config_json)?;
write_bin_field(wr, "NotificationConfigXML", &self.notification_config_xml)?;
@@ -880,13 +909,28 @@ impl BucketMetadata {
}
match version {
BUCKET_METADATA_VERSION => {}
BUCKET_METADATA_VERSION | 2 => {}
_ => return Err(Error::other("read_bucket_metadata: version invalid")),
}
Ok(())
}
/// Version two is reserved for permanent artifact backup retention. Older
/// readers reject its header instead of skipping the protection field.
pub(crate) fn decode_file(bytes: &[u8]) -> Result<Self> {
Self::check_header(bytes)?;
let metadata = Self::unmarshal(&bytes[4..])?;
let retained_header = LittleEndian::read_u16(&bytes[2..4]) == 2;
if retained_header != metadata.artifact_backup_retained {
return Err(Error::other("artifact backup retention header mismatch"));
}
if metadata.artifact_backup_retained {
metadata.validate_artifact_configuration()?;
}
Ok(metadata)
}
pub(crate) fn default_timestamps(&mut self) {
if self.policy_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.policy_config_updated_at = self.created
@@ -1025,6 +1069,9 @@ impl BucketMetadata {
}
Some(config)
};
if self.artifact_base_prefix.is_some() && !config.as_ref().is_some_and(artifact_versioning_enabled) {
return Err(Error::other("artifact bucket versioning must remain enabled"));
}
self.versioning_config_xml = data;
self.versioning_config = config;
self.versioning_config_updated_at = updated;
@@ -1127,13 +1174,34 @@ impl BucketMetadata {
&mut self,
store: std::sync::Arc<crate::store::ECStore>,
write_completion: WriteCompletion,
) -> Result<()> {
let opts = crate::object_api::ObjectOptions {
max_parity: true,
write_completion,
..Default::default()
};
self.save_with_store_options(store, &opts).await
}
pub(crate) async fn save_with_store_options(
&mut self,
store: Arc<ECStore>,
opts: &crate::object_api::ObjectOptions,
) -> Result<()> {
self.parse_all_configs()?;
self.validate_artifact_update(store.clone()).await?;
let mut buf: Vec<u8> = vec![0; 4];
LittleEndian::write_u16(&mut buf[0..2], BUCKET_METADATA_FORMAT);
LittleEndian::write_u16(&mut buf[2..4], BUCKET_METADATA_VERSION);
LittleEndian::write_u16(
&mut buf[2..4],
if self.artifact_backup_retained {
2
} else {
BUCKET_METADATA_VERSION
},
);
let data = self
.marshal_msg()
@@ -1141,21 +1209,52 @@ impl BucketMetadata {
buf.extend_from_slice(&data);
save_config_with_opts(
store,
self.save_file_path().as_str(),
buf,
&crate::object_api::ObjectOptions {
max_parity: true,
write_completion,
..Default::default()
},
)
.await?;
save_config_with_opts(store, self.save_file_path().as_str(), buf, opts).await?;
Ok(())
}
fn validate_artifact_configuration(&self) -> Result<()> {
if self.artifact_backup_retained && (self.artifact_base_prefix.is_none() || !self.bucket_creation_committed) {
return Err(Error::other("artifact backup retention requires a committed managed bucket"));
}
if let Some(prefix) = &self.artifact_base_prefix {
if !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(prefix) || self.bucket_incarnation_id.is_nil() {
return Err(Error::other("invalid artifact bucket identity"));
}
let versioning = deserialize::<VersioningConfiguration>(&self.versioning_config_xml)?;
if !artifact_versioning_enabled(&versioning) {
return Err(Error::other("artifact bucket versioning must remain enabled"));
}
}
Ok(())
}
/// Full metadata replacement must not change a generation's creation identity.
pub(crate) async fn validate_artifact_update(&self, store: Arc<ECStore>) -> Result<()> {
self.validate_artifact_configuration()?;
// An existing empty/corrupt metadata object is not permission to create an identity.
match read_config_preserve_empty(store, &self.save_file_path()).await {
Ok(data) => {
let previous = Self::decode_file(&data)?;
previous.validate_artifact_configuration()?;
if previous.artifact_backup_retained && !self.artifact_backup_retained {
return Err(Error::other("artifact backup retention cannot be removed"));
}
// Deletion removes this record before recreation. A caller-supplied
// incarnation cannot turn an existing bucket into a new generation.
if previous.artifact_base_prefix != self.artifact_base_prefix
|| (previous.artifact_base_prefix.is_some() && previous.bucket_incarnation_id != self.bucket_incarnation_id)
{
return Err(Error::other("artifact bucket identity is immutable"));
}
}
Err(Error::ConfigNotFound) => {}
Err(error) => return Err(error),
}
Ok(())
}
fn parse_policy_config(&mut self) -> Result<()> {
if !self.policy_config_json.is_empty() {
self.policy_config = Some(serde_json::from_slice(&self.policy_config_json)?);
@@ -1199,6 +1298,7 @@ impl BucketMetadata {
/// `rustfs_bucket_metadata_parse_failed_total`, reflected in
/// `rustfs_bucket_metadata_unparsable_current`, and logged at error level.
pub(super) fn parse_all_configs(&mut self) -> Result<()> {
self.validate_artifact_configuration()?;
if let Err(e) = self.parse_policy_config() {
tracing::warn!(
event = "bucket_metadata_parse_failed",
@@ -1464,6 +1564,10 @@ impl BucketMetadata {
}
}
fn artifact_versioning_enabled(config: &VersioningConfiguration) -> bool {
config.enabled() && !config.exclude_folders.unwrap_or(false) && config.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
}
pub(crate) async fn load_bucket_incarnation(api: Arc<ECStore>, bucket: &str) -> Result<Option<Uuid>> {
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}");
let data = match read_config_preserve_empty(api, &path).await {
@@ -1560,9 +1664,7 @@ async fn read_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<BucketM
let data = read_config(api, &file_path).await?;
BucketMetadata::check_header(&data)?;
let bm = BucketMetadata::unmarshal(&data[4..])?;
let bm = BucketMetadata::decode_file(&data)?;
Ok(bm)
}
@@ -1586,6 +1688,98 @@ where
mod test {
use super::*;
#[test]
fn artifact_metadata_roundtrip_and_corrupt_prefix_rejection() {
let mut metadata = BucketMetadata::new("artifacts");
metadata.artifact_base_prefix = Some("images/".to_owned());
let encoded = metadata.marshal_msg().expect("encode artifact metadata");
let decoded = BucketMetadata::unmarshal(&encoded).expect("decode artifact metadata");
assert_eq!(decoded.artifact_base_prefix, metadata.artifact_base_prefix, "ARTIFACT_METADATA_ROUNDTRIP");
assert_eq!(decoded.bucket_incarnation_id, metadata.bucket_incarnation_id);
let legacy = BucketMetadata::new("ordinary")
.marshal_msg()
.expect("encode ordinary metadata");
assert!(
BucketMetadata::unmarshal(&legacy)
.expect("decode ordinary metadata")
.artifact_base_prefix
.is_none()
);
for prefix in ["", "../", "outputs", "a b/"] {
let mut invalid = Vec::new();
rmp::encode::write_map_len(&mut invalid, 1).expect("encode map");
rmp::encode::write_str(&mut invalid, "ArtifactBasePrefix").expect("encode field");
rmp::encode::write_str(&mut invalid, prefix).expect("encode invalid prefix");
assert!(BucketMetadata::unmarshal(&invalid).is_err(), "ARTIFACT_CORRUPT_PREFIX");
}
}
#[test]
fn artifact_backup_retention_marker_rejects_false_and_duplicates() {
for count in [1, 2] {
let mut bytes = Vec::new();
rmp::encode::write_map_len(&mut bytes, count).unwrap();
for _ in 0..count {
rmp::encode::write_str(&mut bytes, "ArtifactBackupRetained").unwrap();
rmp::encode::write_bool(&mut bytes, count == 2).unwrap();
}
assert!(BucketMetadata::unmarshal(&bytes).is_err(), "O02P3_INVALID_RETENTION_MARKER");
}
}
#[test]
fn artifact_backup_retention_requires_matching_header_and_identity() {
let mut metadata = BucketMetadata::new("retained");
metadata.bucket_incarnation_id = Uuid::new_v4();
metadata.bucket_creation_committed = true;
metadata.artifact_base_prefix = Some("outputs/".to_owned());
metadata.versioning_config_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec();
let file = |metadata: &BucketMetadata, version: u16| {
let mut bytes = Vec::new();
bytes.extend_from_slice(&BUCKET_METADATA_FORMAT.to_le_bytes());
bytes.extend_from_slice(&version.to_le_bytes());
bytes.extend_from_slice(&metadata.marshal_msg().unwrap());
bytes
};
assert!(BucketMetadata::decode_file(&file(&metadata, 1)).is_ok(), "O02P3_ORDINARY_V1_COMPATIBLE");
assert!(BucketMetadata::decode_file(&file(&metadata, 2)).is_err(), "O02P3_MARKER_REQUIRED");
metadata.artifact_backup_retained = true;
assert!(
BucketMetadata::decode_file(&file(&metadata, 1)).is_err(),
"O02P3_PROTECTION_CANNOT_DOWNGRADE_HEADER"
);
assert!(
BucketMetadata::decode_file(&file(&metadata, 2))
.unwrap()
.artifact_backup_retained,
"O02P3_PROTECTED_V2_ROUNDTRIP"
);
metadata.artifact_base_prefix = None;
assert!(
BucketMetadata::decode_file(&file(&metadata, 2)).is_err(),
"O02P3_PROTECTION_REQUIRES_MANAGED_BUCKET"
);
}
#[test]
fn artifact_versioning_cannot_be_disabled_or_excluded() {
let mut metadata = BucketMetadata::new("artifacts");
metadata.artifact_base_prefix = Some("images/".to_owned());
for xml in [
"",
"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>",
"<VersioningConfiguration><Status>Enabled</Status><ExcludeFolders>true</ExcludeFolders></VersioningConfiguration>",
"<VersioningConfiguration><Status>Enabled</Status><ExcludedPrefixes><Prefix>images/</Prefix></ExcludedPrefixes></VersioningConfiguration>",
] {
assert!(
metadata
.update_config(BUCKET_VERSIONING_CONFIG, xml.as_bytes().to_vec())
.is_err(),
"ARTIFACT_VERSIONING_REQUIRED"
);
}
}
/// rustfs/backlog#1734: `StorageError::clone` rebuilds I/O errors from
/// their text. The unreadable-config refusal must stay typed across that
/// clone, or a cloned error stops mapping to its retryable S3 response.
+36 -6
View File
@@ -293,8 +293,10 @@ pub(super) fn get_bucket_metadata_sys() -> Result<Arc<RwLock<BucketMetadataSys>>
pub async fn set_bucket_metadata(bucket: String, bm: BucketMetadata) -> Result<()> {
let sys = get_bucket_metadata_sys()?;
let lock = sys.write().await;
lock.set(bucket, Arc::new(bm)).await;
Ok(())
if bucket != bm.name {
return Err(Error::other("bucket metadata identity mismatch"));
}
lock.set_validated(bm).await
}
pub async fn reload_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<()> {
@@ -585,6 +587,15 @@ pub(crate) async fn get_cached_bucket_incarnation_id_in(
sys.get_bucket_incarnation_id(bucket).await
}
pub(crate) async fn get_bucket_incarnation_metadata_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<Arc<BucketMetadata>> {
let sys = bucket_metadata_sys_of(ctx)?;
let sys = sys.read().await.clone();
sys.get_bucket_incarnation_metadata(bucket).await
}
pub(crate) async fn set_bucket_metadata_in(ctx: &crate::runtime::instance::InstanceContext, bm: BucketMetadata) -> Result<()> {
let sys = bucket_metadata_sys_of(ctx)?;
let lock = sys.read().await;
@@ -2132,7 +2143,16 @@ impl BucketMetadataSys {
}
}
pub async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
pub(crate) async fn set_validated(&self, mut bm: BucketMetadata) -> Result<()> {
if bm.artifact_base_prefix.is_some() {
bm.parse_all_configs()?;
}
bm.validate_artifact_update(self.object_store()).await?;
self.set(bm.name.clone(), Arc::new(bm)).await;
Ok(())
}
async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
if !is_meta_bucketname(&bucket) {
let publish_lock = self.metadata_publish_lock(&bucket);
let _publish_guard = publish_lock.lock().await;
@@ -2147,6 +2167,11 @@ impl BucketMetadataSys {
}
}
#[cfg(any(test, feature = "test-util"))]
pub async fn set_for_test(&self, bucket: String, bm: Arc<BucketMetadata>) {
self.set(bucket, bm).await;
}
/// Remove a bucket's cached metadata from the in-memory map.
///
/// Returns `true` if an entry was present. Reserved meta buckets are ignored.
@@ -2651,12 +2676,16 @@ impl BucketMetadataSys {
}
async fn get_bucket_incarnation_id(&self, bucket: &str) -> Result<Uuid> {
Ok(self.get_bucket_incarnation_metadata(bucket).await?.bucket_incarnation_id)
}
async fn get_bucket_incarnation_metadata(&self, bucket: &str) -> Result<Arc<BucketMetadata>> {
let authority = self.get_metadata_authority(bucket).await?;
if let BucketMetadataAuthority::Authoritative(metadata) = &authority
&& metadata.bucket_incarnation_sidecar
&& !metadata.bucket_incarnation_id.is_nil()
{
return Ok(metadata.bucket_incarnation_id);
return Ok(Arc::clone(metadata));
}
match authority {
@@ -2665,7 +2694,7 @@ impl BucketMetadataSys {
BucketMetadataAuthority::Authoritative(metadata)
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
{
Ok(metadata.bucket_incarnation_id)
Ok(metadata)
}
BucketMetadataAuthority::Authoritative(_) | BucketMetadataAuthority::Fabricated => {
Err(Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}")))
@@ -2993,7 +3022,8 @@ impl BucketMetadataSys {
load_bucket_metadata_parse_with_presence(self.object_store(), bucket, true),
)
.await?;
if persisted && metadata.lock_enabled && !metadata.bucket_creation_committed {
if persisted && (metadata.lock_enabled || metadata.artifact_base_prefix.is_some()) && !metadata.bucket_creation_committed
{
return Err(Error::ErasureWriteQuorum);
}
if persisted {
+1
View File
@@ -14,6 +14,7 @@
// #730: bucket subsystems still contain staged ECStore migration code.
pub mod artifact;
pub mod bandwidth;
pub mod bucket_target_sys;
pub mod config_parse_mode;
+1 -1
View File
@@ -895,7 +895,7 @@ mod tests {
.update_bucket_metadata_config(&bucket, QUOTA_CONFIG_FILE, legacy_quota_json(8192))
.await
.expect("newer quota save");
sys.read().await.set(bucket.clone(), stale).await;
sys.read().await.set_for_test(bucket.clone(), stale).await;
assert_eq!(
checker
.migrate_reservation_protocol(&bucket)
@@ -275,7 +275,7 @@ async fn quota_statistics_remain_available_without_a_complete_scanner_snapshot()
// assertion: reporting must work before a protocol upgrade.
let metadata_sys = BucketMetadataSys::new(store.clone());
let (metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
metadata_sys.set(bucket.clone(), metadata).await;
metadata_sys.set_for_test(bucket.clone(), metadata).await;
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
let (snapshot_source, size) = if source == "empty" { ("complete", 0) } else { (source, 1000) };
install_reporting_snapshot(store.clone(), &bucket, snapshot_source, size).await;
@@ -317,7 +317,7 @@ async fn quota_statistics_preserve_configuration_errors() {
let metadata_sys = BucketMetadataSys::new(store.clone());
let (mut metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
Arc::make_mut(&mut metadata).quota_config_json = b"{not-json".to_vec();
metadata_sys.set(bucket.clone(), metadata).await;
metadata_sys.set_for_test(bucket.clone(), metadata).await;
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
let statistics = checker.get_quota_stats(&bucket).await;
@@ -707,6 +707,13 @@ struct ShardedReservationData {
}
impl ReservationLedgerData {
fn store(&self) -> &Arc<ECStore> {
match self {
Self::Single(data) => &data.store,
Self::Sharded(data) => &data.store,
}
}
fn bucket(&self) -> &str {
match self {
Self::Single(data) => &data.bucket,
@@ -758,7 +765,7 @@ impl QuotaReservation {
let Some(ledger) = self.ledger.as_ref() else {
return;
};
crate::store::list_objects::observe_scanner_namespace_mutations(ledger.bucket(), 1);
crate::store::list_objects::observe_list_objects_mutation(ledger.store(), ledger.bucket()).await;
match settle(ledger, true).await {
Ok(()) => self.ledger = None,
Err(err) => log_deferred_settlement(ledger, "commit_deferred", &err),
@@ -1283,7 +1290,7 @@ async fn reconcile_exact(store: &Arc<ECStore>, bucket: &str, ledger: &mut QuotaL
}
async fn exact_bucket_usage(store: &Arc<ECStore>, bucket: &str) -> Result<u64> {
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
crate::store::list_objects::observe_list_objects_mutation(store, bucket).await;
Ok(compute_bucket_usage(Arc::clone(store), bucket).await?.size)
}
+61 -14
View File
@@ -43,6 +43,11 @@ const EVENT_METACACHE_LISTING: &str = "metacache_listing";
pub type AgreedFn = Box<dyn Fn(MetaCacheEntry) -> Pin<Box<dyn Future<Output = Result<(), DiskError>> + Send>> + Send + 'static>;
pub type PartialFn =
Box<dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
pub(crate) type PartialResultFn = Box<
dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = disk::error::Result<()>> + Send>>
+ Send
+ 'static,
>;
type FinishedFn = Box<dyn Fn(&[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
#[derive(Clone, Default)]
@@ -55,7 +60,6 @@ impl FallbackClaimTracker {
self.claimed.lock().await.insert(disk.endpoint().to_string());
}
#[cfg(test)]
pub(crate) async fn claimed_keys(&self) -> HashSet<String> {
self.claimed.lock().await.clone()
}
@@ -313,17 +317,7 @@ pub struct ListPathRawOutcome {
pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> disk::error::Result<()> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, None).await.map(|_| ())
}
/// Like [`list_path_raw`], but reports how the merged stream ended.
pub(crate) async fn list_path_raw_with_outcome(
rx: CancellationToken,
opts: ListPathRawOptions,
) -> disk::error::Result<ListPathRawOutcome> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, None).await
list_path_raw_inner(rx, opts, None, None).await.map(|_| ())
}
pub(crate) async fn list_path_raw_with_claim_tracker(
@@ -333,13 +327,25 @@ pub(crate) async fn list_path_raw_with_claim_tracker(
) -> disk::error::Result<ListPathRawOutcome> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, Some(claim_tracker)).await
list_path_raw_inner(rx, opts, Some(claim_tracker), None).await
}
pub(crate) async fn list_path_raw_with_partial_result(
rx: CancellationToken,
opts: ListPathRawOptions,
claim_tracker: FallbackClaimTracker,
partial_result: PartialResultFn,
) -> disk::error::Result<ListPathRawOutcome> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, Some(claim_tracker), Some(partial_result)).await
}
async fn list_path_raw_inner(
rx: CancellationToken,
opts: ListPathRawOptions,
fallback_claim_tracker: Option<FallbackClaimTracker>,
partial_result: Option<PartialResultFn>,
) -> disk::error::Result<ListPathRawOutcome> {
if opts.disks.is_empty() {
return Err(DiskError::ErasureReadQuorum);
@@ -1014,7 +1020,13 @@ async fn list_path_raw_inner(
}
}
if let Some(partial_fn) = opts.partial.as_ref() {
if let Some(partial_fn) = partial_result.as_ref() {
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
result = partial_fn(MetaCacheEntries(top_entries), &errs) => result?,
}
} else if let Some(partial_fn) = opts.partial.as_ref() {
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
@@ -1296,6 +1308,7 @@ mod tests {
..Default::default()
},
None,
None,
)
.await
.expect("listing should complete");
@@ -1320,6 +1333,7 @@ mod tests {
..Default::default()
},
None,
None,
)
.await
.expect("listing should complete");
@@ -1813,6 +1827,39 @@ mod tests {
);
}
#[tokio::test]
async fn list_path_raw_propagates_partial_callback_failure() {
let first = MetaCacheEntry {
name: "bucket/object-a".to_string(),
metadata: vec![1],
..Default::default()
};
let second = MetaCacheEntry {
name: "bucket/object-b".to_string(),
metadata: vec![2],
..Default::default()
};
let err = list_path_raw_with_partial_result(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None],
min_disks: 2,
test_reader_behaviors: vec![
TestReaderBehavior::Entries(vec![first]),
TestReaderBehavior::Entries(vec![second]),
],
..Default::default()
},
FallbackClaimTracker::default(),
Box::new(move |_, _| Box::pin(async { Err(DiskError::ErasureReadQuorum) })),
)
.await
.expect_err("a resolution failure after reader advancement must fail the listing");
assert_eq!(err, DiskError::ErasureReadQuorum);
}
#[tokio::test]
async fn list_path_raw_cancels_a_blocked_partial_callback() {
let cancel = CancellationToken::new();
+2 -12
View File
@@ -105,7 +105,7 @@ pub async fn tier_mutation_control_time_out_client(
/// Build a `NodeServiceClient` bound to the [`ChannelClass`]-appropriate channel for `addr`.
///
/// Bulk `bytes`-carrying RPCs, including native pool metadata recovery inventories, pass
/// Bulk `bytes`-carrying RPCs (ReadAll/WriteAll/CompareAndUpdateFile/ReadMultiple/BatchReadVersion) pass
/// [`ChannelClass::Bulk`] so, when channel isolation is enabled, they are physically isolated
/// from lock/health RPCs; everything else uses [`ChannelClass::Control`]. When isolation is
/// disabled the two classes resolve to the same cached channel, i.e. legacy behavior.
@@ -357,7 +357,6 @@ fn rename_data_grpc_stage(path: &str) -> bool {
matches!(
path,
"/node_service.NodeService/RenameData"
| "/node_service.NodeService/RenameMultipartData"
| "/node_service.NodeService/RenameDataAtIncarnation"
| "/node_service.NodeService/RenameDataWithQuotaFence"
)
@@ -380,7 +379,6 @@ where
}
fn call(&mut self, mut request: HttpRequest<ReqBody>) -> Self::Future {
let rpc_method = request.extensions().get::<tonic::GrpcMethod<'_>>().cloned();
let observe_rename_data = rustfs_io_metrics::put_stage_metrics_enabled() && rename_data_grpc_stage(request.uri().path());
let request_scope_started = if observe_rename_data {
rustfs_io_metrics::put_stage_timer()
@@ -449,21 +447,13 @@ where
let response_state = verify_tonic_peer_replay_capabilities_response(&audience, challenge, response.headers());
if let Err(error) = &response_state
&& (response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER)
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER)
|| matches!(sent_state.cache_capability, Some(PeerReplayCapability::Capable { .. })))
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER))
{
debug!(
event = "internode_rpc_capability_proof_rejected",
component = "ecstore",
subsystem = "rpc_client",
result = "rejected",
audience = audience.rsplit('@').next().unwrap_or("unknown"),
rpc_service = rpc_method.as_ref().map(|method| method.service()).unwrap_or("unknown"),
rpc_method = rpc_method.as_ref().map(|method| method.method()).unwrap_or("unknown"),
http_status = response.status().as_u16(),
grpc_status = response.headers().get("grpc-status").and_then(|value| value.to_str().ok()).unwrap_or("trailers"),
capability_present = response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER),
capability_proof_present = response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER),
error = %error,
"internode RPC capability proof rejected"
)
File diff suppressed because it is too large Load Diff
@@ -41,10 +41,6 @@ use tonic::service::interceptor::InterceptedService;
use tonic::{Request, Response};
use tracing::{debug, info, warn};
const EVENT_NAMESPACE_LOCK_RELEASE: &str = "namespace_lock_release";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_REMOTE_LOCK: &str = "remote_lock";
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
set_tonic_rolling_mutation_body_digest(request)
}
@@ -237,20 +233,6 @@ pub struct RemoteClient {
impl RemoteClient {
const ONLINE_CHECK_RESOURCE: &'static str = "health-lock-online";
fn record_release_ack(&self, lock_id: &LockId) {
tracing::trace!(
target: "rustfs_ecstore::object_lock_diag",
event = EVENT_NAMESPACE_LOCK_RELEASE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "acknowledged",
peer_kind = "remote",
lock_id = %lock_id,
peer = %self.addr,
"Remote namespace lock release acknowledged"
);
}
pub fn new(endpoint: String) -> Self {
Self { addr: endpoint }
}
@@ -1222,6 +1204,8 @@ impl LockClient for RemoteClient {
}
async fn release(&self, lock_id: &LockId) -> Result<bool> {
info!("remote release for {}", lock_id);
let unlock_request = Self::create_unlock_request(lock_id);
let request_string = serde_json::to_string(&unlock_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
@@ -1242,9 +1226,6 @@ impl LockClient for RemoteClient {
if let Some(error_info) = resp.error_info {
return Err(LockError::internal(error_info));
}
if resp.success {
self.record_release_ack(lock_id);
}
Ok(resp.success)
}
@@ -1280,13 +1261,7 @@ impl LockClient for RemoteClient {
Ok(lock_ids
.iter()
.enumerate()
.map(|(idx, lock_id)| {
let result = resp.results.get(idx);
if result.is_some_and(|result| result.success && result.error_info.is_none()) {
self.record_release_ack(lock_id);
}
result.map(|result| result.success).unwrap_or(false)
})
.map(|(idx, _)| resp.results.get(idx).map(|result| result.success).unwrap_or(false))
.collect())
}
+452 -8
View File
@@ -438,6 +438,64 @@ where
Ok(data)
}
pub(crate) async fn read_config_observation<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
let opts = ObjectOptions {
suppress_read_repair: true,
no_lock: false,
skip_verify_bitrot: false,
..Default::default()
};
crate::object_api::without_get_object_body_cache_hook(read_config_limited_preserve_empty_with_metadata_opts(
api, file, &opts, max_bytes,
))
.await
}
pub(crate) async fn read_config_observation_no_lock<S>(
api: Arc<S>,
file: &str,
max_bytes: usize,
guard: &crate::object_api::WriteCommitGuard,
) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
if !guard.matches(RUSTFS_META_BUCKET, file) {
return Err(Error::InvalidArgument(
RUSTFS_META_BUCKET.to_owned(),
file.to_owned(),
"locked observation requires a matching write owner".to_owned(),
));
}
let lock_lost_error = || Error::NamespaceLockQuorumUnavailable {
mode: "locked_config_observation",
bucket: RUSTFS_META_BUCKET.to_owned(),
object: file.to_owned(),
required: 1,
achieved: 0,
};
if guard.is_lock_lost() {
return Err(lock_lost_error());
}
let opts = ObjectOptions {
suppress_read_repair: true,
no_lock: true,
skip_verify_bitrot: false,
..Default::default()
};
let result = crate::object_api::without_get_object_body_cache_hook(read_config_limited_preserve_empty_with_metadata_opts(
api, file, &opts, max_bytes,
))
.await;
if guard.is_lock_lost() {
return Err(lock_lost_error());
}
result
}
pub(crate) async fn read_config_limited_preserve_empty<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
where
S: EcstoreObjectIO,
@@ -484,10 +542,22 @@ pub(crate) async fn read_config_preserve_empty<S>(api: Arc<S>, file: &str) -> Re
where
S: EcstoreObjectIO,
{
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true, None).await?;
let (data, _obj) = read_config_preserve_empty_with_metadata_opts(api, file, &ObjectOptions::default()).await?;
Ok(data)
}
/// Preserve an empty config payload, its metadata, and caller-supplied read options.
pub(crate) async fn read_config_preserve_empty_with_metadata_opts<S>(
api: Arc<S>,
file: &str,
opts: &ObjectOptions,
) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
read_config_with_metadata_inner(api, file, opts, true, None).await
}
pub async fn read_config_no_lock<S>(api: Arc<S>, file: &str) -> Result<Vec<u8>>
where
S: ObjectIO<
@@ -516,15 +586,13 @@ pub(crate) async fn read_config_no_lock_preserve_empty_with_metadata<S>(api: Arc
where
S: EcstoreObjectIO,
{
read_config_with_metadata_inner(
read_config_preserve_empty_with_metadata_opts(
api,
file,
&ObjectOptions {
no_lock: true,
..Default::default()
},
true,
None,
)
.await
}
@@ -2919,9 +2987,9 @@ mod tests {
config_task_join_error, configs_semantically_equal, decode_server_config_blob, encode_server_config_blob,
heal_config_descriptor, is_standard_object_server_config, lookup_configs, map_system_metadata_write_error,
new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata, read_config_preserve_empty,
read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot, save_config_with_opts_inner,
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
server_config_transaction_lock_path, storage_class_kvs_mut,
read_config_preserve_empty_with_metadata_opts, read_config_with_metadata, read_config_without_migrate,
read_server_config_snapshot, save_config_with_opts_inner, save_server_config, save_server_config_snapshot,
save_server_config_snapshot_with_generation, server_config_transaction_lock_path, storage_class_kvs_mut,
};
use crate::config::{audit, heal, notify, oidc, scanner};
use crate::disk::{RUSTFS_META_BUCKET, endpoint::Endpoint};
@@ -5396,6 +5464,7 @@ mod tests {
heal_replacement: Option<Vec<u8>>,
heal_calls: AtomicUsize,
write_calls: AtomicUsize,
last_get_no_lock: AtomicBool,
last_put_no_lock: AtomicBool,
last_put_preconditions: Mutex<Option<HTTPPreconditions>>,
revision: AtomicUsize,
@@ -5411,6 +5480,7 @@ mod tests {
heal_replacement,
heal_calls: AtomicUsize::new(0),
write_calls: AtomicUsize::new(0),
last_get_no_lock: AtomicBool::new(false),
last_put_no_lock: AtomicBool::new(false),
last_put_preconditions: Mutex::new(None),
revision: AtomicUsize::new(1),
@@ -5466,8 +5536,9 @@ mod tests {
_object: &str,
_range: Option<HTTPRangeSpec>,
_h: HeaderMap,
_opts: &ObjectOptions,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
self.last_get_no_lock.store(opts.no_lock, Ordering::SeqCst);
let data = match &*self.state.lock().expect("state lock poisoned") {
RecoveryReadState::Missing => return Err(Error::ConfigNotFound),
RecoveryReadState::Blob(data) => data.clone(),
@@ -5779,6 +5850,325 @@ mod tests {
assert_eq!(store.write_calls.load(Ordering::SeqCst), 0);
}
type ObservationEofGate = (Option<tokio::sync::oneshot::Sender<()>>, tokio::sync::oneshot::Receiver<()>);
struct ObservationStream {
inner: Cursor<Vec<u8>>,
consumed: Arc<AtomicUsize>,
fail: bool,
eof_gate: Option<ObservationEofGate>,
}
impl AsyncRead for ObservationStream {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
if self.fail {
return Poll::Ready(Err(std::io::Error::other("observation stream failed")));
}
if self.inner.position() == u64::try_from(self.inner.get_ref().len()).expect("fixture length")
&& let Some((ready, release)) = self.eof_gate.as_mut()
{
if let Some(ready) = ready.take() {
ready.send(()).expect("EOF observer remains active");
}
match std::future::Future::poll(Pin::new(release), cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(result) => result.expect("EOF gate released"),
}
self.eof_gate = None;
}
let before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
self.consumed.fetch_add(buf.filled().len() - before, Ordering::SeqCst);
result
}
}
struct ObservationStore {
body: Vec<u8>,
declared_size: i64,
error: Option<Error>,
stream_error: bool,
gets: AtomicUsize,
eof_gate: std::sync::Mutex<Option<ObservationEofGate>>,
consumed: Arc<AtomicUsize>,
options: std::sync::Mutex<Option<(bool, bool, bool)>>,
writes: AtomicUsize,
}
impl Debug for ObservationStore {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ObservationStore").finish()
}
}
impl ObservationStore {
fn new(body: Vec<u8>, declared_size: i64) -> Self {
Self {
body,
declared_size,
error: None,
stream_error: false,
gets: AtomicUsize::new(0),
eof_gate: std::sync::Mutex::new(None),
consumed: Arc::new(AtomicUsize::new(0)),
options: std::sync::Mutex::new(None),
writes: AtomicUsize::new(0),
}
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectIO for ObservationStore {
type Error = Error;
type RangeSpec = HTTPRangeSpec;
type HeaderMap = HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = GetObjectReader;
type PutObjectReader = PutObjReader;
async fn get_object_reader(
&self,
bucket: &str,
object: &str,
range: Option<HTTPRangeSpec>,
_headers: HeaderMap,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
assert_eq!(bucket, RUSTFS_META_BUCKET);
assert_eq!(object, "config/observation.bin");
self.gets.fetch_add(1, Ordering::SeqCst);
assert!(range.is_none());
*self.options.lock().expect("observation options") =
Some((opts.suppress_read_repair, opts.no_lock, opts.skip_verify_bitrot));
if let Some(error) = &self.error {
return Err(error.clone());
}
Ok(GetObjectReader {
stream: Box::new(ObservationStream {
inner: Cursor::new(self.body.clone()),
consumed: self.consumed.clone(),
fail: self.stream_error,
eof_gate: self.eof_gate.lock().expect("EOF gate").take(),
}),
object_info: ObjectInfo {
size: self.declared_size,
etag: Some("observation-etag".to_string()),
..Default::default()
},
buffered_body: None,
body_source: Default::default(),
})
}
async fn put_object(
&self,
_bucket: &str,
_object: &str,
_data: &mut PutObjReader,
_opts: &ObjectOptions,
) -> Result<ObjectInfo> {
self.writes.fetch_add(1, Ordering::SeqCst);
Err(Error::other("observation must not write"))
}
}
#[tokio::test]
async fn locked_observation_preserves_options_and_typed_errors() {
let object = "config/observation.bin";
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
rustfs_lock::NamespaceLock::with_local_manager("locked-observation-options".to_owned(), manager.clone()),
rustfs_lock::ObjectKey::new(RUSTFS_META_BUCKET, object),
"locked-observation-options".to_owned(),
);
let guard = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
.await
.expect("matching real owner");
let store = Arc::new(ObservationStore::new(b"raw".to_vec(), 3));
let result = super::read_config_observation_no_lock(store.clone(), object, 3, &guard).await;
assert!(result.is_ok(), "I06L_READ_FLAGS: {result:?}");
assert!(*store.options.lock().expect("options") == Some((true, true, false)), "I06L_READ_FLAGS");
assert_eq!(result.expect("raw observation").0, b"raw");
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
for (bucket, key) in [(RUSTFS_META_BUCKET, "config/other.bin"), ("wrong-bucket", object)] {
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
rustfs_lock::NamespaceLock::with_local_manager("locked-observation-target".to_owned(), manager.clone()),
rustfs_lock::ObjectKey::new(bucket, key),
"locked-observation-target".to_owned(),
);
let wrong = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
.await
.expect("wrong-target real owner");
let store = Arc::new(ObservationStore::new(Vec::new(), 0));
let result = super::read_config_observation_no_lock(store.clone(), object, 8, &wrong).await;
assert!(matches!(result, Err(Error::InvalidArgument(..))), "I06L_TARGET_REJECTED");
assert!(store.gets.load(Ordering::SeqCst) == 0, "I06L_TARGET_REJECTED");
}
for error in [
Error::FileNotFound,
Error::ObjectNotFound(RUSTFS_META_BUCKET.to_owned(), object.to_owned()),
Error::ErasureReadQuorum,
] {
let expected = if error == Error::FileNotFound || matches!(error, Error::ObjectNotFound(..)) {
Error::ConfigNotFound
} else {
error.clone()
};
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.error = Some(error);
let store = Arc::new(fixture);
assert_eq!(
super::read_config_observation_no_lock(store.clone(), object, 8, &guard)
.await
.expect_err("typed error"),
expected
);
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.stream_error = true;
let store = Arc::new(fixture);
let err = super::read_config_observation_no_lock(store.clone(), object, 8, &guard)
.await
.expect_err("stream failure");
assert!(err.to_string().contains("observation stream failed"), "I06L_TYPED_STREAM_ERROR");
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
let store = Arc::new(ObservationStore::new(vec![7; 64], 0));
let result = super::read_config_observation_no_lock(store.clone(), object, 8, &guard).await;
assert!(matches!(result, Err(Error::CorruptedFormat)), "I06L_STREAM_BOUND");
assert!(store.consumed.load(Ordering::SeqCst) == 9, "I06L_STREAM_BOUND");
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn locked_observation_rejects_actual_owner_loss_before_and_after_read() {
let object = "config/observation.bin";
for after_read in [false, true] {
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let client = Arc::new(RefreshControlledClient::new(manager));
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
rustfs_lock::NamespaceLock::new("locked-observation-loss".to_owned(), client.clone()),
rustfs_lock::ObjectKey::new(RUSTFS_META_BUCKET, object),
"locked-observation-loss".to_owned(),
);
let guard = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
.await
.expect("real distributed owner");
let signal = guard.lock_lost_signal().expect("acquired distributed owner signal");
let store = Arc::new(ObservationStore::new(b"raw".to_vec(), 3));
if after_read {
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
*store.eof_gate.lock().expect("EOF gate") = Some((Some(ready_tx), release_rx));
let mut read = Box::pin(super::read_config_observation_no_lock(store.clone(), object, 3, &guard));
tokio::time::timeout(std::time::Duration::from_secs(60), async {
tokio::select! {
ready = ready_rx => ready.expect("read reached EOF"),
result = &mut read => assert_eq!(false, true, "I06L_LOSS_AFTER_READ: returned before EOF gate: {result:?}"),
}
}).await.expect("EOF watchdog");
client.reject_refreshes();
tokio::time::timeout(std::time::Duration::from_secs(60), signal.notified())
.await
.expect("actual owner loss watchdog");
assert!(guard.is_lock_lost(), "I06L_LOSS_AFTER_READ");
release_tx.send(()).expect("release EOF");
let result = tokio::time::timeout(std::time::Duration::from_secs(60), read)
.await
.expect("read watchdog");
assert!(
matches!(
result,
Err(Error::NamespaceLockQuorumUnavailable {
mode: "locked_config_observation",
..
})
),
"I06L_LOSS_AFTER_READ"
);
assert_eq!(store.gets.load(Ordering::SeqCst), 1);
} else {
client.reject_refreshes();
tokio::time::timeout(std::time::Duration::from_secs(60), signal.notified())
.await
.expect("actual owner loss watchdog");
assert!(guard.is_lock_lost(), "I06L_LOSS_BEFORE_GET");
let result = super::read_config_observation_no_lock(store.clone(), object, 3, &guard).await;
assert!(
matches!(
result,
Err(Error::NamespaceLockQuorumUnavailable {
mode: "locked_config_observation",
..
})
),
"I06L_LOSS_BEFORE_GET"
);
assert!(store.gets.load(Ordering::SeqCst) == 0, "I06L_LOSS_BEFORE_GET");
}
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
}
#[tokio::test]
async fn observation_preserves_options_and_errors() {
for body in [Vec::new(), b"raw config".to_vec()] {
let store = Arc::new(ObservationStore::new(body.clone(), i64::try_from(body.len()).expect("fixture size")));
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
assert!(result.is_ok(), "observation must preserve empty and nonempty objects: {result:?}");
let (observed, info) = result.expect("successful observation");
assert_eq!(observed, body);
assert_eq!(info.etag.as_deref(), Some("observation-etag"));
assert_eq!(
*store.options.lock().expect("options"),
Some((true, false, false)),
"I05_REPAIR_SUPPRESSED"
);
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
for (source, expected) in [
(Error::FileNotFound, Error::ConfigNotFound),
(
Error::ObjectNotFound(RUSTFS_META_BUCKET.to_string(), "config/observation.bin".to_string()),
Error::ConfigNotFound,
),
(Error::ErasureReadQuorum, Error::ErasureReadQuorum),
] {
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.error = Some(source);
let store = Arc::new(fixture);
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
assert_eq!(result.expect_err("read error must propagate"), expected);
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.stream_error = true;
let store = Arc::new(fixture);
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
let error = result.expect_err("stream error must propagate");
assert!(error.to_string().contains("observation stream failed"));
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn observation_enforces_byte_limit() {
let store = Arc::new(ObservationStore::new(vec![7; 64], 0));
let result = super::read_config_observation(store.clone(), "config/observation.bin", 8).await;
assert!(result.is_err(), "I05_BOUND_ENFORCED");
assert_eq!(
result.expect_err("bounded observation error"),
Error::CorruptedFormat,
"I05_BOUND_ENFORCED"
);
assert_eq!(store.consumed.load(Ordering::SeqCst), 9, "I05_BOUND_ENFORCED: drain exactly limit + 1");
let store = Arc::new(ObservationStore::new(vec![7; 8], 8));
let result = super::read_config_observation(store.clone(), "config/observation.bin", 8).await;
assert!(result.is_ok(), "exact byte limit must succeed: {result:?}");
assert_eq!(result.expect("exact limit").0, vec![7; 8]);
assert_eq!(store.consumed.load(Ordering::SeqCst), 8);
}
#[tokio::test]
async fn read_config_preserve_empty_distinguishes_empty_object() {
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
@@ -5799,6 +6189,60 @@ mod tests {
assert!(data.is_empty());
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_keeps_empty_and_options() {
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
for no_lock in [false, true] {
let opts = ObjectOptions {
no_lock,
..Default::default()
};
let (data, info) = read_config_preserve_empty_with_metadata_opts(store.clone(), "config/empty.json", &opts)
.await
.expect("an existing empty config must retain its payload and metadata");
assert!(data.is_empty());
assert_eq!(info.size, 0);
assert_eq!(info.actual_size, 0);
assert_eq!(info.etag.as_deref(), Some("config-1"));
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
assert_eq!(store.last_get_no_lock.load(Ordering::SeqCst), no_lock);
}
let err = read_config(store, "config/empty.json")
.await
.expect_err("the existing reader must still treat empty configs as missing");
assert!(matches!(err, Error::ConfigNotFound));
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_keeps_payload_and_metadata() {
let payload = b"original config bytes".to_vec();
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(payload.clone()), None));
let (data, info) =
read_config_preserve_empty_with_metadata_opts(store, "config/nonempty.json", &ObjectOptions::default())
.await
.expect("a nonempty config must retain its payload and metadata");
assert_eq!(data, payload);
let size = i64::try_from(payload.len()).expect("test payload size fits i64");
assert_eq!(info.size, size);
assert_eq!(info.actual_size, size);
assert_eq!(info.etag.as_deref(), Some("config-1"));
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_preserves_read_errors() {
let missing = Arc::new(RecoveryMockStore::new(RecoveryReadState::Missing, None));
let err = read_config_preserve_empty_with_metadata_opts(missing, "config/missing.json", &ObjectOptions::default())
.await
.expect_err("physical absence must remain ConfigNotFound");
assert!(matches!(err, Error::ConfigNotFound));
let quorum = Arc::new(RecoveryMockStore::new(RecoveryReadState::QuorumError, None));
let err = read_config_preserve_empty_with_metadata_opts(quorum, "config/quorum.json", &ObjectOptions::default())
.await
.expect_err("a quorum failure must retain its typed read error");
assert!(matches!(err, Error::ErasureReadQuorum));
}
#[async_trait::async_trait]
impl StorageAdminApi for RecoveryMockStore {
type BackendInfo = rustfs_madmin::BackendInfo;
@@ -1,630 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use crate::disk::error::DiskError;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
use std::path::PathBuf;
use tokio::fs;
struct PhysicalFixture {
_directories: Vec<tempfile::TempDir>,
store: Arc<ECStore>,
canonical: Vec<u8>,
pending: Vec<u8>,
identity: Vec<u8>,
revision: PoolMetaRevision,
}
fn object_path(disk: &DiskStore) -> PathBuf {
disk.get_object_path_if_local(RUSTFS_META_BUCKET, POOL_META_NAME)
.expect("physical fixture must use local disks")
.expect("pool metadata path should resolve")
}
async fn disks(store: &ECStore) -> Vec<Vec<DiskStore>> {
let mut all = Vec::with_capacity(store.pools.len());
for pool in &store.pools {
let set = pool.get_disks_by_key(POOL_META_NAME);
let disks = set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.expect("physical fixture must keep every disk online"))
.collect::<Vec<_>>();
assert_eq!(disks.len(), 4, "fixture requires an EC 2+2 set");
all.push(disks);
}
all
}
async fn owner(store: &ECStore) -> Arc<rustfs_lock::NamespaceLockGuard> {
let lock = store.pools[0]
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("real namespace lock should be created");
Arc::new(
lock.get_write_lock(get_lock_acquire_timeout())
.await
.expect("physical recovery must hold the namespace writer"),
)
}
fn clean_state(fixture: &PhysicalFixture) -> PoolMetaWriteState {
PoolMetaWriteState::for_startup(fixture.revision.cluster_id.expect("fixture V3 revision requires identity"), false)
}
async fn snapshots(store: &ECStore) -> Vec<Vec<PoolMetaNativeInspection>> {
let mut all = Vec::new();
for pool in disks(store).await {
let mut current = Vec::new();
for disk in pool {
match disk
.pool_meta_native_recovery(Request::Inspect {
object: PoolMetaNativeObject::PoolBin,
})
.await
.expect("physical native inspection should succeed")
{
Response::Inspected(inspection) => current.push(inspection),
_ => panic!("inspection must return a disk snapshot"),
}
}
all.push(current);
}
all
}
async fn fixture(split_pools: usize) -> PhysicalFixture {
let (directories, store, _other_store) =
crate::services::rebalance::test_three_pool_stores_with_four_disk_sets_with_isolated_node_contexts(None).await;
persist_v3_pool_meta_for_test(&store).await;
let previous = load_pool_meta_replicas(store.pools.clone(), true)
.await
.expect("baseline V3 pool metadata must be ordinary-readable");
let revision = PoolMetaRevision {
generation: previous
.revision
.generation
.checked_add(1)
.expect("test generation should increment"),
transaction_id: Some(uuid::Uuid::new_v4()),
..previous.revision
};
let candidate = PoolMetaCommittedCandidate {
canonical: previous.canonical.expect("baseline must have a canonical V3 COMMIT"),
meta: previous.meta.clone(),
revision: previous.revision,
};
let canonical = encode_pool_meta_v3_envelope(&previous.meta, revision, true, None).expect("next COMMIT should encode");
let pending =
encode_pool_meta_v3_envelope(&previous.meta, revision, false, Some(&candidate)).expect("next PREPARE should encode");
let identity = load_pool_meta_identity(
store.pools.clone(),
revision
.cluster_id
.expect("baseline V3 metadata should have a cluster identity"),
)
.await
.expect("real initialized identities must agree")
.identity
.expect("real fixture identity must be present");
let identity = encode_pool_meta_identity(identity).expect("real fixture identity should encode canonically");
let modified = OffsetDateTime::now_utc();
let split = split(&pending, &canonical, modified).await;
let commit = replicas(&canonical, modified + Duration::seconds(1)).await;
let prepared = replicas(&pending, modified).await;
let directory = uuid::Uuid::new_v4();
for (pool_index, pool) in disks(&store).await.into_iter().enumerate() {
let heads = if pool_index < split_pools { &split } else { &commit };
for (disk_index, disk) in pool.into_iter().enumerate() {
let object = object_path(&disk);
fs::remove_dir_all(&object)
.await
.expect("fault injection should remove only its own pool.bin fixture");
fs::create_dir_all(object.join(directory.to_string()))
.await
.expect("fixture rollback directory should be created");
fs::write(object.join("xl.meta"), &heads[disk_index])
.await
.expect("physical split head should be installed");
fs::write(object.join(directory.to_string()).join("xl.meta.bkp"), &prepared[disk_index])
.await
.expect("physical PREPARE backup should be installed");
}
}
PhysicalFixture {
_directories: directories,
store,
canonical,
pending,
identity,
revision,
}
}
async fn assert_committed(fixture: &PhysicalFixture) {
let confirmed = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("ordinary full-pool read must succeed after physical recovery");
assert_eq!(confirmed.canonical.as_deref(), Some(fixture.canonical.as_slice()));
assert_eq!(confirmed.revision, fixture.revision);
assert!(!confirmed.replica_state.needs_repair);
assert!(confirmed.replica_state.repair_write_safe);
for pool in snapshots(&fixture.store).await {
let raw = pool
.iter()
.map(|disk| disk.current.clone().expect("every recovered physical head must exist"))
.collect::<Vec<_>>();
assert_eq!(
codec::decode_inline_pool_meta_replicas(&raw)
.await
.expect("every recovered shard must verify"),
vec![Bytes::copy_from_slice(&fixture.canonical)]
);
}
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_split_repair_completes_normal_cas_and_quorum_readback() {
let fixture = fixture(1).await;
let before = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(before[0].replica, PoolMetaReplica::Unreadable(_)));
assert!(matches!(before[1].replica, PoolMetaReplica::Valid { committed: true, .. }));
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
assert!(
repair(&fixture.store, &mut state, &fence)
.await
.expect("healthy ordinary COMMIT must authorize physical repair")
);
state
.ensure_write_safe("physical recovery readback")
.expect("completed recovery must disarm its transaction");
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_split_without_ordinary_commit_performs_no_mutation() {
let fixture = fixture(3).await;
let before = snapshots(&fixture.store).await;
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
assert!(
repair(&fixture.store, &mut state, &fence).await.is_err(),
"reconstruction alone must never authorize a COMMIT"
);
assert_eq!(
snapshots(&fixture.store).await,
before,
"all current heads, identities and complete backups must remain unchanged"
);
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
}
struct IdentityEpochFork {
path: PathBuf,
original: Bytes,
replacement: Bytes,
original_identity: PersistedPoolMetaIdentity,
}
async fn identity_epoch_fork(fixture: &PhysicalFixture) -> IdentityEpochFork {
let identity_set = fixture.store.pools[0].get_disks_by_key(POOL_META_IDENTITY_NAME);
assert!(
!Arc::ptr_eq(&identity_set, &fixture.store.pools[0].get_disks_by_key(POOL_META_NAME)),
"the identity evidence must come from its independently placed physical set"
);
let identity_disks = identity_set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.expect("actual identity set must retain all four disks"))
.collect::<Vec<_>>();
let mut raw = Vec::with_capacity(identity_disks.len());
for disk in &identity_disks {
match disk
.pool_meta_native_recovery(Request::InspectIdentity {})
.await
.expect("actual identity set must support native inspection")
{
Response::IdentityInspected { disk_id, raw: current } => {
assert!(!disk_id.is_nil());
raw.push(current);
}
_ => panic!("identity inspection must return its physical raw shard"),
}
}
assert_eq!(raw.len(), 4);
let original_identity = match decode_pool_meta_identity(&fixture.identity) {
PoolMetaIdentityReplica::Valid(identity) => identity,
_ => panic!("fixture must provide a valid initialized identity"),
};
let forked_identity = PersistedPoolMetaIdentity {
epoch: original_identity
.epoch
.checked_add(1)
.expect("test identity epoch should increment"),
..original_identity
};
let forked = encode_pool_meta_identity(forked_identity).expect("foreign epoch identity should remain validly encoded");
assert!(matches!(decode_pool_meta_identity(&forked), PoolMetaIdentityReplica::Valid(_)));
let modified = raw
.iter()
.map(|raw| {
codec::parse_inline_info(raw)
.expect("actual identity shard layout should parse")
.mod_time
})
.collect::<Option<Vec<_>>>()
.expect("every actual identity shard should have a modification time")
.into_iter()
.max()
.expect("actual identity set should contain four shards")
+ Duration::nanoseconds(1);
let replacements = codec::encode_inline_pool_meta_replicas(&raw, &forked, modified)
.await
.expect("epoch fork must retain the actual set's valid erasure layout");
assert_eq!(
codec::parse_inline_info(&raw[0]).expect("original layout").erasure,
codec::parse_inline_info(&replacements[0])
.expect("replacement layout")
.erasure
);
codec::validate_inline_pool_meta_shard(&replacements[0], 0, &forked)
.await
.expect("forked shard must pass the physical integrity checks");
let identity_path = identity_disks[0]
.get_object_path_if_local(RUSTFS_META_BUCKET, POOL_META_IDENTITY_NAME)
.expect("identity fixture must use local storage")
.expect("actual identity metadata path should resolve");
IdentityEpochFork {
path: identity_path.join("xl.meta"),
original: raw[0].clone(),
replacement: replacements[0].clone(),
original_identity,
}
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_identity_actual_set_minority_epoch_fork_performs_no_apply() {
let fixture = fixture(1).await;
let fork = identity_epoch_fork(&fixture).await;
let heads = |pools: Vec<Vec<PoolMetaNativeInspection>>| {
pools
.into_iter()
.map(|pool| pool.into_iter().map(|disk| (disk.current, disk.backups)).collect::<Vec<_>>())
.collect::<Vec<_>>()
};
let before = heads(snapshots(&fixture.store).await);
fs::write(&fork.path, &fork.replacement)
.await
.expect("only one actual identity-set disk should receive the epoch fork");
let selected = load_pool_meta_identity(fixture.store.pools.clone(), fork.original_identity.cluster_id)
.await
.expect("three matching identity shards must remain ordinary-readable");
assert_eq!(selected.identity, Some(fork.original_identity));
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(reads[0].replica, PoolMetaReplica::Unreadable(_)));
assert!(matches!(reads[1].replica, PoolMetaReplica::Valid { committed: true, .. }));
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let error = repair(&fixture.store, &mut state, &fence)
.await
.expect_err("ordinary identity quorum must not hide a physical minority epoch fork");
assert_eq!(
error
.pool_metadata_failure()
.expect("known identity fork must retain typed failure")
.kind,
crate::error::PoolMetadataFailure::RecoveryRequired
);
assert!(state.write_blocked, "known minority fork must latch the write gate");
assert_eq!(
heads(snapshots(&fixture.store).await),
before,
"identity rejection must precede every pool.bin Apply"
);
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
fs::write(&fork.path, &fork.original)
.await
.expect("restoring the input identity must not clear the observed fork latch");
let error = repair(&fixture.store, &mut state, &fence)
.await
.expect_err("the same state must retain operator recovery after identity becomes consistent");
assert_eq!(
error
.pool_metadata_failure()
.expect("sticky failure must retain its classification")
.kind,
crate::error::PoolMetadataFailure::RecoveryRequired
);
assert!(state.write_blocked);
assert_eq!(heads(snapshots(&fixture.store).await), before);
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_runtime_fork_after_apply_latches_original_gate() {
let fixture = fixture(1).await;
let fork = identity_epoch_fork(&fixture).await;
let since = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed original failure timestamp should be valid");
{
let mut state = fixture.store.pool_meta_save_gate.lock().await;
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
state.write_blocked = false;
state.block_context = None;
state.aborted_transaction.store(true, Ordering::SeqCst);
let mut failure = pool_metadata_error(crate::error::PoolMetadataFailure::TransactionUnknown, "commit_cas", None);
failure.since = since;
*state
.transaction_failure
.lock()
.expect("original transaction failure mutex should lock") = Some(failure);
*state
.block_started_at
.lock()
.expect("original block timestamp mutex should lock") = Some(since);
}
let pause = Arc::new(NativeApplyPause {
reached: tokio::sync::Notify::new(),
resume: tokio::sync::Notify::new(),
});
let reached = pause.reached.notified();
tokio::pin!(reached);
reached.as_mut().enable();
let recovery = tokio::spawn({
let store = Arc::clone(&fixture.store);
let pause = Arc::clone(&pause);
async move {
AFTER_NATIVE_APPLY
.scope(pause, async move { store.recover_pool_meta_transaction().await })
.await
}
});
tokio::time::timeout(std::time::Duration::from_secs(10), reached)
.await
.expect("real runtime recovery should pause after completing physical Apply");
let readable = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("physical Apply must already make every pool ordinary-readable");
assert_eq!(readable.canonical.as_deref(), Some(fixture.canonical.as_slice()));
fs::write(&fork.path, &fork.replacement)
.await
.expect("a valid minority identity fork should arrive after physical repair");
pause.resume.notify_one();
let error = tokio::time::timeout(std::time::Duration::from_secs(10), recovery)
.await
.expect("resumed runtime recovery should finish its validation")
.expect("runtime recovery task should not panic")
.expect_err("post-Apply identity fork must reject runtime recovery");
assert_eq!(
error
.pool_metadata_failure()
.expect("runtime fork failure must remain typed")
.kind,
crate::error::PoolMetadataFailure::RecoveryRequired
);
assert_committed(&fixture).await;
{
let state = fixture.store.pool_meta_save_gate.lock().await;
assert!(
state.write_blocked,
"candidate validation must propagate its permanent latch to the original state"
);
let context = state
.block_context
.as_ref()
.expect("original state must retain the known fork context");
assert_eq!(context.kind, crate::error::PoolMetadataFailure::RecoveryRequired);
assert_eq!(context.since, since, "candidate failure must preserve the original outage timestamp");
assert_eq!(*state.block_started_at.lock().expect("original block timestamp should lock"), Some(since));
}
assert!(
!fixture
.store
.recover_pool_meta_transaction()
.await
.expect("permanently blocked recovery should return without mutation"),
"ordinary-readable repaired heads must not clear the original known-fork latch"
);
let state = fixture.store.pool_meta_save_gate.lock().await;
assert!(state.write_blocked);
assert_eq!(
state
.block_context
.as_ref()
.expect("sticky fork context must remain present")
.since,
since
);
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_partial_apply_resumes_after_conditional_rejection() {
let fixture = fixture(1).await;
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let inspected = inspect(&fixture.store.pools, &fence)
.await
.expect("real native snapshots should be obtained");
let replacements = plan(&inspected, &fixture.canonical, fixture.revision, &fixture.identity)
.await
.expect("physical recovery plan should validate");
let snapshot = &inspected[0].snapshots[0];
assert_eq!(
inspected[0].disks[0]
.pool_meta_native_recovery(Request::Apply {
object: PoolMetaNativeObject::PoolBin,
expected_disk_id: snapshot.disk_id,
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
expected_backup_inventory_sha256: snapshot.inventory_sha256().expect("snapshot inventory should hash"),
replacement: replacements[0][0].clone(),
})
.await
.expect("first real Apply must atomically publish and sync"),
Response::Applied
);
let before_rejected = snapshots(&fixture.store).await;
let snapshot = &inspected[0].snapshots[1];
let mut stale_inventory = snapshot.inventory_sha256().expect("snapshot inventory should hash");
stale_inventory[0] ^= 1;
assert!(matches!(
inspected[0].disks[1]
.pool_meta_native_recovery(Request::Apply {
object: PoolMetaNativeObject::PoolBin,
expected_disk_id: snapshot.disk_id,
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
expected_backup_inventory_sha256: stale_inventory,
replacement: replacements[0][1].clone(),
})
.await,
Err(DiskError::ConditionalDeleteConflict)
));
assert_eq!(
snapshots(&fixture.store).await,
before_rejected,
"rejected Apply must preserve the partial repair evidence"
);
drop(inspected);
drop(fence);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let mut state = clean_state(&fixture);
assert!(
repair(&fixture.store, &mut state, &fence)
.await
.expect("a fresh owner should resume the partial physical repair")
);
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_noninline_conditional_io_round_trip() {
let fixture = fixture(0).await;
let pool = Arc::clone(&fixture.store.pools[0]);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let (original, info) = read_config_no_lock_preserve_empty_with_metadata(Arc::clone(&pool), POOL_META_NAME)
.await
.expect("baseline conditional object must be readable");
let token = pool_meta_cas_token_from_info(info);
// This smoke test targets the physical conditional I/O boundary. The large
// opaque body deliberately exceeds the inline writer's one-block limit.
let payload = vec![0x5a; rustfs_filemeta::BLOCK_SIZE_V2 + 137];
let state = clean_state(&fixture);
let mut arm = state.arm_transaction();
save_pool_meta_object_cas(
Arc::clone(&pool),
POOL_META_NAME,
payload.clone(),
&token,
&fence,
"noninline_smoke",
&mut arm,
)
.await
.expect("normal native CAS must support noninline pool metadata storage");
let (readback, info) = read_config_no_lock_preserve_empty_with_metadata(Arc::clone(&pool), POOL_META_NAME)
.await
.expect("noninline conditional write must be ordinary-readable");
assert_eq!(readback, payload);
for disk in &snapshots(&fixture.store).await[0] {
let raw = disk
.current
.as_ref()
.expect("noninline head should be installed on every disk");
let info = rustfs_filemeta::get_file_info(
raw,
RUSTFS_META_BUCKET,
POOL_META_NAME,
"",
rustfs_filemeta::FileInfoOpts {
data: true,
include_free_versions: false,
include_part_checksums: true,
},
)
.expect("physical noninline metadata should decode");
assert!(!info.inline_data());
assert!(info.data.is_none());
}
save_pool_meta_object_cas(
pool,
POOL_META_NAME,
original.clone(),
&pool_meta_cas_token_from_info(info),
&fence,
"noninline_to_inline_smoke",
&mut arm,
)
.await
.expect("normal CAS must also replace a noninline parent with inline metadata");
arm.disarm();
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_three_plus_one_checkpoint_converges_all_shards() {
let fixture = fixture(0).await;
let pending = replicas(&fixture.pending, OffsetDateTime::now_utc() - Duration::seconds(1)).await;
let all_disks = disks(&fixture.store).await;
fs::write(object_path(&all_disks[0][0]).join("xl.meta"), &pending[0])
.await
.expect("one stale physical PREPARE head should be installed");
let selection = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("three matching COMMIT shards must remain ordinary-readable");
assert_eq!(selection.canonical.as_deref(), Some(fixture.canonical.as_slice()));
let guard = owner(&fixture.store).await;
let fence = PoolMetaPersistenceFence::Owned(Arc::clone(&guard));
let mut state = clean_state(&fixture);
assert!(
!repair(&fixture.store, &mut state, &fence)
.await
.expect("ordinary-readable pools should use normal checkpoint recovery")
);
selection
.meta
.save_for_startup_guarded(fixture.store.pools.clone(), &mut state, guard)
.await
.expect("normal checkpoint must converge a readable 3+1 pool without an unsafe native overwrite");
let checkpoint = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("checkpoint must remain ordinary-readable across all pools");
assert!(!checkpoint.replica_state.needs_repair);
assert!(checkpoint.replica_state.repair_write_safe);
assert!(checkpoint.revision.generation > fixture.revision.generation);
let canonical = checkpoint.canonical.expect("checkpoint should expose a canonical COMMIT");
for pool in snapshots(&fixture.store).await {
let raw = pool
.into_iter()
.map(|disk| disk.current.expect("checkpoint head must exist"))
.collect::<Vec<_>>();
assert_eq!(
codec::decode_inline_pool_meta_replicas(&raw)
.await
.expect("all four checkpoint shards must verify"),
vec![Bytes::copy_from_slice(&canonical)]
);
}
}
@@ -1,736 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::pool_meta_recovery_codec as codec;
use super::*;
use crate::disk::{
DiskAPI, DiskStore, PoolMetaNativeInspection, PoolMetaNativeObject, PoolMetaNativeRecoveryRequest as Request,
PoolMetaNativeRecoveryResponse as Response,
};
use bytes::Bytes;
#[cfg(test)]
#[derive(Default)]
pub(super) struct NativeApplyPause {
pub(super) reached: tokio::sync::Notify,
pub(super) resume: tokio::sync::Notify,
}
#[cfg(test)]
tokio::task_local! {
pub(super) static AFTER_NATIVE_APPLY: Arc<NativeApplyPause>;
}
struct InspectedPool {
disks: Vec<DiskStore>,
snapshots: Vec<PoolMetaNativeInspection>,
}
fn invalid() -> Error {
Error::other("native pool metadata recovery requires one proven V3 transaction and complete matching disk evidence")
}
fn known_validation(source: Error, phase: &'static str) -> Error {
Error::other(pool_metadata_error(
crate::error::PoolMetadataFailure::RecoveryRequired,
phase,
Some(Arc::new(source)),
))
}
fn codec_validation(source: codec::PoolMetaCodecError, phase: &'static str) -> Error {
// A supported transport may carry a valid layout outside this narrow
// recovery codec. That is not evidence of corruption or a transaction fork.
if matches!(source, codec::PoolMetaCodecError::UnsupportedLayout) {
return Error::other(source);
}
known_validation(Error::other(source), phase)
}
fn latch_validation(state: &mut PoolMetaWriteState, error: Error) -> Error {
if let Some(context) = error.pool_metadata_failure()
&& context.kind == crate::error::PoolMetadataFailure::RecoveryRequired
{
state.block_with_context(context.clone());
}
error
}
fn current(pool: &InspectedPool) -> Result<Vec<Bytes>> {
pool.snapshots
.iter()
.map(|snapshot| snapshot.current.clone().ok_or_else(invalid))
.collect()
}
async fn inspect(pools: &[Arc<Sets>], fence: &PoolMetaPersistenceFence<'_>) -> Result<Vec<InspectedPool>> {
let mut inspected = Vec::with_capacity(pools.len());
// Pool -> disk order is stable; all capability probes precede every mutation.
for pool in pools {
fence.ensure_held()?;
let set = pool.get_disks_by_key(POOL_META_NAME);
let disks = set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.ok_or_else(invalid))
.collect::<Result<Vec<_>>>()?;
let probes = futures::stream::iter(disks.iter().cloned().map(|disk| async move {
match disk.pool_meta_native_recovery(Request::Probe {}).await? {
Response::Probed => Ok(()),
_ => Err(invalid()),
}
}))
.buffered(4)
.collect::<Vec<Result<()>>>()
.await;
for result in probes {
result?;
}
inspected.push(InspectedPool {
disks,
snapshots: Vec::new(),
});
}
for pool in &mut inspected {
fence.ensure_held()?;
let results = futures::stream::iter(pool.disks.iter().cloned().map(|disk| async move {
match disk
.pool_meta_native_recovery(Request::Inspect {
object: PoolMetaNativeObject::PoolBin,
})
.await?
{
Response::Inspected(snapshot) if !snapshot.disk_id.is_nil() => Ok(snapshot),
_ => Err(invalid()),
}
}))
.buffered(4)
.collect::<Vec<Result<PoolMetaNativeInspection>>>()
.await;
for result in results {
pool.snapshots.push(result?);
}
}
fence.ensure_held()?;
Ok(inspected)
}
async fn validate_identities(pools: &[Arc<Sets>], canonical: &[u8], fence: &PoolMetaPersistenceFence<'_>) -> Result<()> {
for pool in pools {
fence.ensure_held()?;
// Identity and pool.bin have independent placement hashes. Read every
// member of the identity's actual set instead of assuming co-location.
let set = pool.get_disks_by_key(POOL_META_IDENTITY_NAME);
let disks = set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.ok_or_else(invalid))
.collect::<Result<Vec<_>>>()?;
let results = futures::stream::iter(disks.into_iter().enumerate().map(|(index, disk)| async move {
match disk.pool_meta_native_recovery(Request::InspectIdentity {}).await? {
Response::IdentityInspected { disk_id, raw } if !disk_id.is_nil() => {
codec::validate_inline_pool_meta_shard(&raw, index, canonical)
.await
.map_err(|error| codec_validation(error, "native_identity_validation"))
}
_ => Err(invalid()),
}
}))
.buffered(4)
.collect::<Vec<Result<()>>>()
.await;
for result in results {
result?;
}
}
fence.ensure_held()
}
async fn plan(
inspected: &[InspectedPool],
canonical: &[u8],
revision: PoolMetaRevision,
identity: &[u8],
) -> Result<Vec<Vec<Bytes>>> {
let mut pending = Vec::<Vec<u8>>::new();
let mut predecessors = Vec::<Vec<u8>>::new();
let mut decoded = Vec::<Vec<u8>>::new();
let mut newest = OffsetDateTime::now_utc();
for pool in inspected {
let current = current(pool)?;
for (index, snapshot) in pool.snapshots.iter().enumerate() {
if let Some(local_identity) = snapshot.identity.as_ref() {
codec::validate_inline_pool_meta_shard(local_identity, index, identity)
.await
.map_err(|error| codec_validation(error, "native_metadata_validation"))?;
}
for raw in std::iter::once(current[index].clone()).chain(snapshot.backups.iter().map(|backup| backup.data.clone())) {
let info =
codec::parse_inline_info(&raw).map_err(|error| codec_validation(error, "native_metadata_validation"))?;
newest = newest.max(info.mod_time.ok_or_else(invalid)?);
}
}
let mut inventories = vec![current.clone()];
let directories = pool
.snapshots
.iter()
.flat_map(|snapshot| snapshot.backups.iter().map(|backup| backup.directory))
.collect::<HashSet<_>>();
for directory in directories {
// A missing backup contributes its actual current shard only to
// integrity reconstruction. It never invents backup evidence.
inventories.push(
pool.snapshots
.iter()
.enumerate()
.map(|(index, snapshot)| {
snapshot
.backups
.iter()
.find(|backup| backup.directory == directory)
.map(|backup| backup.data.clone())
.unwrap_or_else(|| current[index].clone())
})
.collect(),
);
}
for raw in inventories {
for generation in codec::decode_inline_pool_meta_replicas(&raw)
.await
.map_err(|error| codec_validation(error, "native_metadata_validation"))?
{
let payload = generation.to_vec();
if !decoded.contains(&payload) {
decoded.push(payload);
}
}
}
}
let mut obsolete = Vec::<Vec<u8>>::new();
for payload in &decoded {
match decode_pool_meta_replica(payload.clone()) {
PoolMetaReplica::Valid {
canonical: candidate,
revision: candidate_revision,
committed: false,
previous: Some(previous),
..
} if candidate_revision == revision && candidate == canonical => {
if !pending.contains(payload) {
pending.push(payload.clone());
}
if !predecessors.contains(&previous.canonical) {
predecessors.push(previous.canonical);
}
}
PoolMetaReplica::Valid { revision: older, .. }
if older.is_generation_protocol()
&& older.cluster_id == revision.cluster_id
&& older.epoch == revision.epoch
&& older.generation < revision.generation =>
{
// Retained evidence from an earlier successful recovery is
// obsolete, not a competing commit authority. It remains
// fully reconstructed and bound into the native CAS.
obsolete.push(payload.clone());
}
PoolMetaReplica::Valid {
canonical: candidate,
revision: candidate_revision,
committed: true,
..
} if candidate_revision == revision && candidate == canonical => {}
PoolMetaReplica::Valid { revision: conflict, .. } if conflict.is_generation_protocol() => {
return Err(known_validation(
Error::other("native V3 transaction conflicts with committed authority"),
"native_transaction_validation",
));
}
PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_) => {
return Err(known_validation(
Error::other("native V3 payload is corrupt or incompatible"),
"native_transaction_validation",
));
}
_ => {}
}
}
if pending.len() > 1 {
return Err(known_validation(
Error::other("native V3 prepare records disagree on the predecessor"),
"native_transaction_validation",
));
}
if pending.is_empty() {
return Err(invalid());
}
for payload in &decoded {
if payload.as_slice() != canonical
&& !pending.contains(payload)
&& !predecessors.contains(payload)
&& !obsolete.contains(payload)
{
return Err(invalid());
}
}
for pool in inspected {
for (index, snapshot) in pool.snapshots.iter().enumerate() {
let raw = snapshot.current.as_ref().ok_or_else(invalid)?;
let mut matched = false;
for payload in std::iter::once(canonical).chain(pending.iter().map(Vec::as_slice)) {
matched |= codec::validate_inline_pool_meta_shard(raw, index, payload).await.is_ok();
}
if !matched {
return Err(invalid());
}
for backup in &snapshot.backups {
let mut matched = false;
for payload in std::iter::once(canonical).chain(
pending
.iter()
.chain(predecessors.iter())
.chain(obsolete.iter())
.map(Vec::as_slice),
) {
matched |= codec::validate_inline_pool_meta_shard(&backup.data, index, payload)
.await
.is_ok();
}
if !matched {
return Err(invalid());
}
}
}
}
let modified = newest.checked_add(Duration::nanoseconds(1)).ok_or_else(invalid)?;
let mut replacements = Vec::with_capacity(inspected.len());
for pool in inspected {
replacements.push(
codec::encode_inline_pool_meta_replicas(&current(pool)?, canonical, modified)
.await
.map_err(|error| codec_validation(error, "native_metadata_validation"))?,
);
}
Ok(replacements)
}
pub(super) async fn repair(
store: &ECStore,
state: &mut PoolMetaWriteState,
fence: &PoolMetaPersistenceFence<'_>,
) -> Result<bool> {
fence.ensure_held()?;
let reads = read_pool_meta_replicas(store.pools.clone(), true).await;
if !reads
.iter()
.any(|read| matches!(read.replica, PoolMetaReplica::Unreadable(_)))
{
return Ok(false);
}
state.ensure_write_safe("native pool metadata recovery")?;
if reads
.iter()
.any(|read| matches!(read.replica, PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_)))
{
return Err(latch_validation(
state,
known_validation(Error::other("pool metadata is corrupt or incompatible"), "native_metadata_validation"),
));
}
if reads
.iter()
.any(|read| !matches!(read.replica, PoolMetaReplica::Valid { .. } | PoolMetaReplica::Unreadable(_)))
{
return Err(invalid());
}
// Only an ordinary, successfully read COMMIT grants authority. Rebuilding
// two erasure shards below proves integrity, never a commit decision.
let ordinary_commits = reads
.iter()
.filter_map(|read| match &read.replica {
PoolMetaReplica::Valid {
raw,
canonical,
revision,
committed: true,
..
} if revision.is_generation_protocol() && raw == canonical => Some((canonical.clone(), *revision)),
_ => None,
})
.collect::<Vec<_>>();
if ordinary_commits.is_empty() {
return Err(invalid());
}
let selection = select_pool_meta_replica_reads(reads)
.map_err(|error| latch_validation(state, known_validation(error, "native_metadata_validation")))?;
state
.validate_selection(&selection)
.map_err(|error| latch_validation(state, known_validation(error, "native_metadata_validation")))?;
if selection.meta.pools.len() != store.pools.len()
|| selection
.meta
.pools
.iter()
.zip(&store.pools)
.enumerate()
.any(|(index, (meta, pool))| meta.id != index || meta.cmd_line != pool.endpoints.cmd_line)
{
return Err(invalid());
}
let canonical = selection.canonical.as_ref().ok_or_else(invalid)?;
if !selection.revision.is_generation_protocol()
|| !ordinary_commits
.iter()
.any(|(raw, revision)| raw == canonical && *revision == selection.revision)
{
return Err(invalid());
}
let cluster_id = selection.revision.cluster_id.ok_or_else(invalid)?;
if state.expected_cluster_id != Some(cluster_id) {
return Err(invalid());
}
let identities = join_all(store.pools.iter().cloned().map(read_pool_meta_identity_replica)).await;
if identities.iter().any(|read| {
matches!(
read.replica,
PoolMetaIdentityReplica::Corrupt(_) | PoolMetaIdentityReplica::Incompatible(_)
)
}) {
return Err(latch_validation(
state,
known_validation(Error::other("pool identity is corrupt or incompatible"), "native_identity_validation"),
));
}
if identities
.iter()
.any(|read| !matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
{
return Err(invalid());
}
let identity = select_pool_meta_identity(identities, cluster_id)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
state
.validate_identity(&identity)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
let identity = identity.identity.ok_or_else(invalid)?;
if !identity.initialized {
return Err(invalid());
}
if identity.epoch != selection.revision.epoch {
return Err(latch_validation(
state,
known_validation(Error::other("pool identity epoch conflicts with metadata"), "native_identity_validation"),
));
}
let identity_bytes = encode_pool_meta_identity(identity)?;
let repair_started = std::time::Instant::now();
validate_identities(&store.pools, &identity_bytes, fence)
.await
.map_err(|error| latch_validation(state, error))?;
let inspected = inspect(&store.pools, fence).await?;
let replacements = plan(&inspected, canonical, selection.revision, &identity_bytes)
.await
.map_err(|error| latch_validation(state, error))?;
fence.ensure_held()?;
let mut arm = state.arm_transaction();
arm.phase = Some("native_repair_apply");
for (pool, replacements) in inspected.iter().zip(replacements) {
for ((disk, snapshot), replacement) in pool.disks.iter().zip(&pool.snapshots).zip(replacements) {
fence.ensure_held()?;
let request = Request::Apply {
object: PoolMetaNativeObject::PoolBin,
expected_disk_id: snapshot.disk_id,
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
expected_backup_inventory_sha256: snapshot.inventory_sha256()?,
replacement,
};
match disk.pool_meta_native_recovery(request).await? {
Response::Applied => {}
_ => return Err(invalid()),
}
}
}
#[cfg(test)]
if let Ok(pause) = AFTER_NATIVE_APPLY.try_with(Arc::clone) {
pause.reached.notify_one();
pause.resume.notified().await;
}
fence.ensure_held()?;
let confirmed = load_pool_meta_replicas(store.pools.clone(), true).await?;
if confirmed.canonical.as_ref() != Some(canonical)
|| confirmed.revision != selection.revision
|| !confirmed.replica_state.repair_write_safe
|| confirmed.replica_state.needs_repair
{
return Err(invalid());
}
// Re-enter the normal V3 conditional-write path, including native CAS,
// full tail completion, and the final ordinary quorum readback.
for (pool, token) in store.pools.iter().cloned().zip(&confirmed.cas_tokens) {
save_pool_meta_object_cas(pool, POOL_META_NAME, canonical.clone(), token, fence, "native_repair_confirm", &mut arm)
.await?;
}
let confirmed = load_pool_meta_replicas(store.pools.clone(), true).await?;
if confirmed.canonical.as_ref() != Some(canonical)
|| confirmed.revision != selection.revision
|| confirmed.replica_state.needs_repair
|| !confirmed.replica_state.repair_write_safe
{
return Err(invalid());
}
validate_identities(&store.pools, &identity_bytes, fence)
.await
.map_err(|error| latch_validation(state, error))?;
let identities = join_all(store.pools.iter().cloned().map(read_pool_meta_identity_replica)).await;
if identities.iter().any(|read| {
matches!(
read.replica,
PoolMetaIdentityReplica::Corrupt(_) | PoolMetaIdentityReplica::Incompatible(_)
)
}) {
return Err(latch_validation(
state,
known_validation(Error::other("pool identity is corrupt or incompatible"), "native_identity_validation"),
));
}
if identities
.iter()
.any(|read| !matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
{
return Err(invalid());
}
let identity = select_pool_meta_identity(identities, cluster_id)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
state
.validate_identity(&identity)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
if identity.identity.map(encode_pool_meta_identity).transpose()?.as_deref() != Some(identity_bytes.as_slice()) {
return Err(latch_validation(
state,
known_validation(Error::other("native identity changed after repair"), "native_identity_validation"),
));
}
fence.ensure_held()?;
arm.disarm();
info!(
event = EVENT_DECOMMISSION_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
state = "pool_metadata_native_repaired",
generation = selection.revision.generation,
pools = inspected.len(),
disks = inspected.iter().map(|pool| pool.disks.len()).sum::<usize>(),
elapsed_ms = repair_started.elapsed().as_secs_f64() * 1000.0,
"Pool metadata native replicas repaired"
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
struct Fixture {
pools: Vec<InspectedPool>,
commit: Vec<u8>,
pending: Vec<u8>,
identity: Vec<u8>,
revision: PoolMetaRevision,
modified: OffsetDateTime,
}
async fn fixture() -> Fixture {
let cluster_id = uuid::Uuid::new_v4();
let revision = PoolMetaRevision {
version: POOL_META_GENERATION_VERSION,
cluster_id: Some(cluster_id),
epoch: 1,
generation: 1,
transaction_id: Some(uuid::Uuid::new_v4()),
};
let meta = PoolMeta {
version: POOL_META_GENERATION_VERSION,
..Default::default()
};
let previous_meta = PoolMeta {
version: POOL_META_V1_VERSION,
..Default::default()
};
let previous = PoolMetaCommittedCandidate {
canonical: previous_meta.encode_config_data_for_v2_gate(true).unwrap(),
meta: previous_meta,
revision: PoolMetaRevision::legacy(POOL_META_V1_VERSION),
};
let pending = encode_pool_meta_v3_envelope(&meta, revision, false, Some(&previous)).unwrap();
let commit = encode_pool_meta_v3_envelope(&meta, revision, true, None).unwrap();
let identity = encode_pool_meta_identity(PersistedPoolMetaIdentity {
version: POOL_META_IDENTITY_VERSION,
cluster_id,
epoch: 1,
initialized: true,
fresh_bootstrap_nonce: None,
})
.unwrap();
let modified = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
let current_split = split(&pending, &commit, modified).await;
let current_commit = replicas(&commit, modified + Duration::seconds(1)).await;
let backups = replicas(&pending, modified).await;
let identities = replicas(&identity, modified).await;
let directory = uuid::Uuid::new_v4();
let pools = [current_split.clone(), current_split, current_commit.clone(), current_commit]
.into_iter()
.map(|current| InspectedPool {
disks: Vec::new(),
snapshots: current
.into_iter()
.enumerate()
.map(|(index, current)| PoolMetaNativeInspection {
disk_id: uuid::Uuid::new_v4(),
identity: Some(identities[index].clone()),
current: Some(current),
backups: vec![crate::disk::PoolMetaNativeBackup {
directory,
data: backups[index].clone(),
}],
})
.collect(),
})
.collect();
Fixture {
pools,
commit,
pending,
identity,
revision,
modified,
}
}
#[tokio::test]
async fn pool_meta_native_plan_restores_physical_split_with_fresh_common_identity() {
let f = fixture().await;
let repaired = plan(&f.pools, &f.commit, f.revision, &f.identity).await.unwrap();
assert_eq!(repaired.len(), 4);
for pool in repaired {
assert_eq!(
codec::decode_inline_pool_meta_replicas(&pool).await.unwrap(),
vec![Bytes::copy_from_slice(&f.commit)]
);
let times = pool
.iter()
.map(|raw| codec::parse_inline_info(raw).unwrap().mod_time.unwrap())
.collect::<HashSet<_>>();
assert_eq!(times.len(), 1);
assert!(*times.iter().next().unwrap() > f.modified + Duration::seconds(1));
}
}
#[tokio::test]
async fn pool_meta_native_plan_rejects_single_hidden_identity_fork() {
let mut f = fixture().await;
let wrong = replicas(b"different identity", f.modified).await;
f.pools[0].snapshots[0].identity = Some(wrong[0].clone());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
}
#[tokio::test]
async fn pool_meta_native_plan_rejects_hidden_higher_generation_and_unknown_backup() {
let mut f = fixture().await;
let higher_revision = PoolMetaRevision {
generation: 2,
transaction_id: Some(uuid::Uuid::new_v4()),
..f.revision
};
let higher = encode_pool_meta_v3_envelope(
&PoolMeta {
version: POOL_META_GENERATION_VERSION,
..Default::default()
},
higher_revision,
true,
None,
)
.unwrap();
let wrong = replicas(&higher, f.modified + Duration::seconds(2)).await;
f.pools[0].snapshots[0].current = Some(wrong[0].clone());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
f.pools[0].snapshots[0].current = Some(replicas(&f.pending, f.modified).await[0].clone());
f.pools[0].snapshots[0].backups[0].data = wrong[0].clone();
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
}
#[tokio::test]
async fn pool_meta_native_plan_accepts_retained_older_recovery_backups() {
let mut f = fixture().await;
let older_commit = f.commit.clone();
let older_pending = f.pending.clone();
let next = PoolMetaRevision {
generation: 2,
transaction_id: Some(uuid::Uuid::new_v4()),
..f.revision
};
let PoolMetaReplica::Valid { meta, .. } = decode_pool_meta_replica(older_commit.clone()) else {
panic!("valid fixture")
};
let previous = PoolMetaCommittedCandidate {
canonical: older_commit.clone(),
meta: meta.clone(),
revision: f.revision,
};
f.commit = encode_pool_meta_v3_envelope(&meta, next, true, None).unwrap();
f.pending = encode_pool_meta_v3_envelope(&meta, next, false, Some(&previous)).unwrap();
f.revision = next;
let current = split(&f.pending, &f.commit, f.modified + Duration::seconds(2)).await;
let old_commit = replicas(&older_commit, f.modified + Duration::seconds(1)).await;
let current_pending = replicas(&f.pending, f.modified + Duration::seconds(2)).await;
let new_directory = uuid::Uuid::new_v4();
let old_directory = uuid::Uuid::new_v4();
for pool in &mut f.pools {
for (index, snapshot) in pool.snapshots.iter_mut().enumerate() {
snapshot.current = Some(current[index].clone());
snapshot.backups.push(crate::disk::PoolMetaNativeBackup {
directory: new_directory,
data: current_pending[index].clone(),
});
snapshot.backups.push(crate::disk::PoolMetaNativeBackup {
directory: old_directory,
data: old_commit[index].clone(),
});
}
}
assert!(older_pending != f.pending);
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_ok());
}
#[tokio::test]
async fn pool_meta_native_plan_rejects_missing_or_corrupt_evidence() {
let mut f = fixture().await;
let before = f.pools[1].snapshots[2].current.take();
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
f.pools[1].snapshots[2].current = before;
f.pools[1].snapshots[2].backups[0].data = Bytes::from_static(b"invalid metadata");
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
}
}
#[cfg(all(test, feature = "test-util"))]
#[path = "pool_meta_native_integration_tests.rs"]
mod integration_tests;
@@ -1,569 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Integrity-only codec for conditional recovery of an inline `pool.bin` head.
//! Reconstructing a payload here never grants authority to publish it.
use crate::disk::RUSTFS_META_BUCKET;
use crate::erasure::coding::encode::IntegrityEncodeMode;
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, Erasure, calc_shard_size};
use crate::io_support::shard_integrity::{PartProofReader, ShardVerifier};
use bytes::Bytes;
use rustfs_filemeta::{
BLOCK_SIZE_V2, ChecksumAlgo, ChecksumInfo, ERASURE_ALGORITHM, FileInfo, FileInfoOpts, FileMeta, get_file_info,
};
use rustfs_utils::HashAlgorithm;
use rustfs_utils::http::{SUFFIX_ACTUAL_SIZE, SUFFIX_INLINE_DATA, get_consistent_str, insert_str, remove_str};
use std::io::{self, Cursor};
use std::sync::Arc;
use time::OffsetDateTime;
use uuid::Uuid;
const OBJECT: &str = "pool.bin";
const MAX_SHARDS: usize = 16;
#[derive(Debug, thiserror::Error)]
pub(super) enum PoolMetaCodecError {
#[error("unsupported inline pool metadata layout")]
UnsupportedLayout,
#[error("pool metadata shard does not match its physical disk")]
ShardPlacement,
#[error("pool metadata replicas disagree on persistent attributes")]
InconsistentMetadata,
#[error("pool metadata payload does not match its metadata or shards")]
PayloadMismatch,
#[error("invalid inline pool metadata")]
Metadata(#[source] rustfs_filemeta::Error),
#[error("inline pool metadata integrity verification failed")]
Integrity(#[source] io::Error),
}
impl From<rustfs_filemeta::Error> for PoolMetaCodecError {
fn from(value: rustfs_filemeta::Error) -> Self {
Self::Metadata(value)
}
}
impl From<io::Error> for PoolMetaCodecError {
fn from(value: io::Error) -> Self {
Self::Integrity(value)
}
}
type Result<T> = std::result::Result<T, PoolMetaCodecError>;
/// Rejects layouts outside the narrow, current inline pool metadata writer.
pub(super) fn parse_inline_info(raw: &Bytes) -> Result<FileInfo> {
if FileMeta::read_format_versions(raw)? != (1, 3, 3, 3) {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let meta = FileMeta::load(raw)?;
if meta.versions.len() != 1 {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
if meta.versions[0]
.parse_version_meta()?
.object
.is_none_or(|object| object.bitrot_checksum_algo != ChecksumAlgo::HighwayHash)
{
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let info = get_file_info(
raw,
RUSTFS_META_BUCKET,
OBJECT,
"",
FileInfoOpts {
data: true,
include_free_versions: true,
include_part_checksums: true,
},
)?;
let size = usize::try_from(info.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
let erasure = &info.erasure;
let shards = erasure.data_blocks.checked_add(erasure.parity_blocks);
if !info.is_valid()
|| info.deleted
|| info.mark_deleted
|| info.version_id.is_some_and(|id| !id.is_nil())
|| info.versioned
|| info.uses_legacy_checksum
|| info.data_dir.is_none_or(|id| id.is_nil())
|| info.mod_time.is_none_or(|modified| modified <= OffsetDateTime::UNIX_EPOCH)
|| info.checksum.is_some()
|| erasure.algorithm != ERASURE_ALGORITHM
|| erasure.block_size != BLOCK_SIZE_V2
|| erasure.data_blocks == 0
|| erasure.data_blocks != erasure.parity_blocks
|| shards.is_none_or(|count| !(2..=MAX_SHARDS).contains(&count))
|| size == 0
|| size > BLOCK_SIZE_V2
|| info.parts.len() != 1
|| info.data.is_none()
|| get_consistent_str(&info.metadata, SUFFIX_INLINE_DATA) != Some("true")
{
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let part = &info.parts[0];
let etag = info.metadata.get("etag").ok_or(PoolMetaCodecError::UnsupportedLayout)?;
if part.number != 1
|| part.size != size
|| part.actual_size != info.size
|| part.etag != *etag
|| part.index.is_some()
|| part.checksums.is_some()
|| part.error.is_some()
|| etag.len() != 32
|| !etag
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|| get_consistent_str(&info.metadata, SUFFIX_ACTUAL_SIZE).and_then(|actual| actual.parse::<usize>().ok()) != Some(size)
|| !erasure.checksums.is_empty()
{
return Err(PoolMetaCodecError::UnsupportedLayout);
}
Ok(info)
}
fn validate_placement(info: &FileInfo, physical_index: usize, disk_count: usize) -> Result<()> {
if info.erasure.distribution.len() != disk_count
|| info.erasure.data_blocks + info.erasure.parity_blocks != disk_count
|| info.erasure.distribution.get(physical_index).copied() != Some(info.erasure.index)
{
return Err(PoolMetaCodecError::ShardPlacement);
}
Ok(())
}
fn erasure(info: &FileInfo) -> Result<Erasure> {
Erasure::try_new(info.erasure.data_blocks, info.erasure.parity_blocks, info.erasure.block_size)
.map_err(|_| PoolMetaCodecError::UnsupportedLayout)
}
fn md5(payload: &[u8]) -> String {
hex_simd::encode_to_string(HashAlgorithm::Md5.hash_encode(payload).as_ref(), hex_simd::AsciiCase::Lower)
}
async fn verified_shard(info: &FileInfo) -> Result<Vec<u8>> {
let size = usize::try_from(info.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
let shard_size = calc_shard_size(size, info.erasure.data_blocks);
let data = info.data.as_ref().ok_or(PoolMetaCodecError::UnsupportedLayout)?;
if data.len() != shard_size + HashAlgorithm::HighwayHash256S.size() {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let mut reader = BitrotReader::new(Cursor::new(data.clone()), shard_size, HashAlgorithm::HighwayHash256S, false);
if let Some(integrity) = &info.parts[0].integrity {
let proof = PartProofReader::new(integrity.clone(), std::slice::from_ref(info), &[None], RUSTFS_META_BUCKET, OBJECT)?;
reader.set_integrity(ShardVerifier::new(proof, info.erasure.index - 1, 0, None)?)?;
}
let mut shard = vec![0; shard_size];
if reader.read(&mut shard).await? != shard_size {
return Err(PoolMetaCodecError::PayloadMismatch);
}
Ok(shard)
}
fn generation_key(info: &FileInfo) -> FileInfo {
let mut key = info.clone();
key.erasure.index = 0;
key.data = None;
key
}
/// Decodes every reconstructable generation without making a quorum decision.
///
/// All physical disks must be present and every supplied shard must verify.
/// Groups below the erasure reconstruction threshold are left undecoded; the
/// caller must match every such shard against an independently known payload
/// with [`validate_inline_pool_meta_shard`] before proposing any mutation.
pub(super) async fn decode_inline_pool_meta_replicas(raw: &[Bytes]) -> Result<Vec<Bytes>> {
if !(2..=MAX_SHARDS).contains(&raw.len()) {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
struct Group {
key: FileInfo,
present: usize,
shards: Vec<Option<Vec<u8>>>,
}
let mut groups: Vec<Group> = Vec::new();
for (physical_index, bytes) in raw.iter().enumerate() {
let info = parse_inline_info(bytes)?;
validate_placement(&info, physical_index, raw.len())?;
let shard = verified_shard(&info).await?;
let key = generation_key(&info);
let group_index = if let Some(index) = groups.iter().position(|group| group.key == key) {
index
} else {
groups.push(Group {
key,
present: 0,
shards: vec![None; raw.len()],
});
groups.len() - 1
};
let group = &mut groups[group_index];
let slot = &mut group.shards[info.erasure.index - 1];
if slot.is_some() {
return Err(PoolMetaCodecError::ShardPlacement);
}
*slot = Some(shard);
group.present += 1;
}
let mut decoded = Vec::with_capacity(groups.len());
for mut group in groups {
if group.present < group.key.erasure.data_blocks {
continue;
}
let codec = erasure(&group.key)?;
codec.decode_data(&mut group.shards)?;
let size = usize::try_from(group.key.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
let mut payload = Vec::with_capacity(size);
for shard in group.shards.iter().take(group.key.erasure.data_blocks) {
let shard = shard.as_ref().ok_or(PoolMetaCodecError::PayloadMismatch)?;
let remaining = size.saturating_sub(payload.len());
payload.extend_from_slice(&shard[..remaining.min(shard.len())]);
}
if payload.len() != size || group.key.metadata.get("etag") != Some(&md5(&payload)) {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let encoded = codec.encode_data(&payload)?;
if group
.shards
.iter()
.zip(&encoded)
.any(|(actual, expected)| actual.as_ref().is_some_and(|actual| actual.as_slice() != expected.as_ref()))
{
return Err(PoolMetaCodecError::PayloadMismatch);
}
decoded.push(Bytes::from(payload));
}
Ok(decoded)
}
/// Verifies a minority shard against a payload whose authority was established elsewhere.
pub(super) async fn validate_inline_pool_meta_shard(raw: &Bytes, physical_index: usize, payload: &[u8]) -> Result<()> {
let info = parse_inline_info(raw)?;
validate_placement(&info, physical_index, info.erasure.distribution.len())?;
if usize::try_from(info.size).ok() != Some(payload.len()) || info.metadata.get("etag") != Some(&md5(payload)) {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let actual = verified_shard(&info).await?;
let encoded = erasure(&info)?.encode_data(payload)?;
if encoded
.get(info.erasure.index - 1)
.is_none_or(|expected| expected.as_ref() != actual.as_slice())
{
return Err(PoolMetaCodecError::PayloadMismatch);
}
Ok(())
}
fn persistent_attributes(info: &FileInfo) -> FileInfo {
let mut attributes = generation_key(info);
attributes.data_dir = None;
attributes.mod_time = None;
attributes.size = 0;
attributes.parts.clear();
attributes.erasure.checksums.clear();
attributes.metadata.remove("etag");
remove_str(&mut attributes.metadata, SUFFIX_ACTUAL_SIZE);
rustfs_filemeta::shard_integrity::clear_integrity_metadata(&mut attributes.metadata);
attributes
}
/// Builds disk-specific inline heads while retaining each disk's existing FileMeta.
/// The caller supplies the independently authorized canonical COMMIT payload.
pub(super) async fn encode_inline_pool_meta_replicas(
raw: &[Bytes],
canonical: &[u8],
modified: OffsetDateTime,
) -> Result<Vec<Bytes>> {
if !(2..=MAX_SHARDS).contains(&raw.len()) || canonical.is_empty() || canonical.len() > BLOCK_SIZE_V2 {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let mut infos = Vec::with_capacity(raw.len());
for (physical_index, bytes) in raw.iter().enumerate() {
let info = parse_inline_info(bytes)?;
validate_placement(&info, physical_index, raw.len())?;
verified_shard(&info).await?;
if info.mod_time.is_none_or(|previous| modified <= previous) {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
if let Some(first) = infos.first()
&& persistent_attributes(first) != persistent_attributes(&info)
{
return Err(PoolMetaCodecError::InconsistentMetadata);
}
infos.push(info);
}
let mut base = infos.first().ok_or(PoolMetaCodecError::UnsupportedLayout)?.clone();
let protected = infos.iter().any(|info| info.parts[0].integrity.is_some());
let codec = Arc::new(erasure(&base)?);
let mut writers: Vec<Option<BitrotWriterWrapper>> = std::iter::repeat_with(|| None).take(raw.len()).collect();
let (_, written, shards, integrity) = Arc::clone(&codec)
.encode_with_shard_integrity(
Cursor::new(Bytes::copy_from_slice(canonical)),
&mut writers,
raw.len(),
1,
IntegrityEncodeMode::Inline(canonical.len()),
protected,
)
.await?;
if written != canonical.len() {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let shards = shards.ok_or(PoolMetaCodecError::PayloadMismatch)?;
let etag = md5(canonical);
base.data_dir = Some(Uuid::new_v4());
base.mod_time = Some(modified);
base.size = i64::try_from(canonical.len()).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
base.metadata.insert("etag".to_owned(), etag.clone());
insert_str(&mut base.metadata, SUFFIX_ACTUAL_SIZE, canonical.len().to_string());
base.parts.clear();
base.add_object_part(1, etag, canonical.len(), Some(modified), base.size, None, None);
rustfs_filemeta::shard_integrity::clear_integrity_metadata(&mut base.metadata);
if let Some(integrity) = integrity {
integrity.set_inline_metadata(&mut base)?;
base.parts[0].integrity = Some(integrity.part);
}
base.persist_shard_integrity()?;
base.erasure.checksums = vec![ChecksumInfo {
part_number: 1,
algorithm: HashAlgorithm::HighwayHash256S,
hash: Bytes::new(),
}];
base.set_inline_data();
let mut replacement = Vec::with_capacity(raw.len());
for (bytes, original) in raw.iter().zip(infos) {
let mut info = base.clone();
info.erasure.index = original.erasure.index;
info.data = Some(
shards
.get(info.erasure.index - 1)
.ok_or(PoolMetaCodecError::ShardPlacement)?
.clone(),
);
let mut meta = FileMeta::load(bytes)?;
meta.add_version(info)?;
replacement.push(Bytes::from(meta.marshal_msg()?));
}
Ok(replacement)
}
#[cfg(test)]
pub(super) async fn fixture_inline_pool_meta_replicas(payload: &[u8], modified: OffsetDateTime) -> Vec<Bytes> {
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
let mut base = FileInfo::new(".rustfs.sys/pool.bin", 2, 2);
base.volume = RUSTFS_META_BUCKET.to_owned();
base.name = OBJECT.to_owned();
base.data_dir = Some(Uuid::new_v4());
base.mod_time = Some(modified);
base.size = i64::try_from(payload.len()).expect("test payload should fit i64");
let etag = md5(payload);
base.metadata.insert("etag".to_owned(), etag.clone());
base.metadata
.insert("content-type".to_owned(), "application/octet-stream".to_owned());
insert_str(&mut base.metadata, SUFFIX_ACTUAL_SIZE, payload.len().to_string());
base.set_inline_data();
base.add_object_part(1, etag, payload.len(), Some(modified), base.size, None, None);
let codec = erasure(&base).expect("fixture layout should construct");
let shards = codec.encode_data(payload).expect("fixture erasure encoding should succeed");
let mut raw = Vec::with_capacity(4);
for coding_index in &base.erasure.distribution {
let mut writer =
BitrotWriterWrapper::new(CustomWriter::new_inline_buffer(), codec.shard_size(), HashAlgorithm::HighwayHash256S);
writer
.write(&shards[*coding_index - 1])
.await
.expect("fixture bitrot writer should write");
writer.shutdown().await.expect("fixture bitrot writer should finish");
let mut info = base.clone();
info.erasure.index = *coding_index;
info.data = Some(Bytes::from(writer.into_inline_data().expect("fixture writer should retain inline bytes")));
let mut meta = FileMeta::new();
meta.add_version(info).expect("test inline FileInfo should serialize");
raw.push(Bytes::from(meta.marshal_msg().expect("test xl.meta should serialize")));
}
raw
}
#[cfg(test)]
pub(super) async fn fixture_split_inline_pool_meta_replicas(
prepare: &[u8],
commit: &[u8],
modified: OffsetDateTime,
) -> Vec<Bytes> {
let mut raw = fixture_inline_pool_meta_replicas(prepare, modified).await;
let committed = fixture_inline_pool_meta_replicas(commit, modified + time::Duration::seconds(1)).await;
raw[2..].clone_from_slice(&committed[2..]);
raw
}
#[cfg(test)]
mod tests {
use super::*;
use fixture_inline_pool_meta_replicas as fixtures;
fn time(seconds: i64) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(seconds).expect("test timestamp should be valid")
}
fn metadata(info: FileInfo) -> Bytes {
let mut meta = FileMeta::new();
meta.add_version(info).expect("test inline FileInfo should serialize");
Bytes::from(meta.marshal_msg().expect("test xl.meta should serialize"))
}
#[tokio::test]
async fn pool_meta_codec_reconstructs_both_halves_without_treating_them_as_authority() {
let raw = fixture_split_inline_pool_meta_replicas(b"prepare with predecessor", b"commit", time(10)).await;
let decoded = decode_inline_pool_meta_replicas(&raw)
.await
.expect("two shards should reconstruct each payload");
assert_eq!(decoded.len(), 2);
assert_eq!(decoded[0].as_ref(), b"prepare with predecessor");
assert_eq!(decoded[1].as_ref(), b"commit");
}
#[tokio::test]
async fn pool_meta_codec_requires_known_payload_for_single_minority_shard() {
let previous = fixtures(b"previous", time(10)).await;
let commit = fixtures(b"commit", time(11)).await;
let raw = vec![previous[0].clone(), commit[1].clone(), commit[2].clone(), commit[3].clone()];
let decoded = decode_inline_pool_meta_replicas(&raw)
.await
.expect("three commit shards should reconstruct");
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].as_ref(), b"commit");
validate_inline_pool_meta_shard(&raw[0], 0, b"previous")
.await
.expect("known predecessor should verify");
assert!(matches!(
validate_inline_pool_meta_shard(&raw[0], 0, b"commit").await,
Err(PoolMetaCodecError::PayloadMismatch)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_bitrot_even_with_other_reconstructable_shards() {
let mut raw = fixtures(b"commit", time(10)).await;
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
let mut data = info.data.take().expect("fixture should be inline").to_vec();
data[HashAlgorithm::HighwayHash256S.size()] ^= 1;
info.data = Some(Bytes::from(data));
raw[0] = metadata(info);
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::Integrity(_))
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_trailing_inline_bytes() {
let mut raw = fixtures(b"commit", time(10)).await;
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
let mut data = info.data.take().expect("fixture should be inline").to_vec();
data.push(0);
info.data = Some(Bytes::from(data));
raw[0] = metadata(info);
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::PayloadMismatch)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_duplicate_physical_shard_mapping() {
let mut raw = fixtures(b"commit", time(10)).await;
raw.swap(0, 1);
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::ShardPlacement)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_payload_md5_disagreement_after_valid_bitrot() {
let mut raw = fixtures(b"commit", time(10)).await;
for bytes in &mut raw {
let mut info = parse_inline_info(bytes).expect("fixture should parse");
info.metadata
.insert("etag".to_owned(), "00000000000000000000000000000000".to_owned());
info.parts[0].etag = "00000000000000000000000000000000".to_owned();
*bytes = metadata(info);
}
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::PayloadMismatch)
));
}
#[tokio::test]
async fn pool_meta_codec_reencodes_split_heads_to_one_canonical_generation() {
let prepare = fixtures(b"prepare with predecessor", time(10)).await;
let commit = fixtures(b"commit", time(11)).await;
let raw = vec![prepare[0].clone(), prepare[1].clone(), commit[2].clone(), commit[3].clone()];
let repaired = encode_inline_pool_meta_replicas(&raw, b"commit", time(12))
.await
.expect("split heads should encode");
let decoded = decode_inline_pool_meta_replicas(&repaired)
.await
.expect("repaired set should decode");
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].as_ref(), b"commit");
let info = parse_inline_info(&repaired[0]).expect("repaired metadata should parse");
assert_eq!(info.mod_time, Some(time(12)));
assert_eq!(info.metadata.get("content-type").map(String::as_str), Some("application/octet-stream"));
for (index, bytes) in repaired.iter().enumerate() {
validate_inline_pool_meta_shard(bytes, index, b"commit")
.await
.expect("every repaired disk should match commit");
}
}
#[tokio::test]
async fn pool_meta_codec_preserves_required_persistent_metadata_agreement() {
let mut raw = fixtures(b"commit", time(10)).await;
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
info.metadata.insert("content-type".to_owned(), "text/plain".to_owned());
raw[0] = metadata(info);
assert!(matches!(
encode_inline_pool_meta_replicas(&raw, b"commit", time(11)).await,
Err(PoolMetaCodecError::InconsistentMetadata)
));
}
#[tokio::test]
async fn pool_meta_codec_requires_strictly_newer_common_modification_time() {
let raw = fixtures(b"commit", time(10)).await;
assert!(matches!(
encode_inline_pool_meta_replicas(&raw, b"commit", time(10)).await,
Err(PoolMetaCodecError::UnsupportedLayout)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_noninline_heads_and_wrong_disk_count() {
let mut raw = fixtures(b"commit", time(10)).await;
assert!(matches!(
decode_inline_pool_meta_replicas(&raw[..3]).await,
Err(PoolMetaCodecError::ShardPlacement)
));
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
info.data = None;
raw[0] = metadata(info);
assert!(matches!(parse_inline_info(&raw[0]), Err(PoolMetaCodecError::UnsupportedLayout)));
}
}
File diff suppressed because it is too large Load Diff
+4 -38
View File
@@ -473,6 +473,7 @@ mod decommission_lock_order_tests {
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
ctx.update_erasure_type(crate::layout::endpoints::SetupType::DistErasure)
.await;
*ctx.local_disk_map().write().await = other_store.ctx.local_disk_map().read().await.clone();
let endpoints = other_store
.ctx
.endpoints()
@@ -481,43 +482,9 @@ mod decommission_lock_order_tests {
if let Some(deployment_id) = other_store.ctx.deployment_id() {
ctx.set_deployment_id(deployment_id);
}
let authority = ctx
.bind_multipart_proof_topology(&endpoints)
.expect("lease-loss fixture must own its complete native proof topology");
let mut local_disk_map = HashMap::new();
for pool in &mut pools {
let pool = Arc::make_mut(pool);
pool.set_instance_ctx_for_test(Arc::clone(&ctx));
for set in &mut pool.disk_set {
let set = Arc::make_mut(set);
let source_disks = set.disks.read().await.clone();
let mut disks = Vec::with_capacity(set.set_endpoints.len());
for (slot, endpoint) in set.set_endpoints.iter().enumerate() {
// The logical node clone shares durable contents, never a
// native handle already owned by another instance scope.
let disk = crate::disk::new_disk(
endpoint,
&crate::disk::DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("open the lease-loss node's independently owned native handle");
assert!(!Arc::ptr_eq(&disk, source_disks[slot].as_ref().expect("source fixture disk")));
assert_eq!(
crate::disk::DiskAPI::get_disk_id(disk.as_ref()).await.unwrap(),
Some(pool.format.erasure.sets[set.set_index][slot])
);
local_disk_map.insert(endpoint.to_string(), Some(disk.clone()));
disks.push(Some(disk));
}
crate::disk::multipart_source::bind_multipart_proof_snapshot(&disks, &authority)
.expect("lease-loss native handles must bind only to this fixture instance");
set.disks = Arc::new(tokio::sync::RwLock::new(disks));
}
Arc::make_mut(pool).set_instance_ctx_for_test(Arc::clone(&ctx));
}
*ctx.local_disk_map().write().await = local_disk_map;
let store = Arc::new(crate::store::ECStore {
id: uuid::Uuid::new_v4(),
disk_map: other_store.disk_map.clone(),
@@ -532,6 +499,7 @@ mod decommission_lock_order_tests {
),
ctx,
bucket_fence_registry: Arc::default(),
ordinary_classification: Default::default(),
});
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
(store, refresh_calls)
@@ -6919,6 +6887,7 @@ mod decommission_lock_order_tests {
};
owner.apply_to(&mut abort_opts);
let barrier = MultipartCommitBarrier::install(&bucket, object, MultipartCommitPause::AbortAfterDelete);
tokio::time::pause();
let abort = tokio::spawn({
let abort_store = Arc::clone(&lossy_store);
let abort_bucket = bucket.clone();
@@ -6938,9 +6907,6 @@ mod decommission_lock_order_tests {
}
});
barrier.wait_until_paused().await;
// Native cleanup performs real filesystem I/O. Pause time only after
// durable deletion, when the abort is blocked before its release save.
tokio::time::pause();
tokio::task::yield_now().await;
refresh_calls.arm();
tokio::time::advance(Duration::from_secs(11)).await;
+195 -11
View File
@@ -19,6 +19,7 @@ use crate::layout::set_heal::{formats_to_drives_info, new_heal_format_sets};
use crate::multipart_listing::paginate_multipart_listing;
use crate::storage_api_contracts::{
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo},
object::{DeleteAccounting, DeletedObject, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
range::HTTPRangeSpec,
@@ -59,10 +60,16 @@ use std::{
use tokio::sync::RwLock;
use tokio::sync::broadcast::{Receiver, Sender};
use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use tracing::{error, info};
use uuid::Uuid;
type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
const LIST_MULTIPART_SETS_CONCURRENCY: usize = 4;
fn is_idempotent_delete_prefix_error(err: &Error) -> bool {
@@ -92,6 +99,33 @@ pub struct Sets {
ctx: Arc<InstanceContext>,
}
#[cfg(test)]
impl Sets {
pub(crate) fn from_test_set(
set: Arc<SetDisks>,
endpoints: PoolEndpoints,
format: FormatV3,
pool_idx: usize,
parity_count: usize,
ctx: Arc<InstanceContext>,
) -> Arc<Self> {
Arc::new(Self {
id: format.id,
disk_set: vec![set],
pool_idx,
set_count: 1,
set_drive_count: endpoints.drives_per_set,
endpoints,
format,
parity_count,
default_parity_count: parity_count,
distribution_algo: DistributionAlgoVersion::V1,
exit_signal: None,
ctx,
})
}
}
impl Drop for Sets {
fn drop(&mut self) {
if let Some(exit_signal) = self.exit_signal.take() {
@@ -317,17 +351,6 @@ impl Sets {
&self.ctx
}
#[cfg(test)]
pub(crate) async fn stop_endpoint_monitor_for_test(&self) {
if let Some(exit_signal) = &self.exit_signal {
let _ = exit_signal.send(());
// Wait for an in-flight reconnect pass as well as its receiver to exit.
while exit_signal.receiver_count() != 0 {
tokio::task::yield_now().await;
}
}
}
/// Keep simulated peers' metadata ownership separate while sharing disks and lock clients.
#[cfg(test)]
pub(crate) fn set_instance_ctx_for_test(&mut self, ctx: Arc<InstanceContext>) {
@@ -804,6 +827,10 @@ impl crate::storage_api_contracts::object::ObjectOperations for Sets {
mod_time: dst_opts.mod_time,
http_preconditions: dst_opts.http_preconditions.clone(),
quota_admission: dst_opts.quota_admission,
expected_bucket_incarnation_id: dst_opts.expected_bucket_incarnation_id,
bucket_lifecycle_lock_fence: dst_opts.bucket_lifecycle_lock_fence.clone(),
bucket_incarnation_fence: dst_opts.bucket_incarnation_fence.clone(),
object_lock_config_snapshot: dst_opts.object_lock_config_snapshot.clone(),
..Default::default()
};
@@ -886,6 +913,67 @@ impl crate::storage_api_contracts::object::ObjectOperations for Sets {
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::list::ListOperations for Sets {
type Error = Error;
type ListObjectsV2Info = ListObjectsV2Info;
type ListObjectVersionsInfo = ListObjectVersionsInfo;
type ObjectInfoOrErr = ObjectInfoOrErr;
type WalkOptions = WalkOptions;
type WalkCancellation = CancellationToken;
type WalkResultSender = tokio::sync::mpsc::Sender<ObjectInfoOrErr>;
#[tracing::instrument(level = "trace", skip(self))]
async fn 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
}
#[tracing::instrument(skip(self))]
async fn 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
}
async fn 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
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::multipart::MultipartOperations for Sets {
type Error = Error;
@@ -1458,7 +1546,9 @@ mod tests {
use crate::layout::endpoint::Endpoint;
use crate::layout::endpoints::SetupType;
use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::list::ListOperations as _;
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use rustfs_lock::client::local::LocalClient;
use serial_test::serial;
struct SetupTypeGuard {
@@ -2038,6 +2128,100 @@ mod tests {
assert_eq!(second.common_prefixes, expected_prefixes[1..]);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn sets_list_objects_v2_lists_objects_within_the_pool() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let format = FormatV3::new(1, 2);
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_idx in 0..2 {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_idx);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let mut disk_format = format.clone();
disk_format.erasure.this = format.erasure.sets[0][disk_idx];
save_format_file(&Some(disk.clone()), &Some(disk_format))
.await
.expect("format should be saved");
std::mem::forget(dir);
endpoints.push(endpoint);
disks.push(Some(disk));
}
let set_disks = SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(disks)),
2,
1,
0,
0,
endpoints.clone(),
format.clone(),
vec![Arc::new(LocalClient::new()), Arc::new(LocalClient::new())],
)
.await;
let sets = Arc::new(Sets {
id: format.id,
disk_set: vec![set_disks],
pool_idx: 0,
endpoints: PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 2,
endpoints: Endpoints::from(endpoints),
cmd_line: String::new(),
platform: String::new(),
},
format,
parity_count: 1,
set_count: 1,
set_drive_count: 2,
default_parity_count: 1,
distribution_algo: DistributionAlgoVersion::V1,
exit_signal: None,
ctx: bootstrap_ctx(),
});
let bucket = format!("bucket-{}", Uuid::new_v4().simple());
let object = format!("object-{}", Uuid::new_v4().simple());
sets.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"hello".to_vec());
sets.put_object(&bucket, &object, &mut reader, &ObjectOptions::default())
.await
.expect("object should be written");
let result = sets
.clone()
.list_objects_v2(&bucket, "", None, None, 1000, false, None, false)
.await
.expect("pool-level listing should succeed");
assert_eq!(result.objects.len(), 1);
assert_eq!(result.objects[0].name, object);
}
#[tokio::test]
async fn sets_check_abandoned_parts_rejects_invalid_set_scope() {
let format = FormatV3::new(1, 1);
+9 -314
View File
@@ -31,7 +31,7 @@ use crate::storage_api_contracts::{
namespace::NamespaceLocking as _,
object::{HTTPPreconditions, ObjectOperations as _},
};
use crate::store::{DecommissionFixedReadAnchor, ECStore, SourceCleanupMutationFence};
use crate::store::{BucketIncarnationFenceGuard, DecommissionFixedReadAnchor, ECStore, SourceCleanupMutationFence};
use bytes::Bytes;
use rustfs_filemeta::{FileInfo, FileInfoVersions, ObjectPartInfo};
use rustfs_rio::{EtagResolvable, HashReader, HashReaderDetector, Index, TryGetIndex};
@@ -958,26 +958,6 @@ fn is_data_movement_rewritten_transition_metadata(object_info: &ObjectInfo, key:
})
}
fn data_movement_publication_marker_valid(object_info: &ObjectInfo) -> bool {
use crate::disk::multipart_source::{SourceConditionV1, publication_identity_from_metadata};
let publication = match publication_identity_from_metadata(&object_info.user_defined) {
Ok(Some(publication)) => publication,
Ok(None) => return true,
Err(_) => return false,
};
if publication.final_data_dir.is_nil() {
return false;
}
SourceConditionV1 {
lineage: publication.lineage,
generation: publication.source_generation,
attempt: publication.attempt,
}
.validate()
.is_ok()
}
fn is_data_movement_rewritten_metadata(object_info: &ObjectInfo, key: &str, normalize_compression_size: bool) -> bool {
[
SUFFIX_DATA_MOVED,
@@ -992,7 +972,6 @@ fn is_data_movement_rewritten_metadata(object_info: &ObjectInfo, key: &str, norm
]
.iter()
.any(|suffix| is_data_movement_internal_metadata(key, suffix))
|| crate::disk::multipart_source::is_publication_metadata_key(key)
|| is_data_movement_rewritten_transition_metadata(object_info, key)
|| key == rustfs_rio::RUSTFS_MULTIPART_CHECKSUM
|| key == rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE
@@ -1015,14 +994,6 @@ pub(crate) fn is_equivalent_data_movement_metadata(
return false;
}
// Copy can retain a native publication after changing the current key,
// version or placement. Validate that historical record's structure, then
// exclude only this per-copy provenance from content equivalence. Actual
// current scope/content remain independently compared below; this grants
// no native write-quorum proof or Complete replay authority.
if !data_movement_publication_marker_valid(source) || !data_movement_publication_marker_valid(target) {
return false;
}
let normalize_compression_size = source.is_compressed() && target.is_compressed();
if normalize_compression_size
&& !is_compatible_data_movement_marker_presence(
@@ -1062,25 +1033,6 @@ pub(crate) fn is_equivalent_data_movement_object_identity(
target: &ObjectInfo,
compare_mod_time: bool,
compare_part_checksums: bool,
) -> bool {
source.bucket == target.bucket
&& source.name == target.name
&& is_equivalent_data_movement_object_fields(source, target, compare_mod_time, compare_part_checksums)
}
// Exact-delete capacity reconciliation reads a target at a fixed physical key.
// Keep its checked encoded-name contract separate from logical-key equality.
pub(crate) fn is_equivalent_data_movement_capacity_target(source: &ObjectInfo, target: &ObjectInfo) -> bool {
source.bucket == target.bucket
&& source.name == rustfs_utils::path::decode_dir_object(&target.name)
&& is_equivalent_data_movement_object_fields(source, target, true, false)
}
fn is_equivalent_data_movement_object_fields(
source: &ObjectInfo,
target: &ObjectInfo,
compare_mod_time: bool,
compare_part_checksums: bool,
) -> bool {
let (Some(source_actual_size), Some(target_actual_size)) = (effective_actual_size(source), effective_actual_size(target))
else {
@@ -1326,6 +1278,7 @@ pub(crate) enum SourceCleanupError {
#[derive(Clone, Default)]
pub(crate) struct SourceCleanupBucketFence<'a> {
pub(crate) expected_incarnation_id: Option<uuid::Uuid>,
pub(crate) lifecycle_owner: Option<&'a Arc<BucketIncarnationFenceGuard>>,
pub(crate) lifecycle_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
pub(crate) namespace_lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
pub(crate) object_mutation_fence: Option<&'a SourceCleanupMutationFence>,
@@ -1534,9 +1487,10 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
)
};
if bucket_fence
.lifecycle_guard
.is_some_and(rustfs_lock::NamespaceLockGuard::is_lock_lost)
if bucket_fence.lifecycle_owner.is_some_and(|owner| owner.is_lock_lost())
|| bucket_fence
.lifecycle_guard
.is_some_and(rustfs_lock::NamespaceLockGuard::is_lock_lost)
{
return Err(SourceCleanupError::Storage(Error::other(format!(
"{op_label}: bucket incarnation fence was lost before source cleanup"
@@ -1575,6 +1529,9 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
if let Some(object_mutation_fence) = bucket_fence.object_mutation_fence {
object_mutation_fence.add_namespace_lock_fence(&mut opts);
}
if let Some(owner) = bucket_fence.lifecycle_owner {
owner.attach_owner(&mut opts);
}
if let Some(bucket_lifecycle_guard) = bucket_fence.lifecycle_guard {
opts.add_bucket_lifecycle_lock_guard(bucket_lifecycle_guard);
}
@@ -3597,268 +3554,6 @@ mod tests {
assert!(!can_replace_stale_data_movement_target(&delete_marker, &opts));
}
#[test]
fn data_movement_identity_keeps_logical_directory_and_literal_suffix_distinct() {
let fi = FileInfo {
version_id: Some(Uuid::from_u128(1)),
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
size: 10,
metadata: HashMap::from([("etag".to_owned(), "directory-etag".to_owned())]),
..Default::default()
};
let source = ObjectInfo::from_file_info(&fi, "bucket", "directory/", true);
let target = ObjectInfo::from_file_info(&fi, "bucket", &encode_dir_object("directory/"), true);
assert_eq!(target.name, "directory/", "real metadata construction returns the logical name");
assert!(is_equivalent_data_movement_object_identity(&source, &target, true, false));
assert!(is_equivalent_data_movement_capacity_target(&source, &target));
let literal = ObjectInfo {
name: "directory__XLDIR__".to_owned(),
..target
};
assert!(crate::bucket::utils::is_valid_object_name(&literal.name));
assert!(crate::bucket::utils::is_valid_object_name(&source.name));
assert!(!is_equivalent_data_movement_object_identity(&source, &literal, true, false));
}
#[tokio::test]
async fn data_movement_equivalence_validates_each_real_native_multipart_publication() {
use crate::disk::DiskAPI as _;
use crate::disk::multipart_source::{is_publication_metadata_key, publication_identity_from_metadata};
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
let (_source_dirs, source_disks, source_set) = crate::set_disk::hermetic_set_disks_isolated(4).await;
let (_target_dirs, target_disks, target_set) = crate::set_disk::hermetic_set_disks_isolated(4).await;
let bucket = "native-multipart-migration-equivalence";
let object = "native-multipart-migration-equivalence-object";
for disk in source_disks.iter().chain(&target_disks) {
disk.make_volume(bucket).await.expect("create both real bucket volumes");
}
let body = vec![0x39; 1 << 20];
let upload = source_set
.new_multipart_upload(bucket, object, &ObjectOptions::default())
.await
.expect("real source upload");
let part = source_set
.put_object_part(
bucket,
object,
&upload.upload_id,
1,
&mut PutObjReader::from_vec(body.clone()),
&ObjectOptions::default(),
)
.await
.expect("real source part");
source_set
.clone()
.complete_multipart_upload(
bucket,
object,
&upload.upload_id,
vec![CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
}],
&ObjectOptions::default(),
)
.await
.expect("real native source publication");
let source = source_set
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("actual committed source metadata");
let create_opts = data_movement_new_multipart_opts(&source, 0);
let moved_upload = target_set
.new_multipart_upload(bucket, object, &create_opts)
.await
.expect("real temporary transfer upload");
let part_opts = ObjectOptions {
part_number: Some(source.parts[0].number),
preserve_etag: Some(source.parts[0].etag.clone()),
data_movement: true,
..Default::default()
};
let part = target_set
.put_object_part(
bucket,
object,
&moved_upload.upload_id,
1,
&mut PutObjReader::from_vec(body.clone()),
&part_opts,
)
.await
.expect("real transfer part");
let complete_opts = data_movement_complete_multipart_opts(&source, 0, false).expect("compatible transfer options");
target_set
.clone()
.complete_multipart_upload(
bucket,
object,
&moved_upload.upload_id,
vec![CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
}],
&complete_opts,
)
.await
.expect("real native migration publication");
let target = target_set
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("actual committed target metadata");
let source_publication = publication_identity_from_metadata(&source.user_defined)
.expect("source tuple decodes")
.expect("source producer retained its native tuple");
let target_publication = publication_identity_from_metadata(&target.user_defined)
.expect("target tuple decodes")
.expect("target producer retained its own native tuple");
assert_ne!(
source_publication, target_publication,
"physical publications have distinct attempts and directories"
);
assert_ne!(source.data_dir, target.data_dir);
assert!(
are_equivalent_data_movement_parts(&source.parts, &target.parts),
"the real transport preserves each part ETag"
);
assert!(is_equivalent_data_movement_object(&source, &target));
assert_eq!(
resolve_data_movement_overwrite_resume_copy(
&Error::PreconditionFailed,
Ok(Some(target.clone())),
&source,
0,
1,
false,
)
.expect("actual equivalent target resolves the failed create-only condition"),
Some(DataMovementCopy::Committed)
);
for set in [&source_set, &target_set] {
let mut reader = set
.get_object_reader(bucket, object, None, http::HeaderMap::new(), &ObjectOptions::default())
.await
.expect("actual source and target bodies");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read full actual body");
assert_eq!(actual, body);
}
for changed in [
"data-dir",
"version",
"mtime",
"bucket",
"key",
"user-metadata",
"etag",
"checksum",
"parts",
"condition",
"status",
"unknown",
] {
let mut invalid = target.clone();
match changed {
"data-dir" => {
let mut historical = publication_identity_from_metadata(&invalid.user_defined)
.expect("real tuple decodes")
.expect("real tuple exists");
historical.final_data_dir = Uuid::nil();
let encoded = hex_simd::encode_to_string(
rmp_serde::to_vec_named(&historical).expect("encode structurally invalid historical tuple"),
hex_simd::AsciiCase::Lower,
);
for (key, value) in Arc::make_mut(&mut invalid.user_defined).iter_mut() {
if is_publication_metadata_key(key) {
value.clone_from(&encoded);
}
}
}
"version" => invalid.version_id = Some(Uuid::new_v4()),
"mtime" => invalid.mod_time = invalid.mod_time.map(|time| time + time::Duration::seconds(1)),
"bucket" => invalid.bucket.push_str("-other"),
"key" => invalid.name.push_str("-other"),
"etag" => invalid.etag = Some("different-current-etag".to_owned()),
"checksum" => invalid.checksum = Some(Bytes::from_static(b"different-current-checksum")),
"parts" => Arc::make_mut(&mut invalid.parts)[0].etag.push_str("-other"),
"user-metadata" => {
Arc::make_mut(&mut invalid.user_defined).insert("x-amz-meta-extra".to_owned(), "changed".to_owned());
}
suffix => rustfs_utils::http::insert_str(
Arc::make_mut(&mut invalid.user_defined),
&format!("multipart-source-{suffix}"),
"unexpected".to_owned(),
),
}
assert!(
!is_equivalent_data_movement_object(&source, &invalid),
"{changed} cannot bypass metadata equivalence"
);
}
let mut malformed = target.clone();
for (key, value) in Arc::make_mut(&mut malformed.user_defined).iter_mut() {
if is_publication_metadata_key(key) {
*value = "malformed".to_owned();
}
}
assert!(
!is_equivalent_data_movement_object(&source, &malformed),
"malformed publication cannot be ignored"
);
let mut conflicting = target.clone();
let aliases = conflicting
.user_defined
.keys()
.filter(|key| is_publication_metadata_key(key))
.cloned()
.collect::<Vec<_>>();
assert_eq!(aliases.len(), 2, "actual writer stores both canonical publication aliases");
Arc::make_mut(&mut conflicting.user_defined).insert(aliases[0].clone(), "conflicting".to_owned());
assert!(
!is_equivalent_data_movement_object(&source, &conflicting),
"conflicting aliases cannot be ignored"
);
let mut malformed_source = source.clone();
for (key, value) in Arc::make_mut(&mut malformed_source.user_defined).iter_mut() {
if is_publication_metadata_key(key) {
*value = "malformed-source".to_owned();
}
}
assert!(
!is_equivalent_data_movement_object(&malformed_source, &target),
"a malformed present source publication cannot be projected away"
);
let mut takeover = target.clone();
takeover.mod_time = source.mod_time.map(|time| time + time::Duration::seconds(1));
rustfs_utils::http::insert_str(
Arc::make_mut(&mut takeover.user_defined),
SUFFIX_DATA_MOVEMENT_UPLOAD,
data_movement_upload_identity(&source),
);
assert!(is_data_movement_upload_takeover_target(&source, &takeover, true));
rustfs_utils::http::insert_str(
Arc::make_mut(&mut takeover.user_defined),
SUFFIX_DATA_MOVEMENT_UPLOAD,
"different-transfer-identity".to_owned(),
);
assert!(
!is_data_movement_upload_takeover_target(&source, &takeover, true),
"historical publication cannot bypass the exact current transfer identity"
);
let mut legacy = target.clone();
Arc::make_mut(&mut legacy.user_defined).retain(|key, _| !is_publication_metadata_key(key));
assert!(
is_equivalent_data_movement_object(&source, &legacy),
"a legacy destination may lack per-copy provenance"
);
}
#[test]
fn test_is_equivalent_data_movement_object_accepts_matching_metadata() {
let version_id = Uuid::nil();
+1
View File
@@ -3360,6 +3360,7 @@ mod tests {
pool_meta_save_gate: TokioMutex::default(),
ctx,
bucket_fence_registry: Arc::default(),
ordinary_classification: Default::default(),
})
}
+2 -249
View File
@@ -19,7 +19,6 @@ use tracing::Span;
use uuid::Uuid;
const EVENT_OBJECT_NAMESPACE_LOCK: &str = "object_namespace_lock";
const EVENT_OBJECT_NAMESPACE_LOCK_PEER: &str = "object_namespace_lock_peer";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_OBJECT_LOCK: &str = "object_lock";
@@ -57,7 +56,6 @@ struct AttemptContext {
requested_version_id: Option<String>,
namespace: String,
owner: String,
lock_id: Option<String>,
mode: &'static str,
timeout: Duration,
started_at: Instant,
@@ -87,7 +85,6 @@ impl ObjectLockAttempt {
requested_version_id: requested_version_id.map(str::to_owned),
namespace: lock.namespace().to_owned(),
owner: lock.owner().to_owned(),
lock_id: None,
mode,
timeout,
started_at: Instant::now(),
@@ -108,29 +105,9 @@ impl ObjectLockAttempt {
context.finished = true;
context.trace("disabled");
}
Ok(guard) => {
context.lock_id = guard.lock_id().map(ToString::to_string);
Ok(_) => {
context.acquired_at = Some(Instant::now());
context.trace("acquired");
if let NamespaceLockGuard::Standard(guard) = guard {
for (peer_index, peer_lock_id) in guard.granted_lock_ids().enumerate() {
context.parent.in_scope(|| {
tracing::trace!(
target: "rustfs_ecstore::object_lock_diag",
parent: &context.parent,
event = EVENT_OBJECT_NAMESPACE_LOCK_PEER,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_OBJECT_LOCK,
state = "granted",
lock_attempt_id = %context.id,
lock_id = context.lock_id.as_deref(),
peer_lock_id = %peer_lock_id,
peer_index,
"Object namespace peer owner granted"
);
});
}
}
}
Err(error) => {
context.finished = true;
@@ -194,7 +171,6 @@ impl AttemptContext {
mode = self.mode,
owner = %self.owner,
lock_attempt_id = %self.id,
lock_id = self.lock_id.as_deref(),
acquire_ms = duration_ms(acquired_at.duration_since(self.started_at)),
hold_ms = duration_ms(now.duration_since(acquired_at)),
timeout_ms = duration_ms(self.timeout),
@@ -259,238 +235,15 @@ pub(crate) mod tests {
}
pub(crate) fn rows(&self) -> Vec<serde_json::Value> {
self.all_rows()
.into_iter()
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK)
.collect()
}
fn all_rows(&self) -> Vec<serde_json::Value> {
let bytes = self.0.lock().expect("lock diagnostic capture mutex");
let text = std::str::from_utf8(&bytes).expect("UTF-8 diagnostic events");
text.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("JSON diagnostic event"))
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK)
.collect()
}
}
#[test]
#[serial_test::serial]
fn object_lock_diagnostics_join_guard_identity_to_physical_release() {
use rustfs_lock::{
DisabledLockManager, FastObjectLockManager, GlobalLockManager, LocalClient, LockClient, LockRequest, LockType,
NamespaceLock, ObjectKey,
};
temp_env::with_vars([(rustfs_config::ENV_OBJECT_LOCK_DIAG_ENABLE, Some("true"))], || {
let capture = CapturedLockEvents::default();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("diagnostic release runtime");
tracing::subscriber::with_default(capture.subscriber(), || {
runtime.block_on(async {
let clients = (0..4)
.map(|_| Arc::new(LocalClient::with_manager(Arc::new(GlobalLockManager::new()))) as Arc<dyn LockClient>)
.collect::<Vec<_>>();
let lock = NamespaceLockWrapper::new(
NamespaceLock::with_clients_and_quorum("diagnostics".to_owned(), clients.clone(), 4),
ObjectKey::new("bucket", "object"),
"coordinator".to_owned(),
);
let request = tracing::info_span!("request", request_id = "original-multipart-request");
let mut attempt = request.in_scope(|| {
ObjectLockAttempt::start(
"abort_multipart_upload",
"bucket",
"object",
None,
&lock,
"write",
Duration::from_secs(5),
)
});
let result = lock.get_write_lock(Duration::from_secs(5)).await;
attempt.observe(&result);
let guard = result.expect("four real namespace owners");
let id = guard.lock_id().expect("distributed guard identity").clone();
let peer_ids = match &guard {
NamespaceLockGuard::Standard(guard) => {
assert_eq!(guard.granted_lock_ids().count(), 4);
guard.granted_lock_ids().cloned().collect::<std::collections::HashSet<_>>()
}
NamespaceLockGuard::Fast(_) => panic!("real distributed namespace required"),
};
let rows = capture.rows();
assert_eq!(rows.last().expect("acquired event")["fields"]["lock_id"], id.to_string());
let rows = capture.all_rows();
let mappings = rows
.iter()
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK_PEER)
.collect::<Vec<_>>();
assert_eq!(mappings.len(), 4, "every granted peer must map to its logical guard");
assert!(mappings.iter().all(|row| {
row["fields"]["lock_id"] == id.to_string()
&& peer_ids
.iter()
.any(|peer_id| row["fields"]["peer_lock_id"] == peer_id.to_string())
}));
for client in &clients {
let mut owned = 0;
for peer_id in &peer_ids {
owned += usize::from(
client
.check_status(peer_id)
.await
.expect("actual peer owner status")
.is_some(),
);
}
assert_eq!(owned, 1, "each client must hold exactly one actual granted identity");
}
drop((guard, attempt));
assert!(
capture.all_rows().iter().all(|row| row["fields"]["state"] != "released"),
"enqueue is not release acknowledgement"
);
tokio::time::timeout(Duration::from_secs(5), async {
while capture
.all_rows()
.iter()
.filter(|row| {
row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released"
})
.count()
< 4
{
tokio::task::yield_now().await;
}
})
.await
.expect("all physical release callbacks finish");
for client in &clients {
for peer_id in &peer_ids {
assert!(
client
.check_status(peer_id)
.await
.expect("released peer owner status")
.is_none()
);
assert!(!client.release(peer_id).await.expect("duplicate release is absent"));
}
}
let disabled = LocalClient::with_manager(Arc::new(GlobalLockManager::Disabled(DisabledLockManager::new())));
let disabled_request =
LockRequest::new(ObjectKey::new("bucket", "disabled"), LockType::Exclusive, "disabled-owner".to_owned());
assert!(
disabled
.acquire_lock(&disabled_request)
.await
.expect("disabled acquire bypass")
.success
);
assert!(
disabled
.release(&disabled_request.lock_id)
.await
.expect("disabled release bypass")
);
let rows = capture.all_rows();
let releases = rows
.iter()
.filter(|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released")
.collect::<Vec<_>>();
assert_eq!(
releases.len(),
4,
"duplicates and disabled guards cannot acknowledge another physical release"
);
let physical_mappings = rows
.iter()
.filter(|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "mapped")
.collect::<Vec<_>>();
assert_eq!(physical_mappings.len(), 4);
assert!(releases.iter().all(|row| physical_mappings.iter().any(|mapped| {
mapped["fields"]["physical_guard_id"] == row["fields"]["physical_guard_id"]
&& mapped["fields"]["process_id"] == row["fields"]["process_id"]
&& peer_ids
.iter()
.any(|peer_id| mapped["fields"]["lock_id"] == peer_id.to_string())
})));
let dropped = capture.rows().pop().expect("guard drop event");
assert_eq!(dropped["fields"]["lock_id"], id.to_string());
assert!(
dropped["spans"]
.as_array()
.expect("retained request span")
.iter()
.any(|span| span["request_id"] == "original-multipart-request")
);
let manager = Arc::new(FastObjectLockManager::new());
let stale_client = LocalClient::with_manager(Arc::new(GlobalLockManager::Enabled(manager.clone())));
let stale_request = LockRequest::new(
ObjectKey::new("bucket", "externally-released"),
LockType::Exclusive,
"stale-owner".to_owned(),
);
assert!(
stale_client
.acquire_lock(&stale_request)
.await
.expect("real stale owner")
.success
);
assert_eq!(manager.force_unlock(&stale_request.resource), 1);
let mut successor = manager
.acquire_write_lock(stale_request.resource.clone(), "successor-owner")
.await
.expect("real successor guard");
assert!(
stale_client
.release(&stale_request.lock_id)
.await
.expect("retire stale client record")
);
assert_eq!(
manager
.get_lock_info(&stale_request.resource)
.expect("successor remains locked")
.owner
.as_ref(),
"successor-owner"
);
assert_eq!(
capture
.all_rows()
.iter()
.filter(
|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released"
)
.count(),
4,
"a stale guard's failed physical release must not produce an ACK"
);
let successor_id = successor.guard_id();
assert!(successor.release(), "the actual successor release succeeds");
assert!(!successor.release(), "a duplicate explicit release cannot complete again");
drop(successor);
assert_eq!(
capture
.all_rows()
.iter()
.filter(|row| row["fields"]["event"] == "namespace_lock_release"
&& row["fields"]["state"] == "released"
&& row["fields"]["physical_guard_id"] == successor_id)
.count(),
1,
"explicit release followed by Drop records one physical completion"
);
});
});
});
}
fn namespace() -> NamespaceLockWrapper {
let manager = rustfs_lock::GlobalLockManager::Enabled(Arc::new(rustfs_lock::fast_lock::FastObjectLockManager::new()));
NamespaceLockWrapper::new(
File diff suppressed because it is too large Load Diff
+5 -47
View File
@@ -159,20 +159,6 @@ pub fn lstat_std(path: impl AsRef<Path>) -> io::Result<Metadata> {
std::fs::symlink_metadata(path)
}
pub fn create_dir_all_std(path: &Path) -> io::Result<()> {
match std::fs::create_dir(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => std::fs::create_dir_all(path),
Err(e) => Err(e),
}
}
pub async fn make_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref().to_path_buf();
tokio::task::spawn_blocking(move || create_dir_all_std(&path)).await?
}
fn is_dir_error(e: &io::Error) -> bool {
e.raw_os_error() == Some(libc::EISDIR)
|| e.kind() == io::ErrorKind::IsADirectory
@@ -220,11 +206,6 @@ pub fn remove_all_std(path: impl AsRef<Path>) -> io::Result<()> {
}
}
#[cfg(test)]
pub async fn mkdir(path: impl AsRef<Path>) -> io::Result<()> {
fs::create_dir(path.as_ref()).await
}
pub async fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
fs::rename(from, to).await
}
@@ -250,7 +231,7 @@ impl BucketExistenceCache {
}
}
fn check_exists(&self, path: &Path) -> Option<bool> {
fn check_exists(&self, path: &PathBuf) -> Option<bool> {
let mut cache = self.cache.lock().ok()?;
if let Some((timestamp, exists)) = cache.get(path) {
if timestamp.elapsed() < self.ttl {
@@ -267,7 +248,7 @@ impl BucketExistenceCache {
}
}
fn invalidate(&self, path: &Path) {
fn invalidate(&self, path: &PathBuf) {
if let Ok(mut cache) = self.cache.lock() {
cache.remove(path);
}
@@ -279,16 +260,15 @@ static BUCKET_EXISTENCE_CACHE: std::sync::LazyLock<BucketExistenceCache> =
/// Cached access check - reduces statx syscalls
pub async fn cached_access(path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref();
let path_buf = path.as_ref().to_path_buf();
if let Some(exists) = BUCKET_EXISTENCE_CACHE.check_exists(path) {
if let Some(exists) = BUCKET_EXISTENCE_CACHE.check_exists(&path_buf) {
if exists {
return Ok(());
}
return Err(io::Error::new(io::ErrorKind::NotFound, "bucket not found (cached)"));
}
let path_buf = path.to_path_buf();
let result = fs::metadata(&path_buf).await;
match &result {
@@ -304,7 +284,7 @@ pub async fn cached_access(path: impl AsRef<Path>) -> io::Result<()> {
}
pub fn invalidate_bucket_cache(path: impl AsRef<Path>) {
BUCKET_EXISTENCE_CACHE.invalidate(path.as_ref());
BUCKET_EXISTENCE_CACHE.invalidate(&path.as_ref().to_path_buf());
}
#[cfg(test)]
@@ -484,17 +464,6 @@ mod tests {
assert!(metadata.file_type().is_symlink());
}
#[tokio::test]
async fn test_make_dir_all() {
let temp_dir = TempDir::new().unwrap();
let nested_path = temp_dir.path().join("level1").join("level2").join("level3");
// Should create nested directories
assert!(make_dir_all(&nested_path).await.is_ok());
assert!(nested_path.exists());
assert!(nested_path.is_dir());
}
#[tokio::test]
async fn test_remove_file() {
let temp_dir = TempDir::new().unwrap();
@@ -567,17 +536,6 @@ mod tests {
assert!(!dir_path.exists());
}
#[tokio::test]
async fn test_mkdir() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("test_mkdir");
// Create directory
assert!(mkdir(&dir_path).await.is_ok());
assert!(dir_path.exists());
assert!(dir_path.is_dir());
}
#[tokio::test]
async fn test_rename() {
let temp_dir = TempDir::new().unwrap();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,740 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::disk::error::{DiskError, Result};
use std::{fs::File, sync::Arc};
/// A target disk's admitted artifact publication. Physical mutation workers
/// retain this Arc until their final syscall and rollback have finished.
pub struct ArtifactLease {
_active: File,
_key_directory: File,
_seal_directory: File,
_root: File,
}
pub type ArtifactPutLease = ArtifactLease;
pub type ArtifactRepairLease = ArtifactLease;
/// Read-only census owns both already-closed physical mutation domains.
pub(super) struct ArtifactInventoryLease {
_put: Arc<ArtifactLease>,
_repair: Arc<ArtifactLease>,
}
pub(super) async fn drain_closed(root: File, key: [u8; 32], deadline: tokio::time::Instant) -> Result<ArtifactInventoryLease> {
#[cfg(unix)]
{
unix::drain_closed(root, key, deadline).await
}
#[cfg(not(unix))]
{
let _ = (root, key, deadline);
Err(DiskError::UnsupportedDisk)
}
}
#[derive(Clone, Copy)]
pub(crate) enum Domain {
Put,
Repair,
}
impl Domain {
fn directory(self) -> &'static str {
match self {
Self::Put => ".rustfs-artifact-put-seals",
Self::Repair => ".rustfs-artifact-repair-seals",
}
}
fn closed(self) -> &'static [u8] {
match self {
Self::Put => b"rustfs-artifact-put-sealed-v1\n",
Self::Repair => b"rustfs-artifact-repair-sealed-v1\n",
}
}
}
pub(super) async fn pinned_root(disk: &super::LocalDisk) -> Result<File> {
#[cfg(target_os = "linux")]
let root = disk.mount_lease.clone();
#[cfg(all(unix, not(target_os = "linux")))]
let root = disk.artifact_identity_root.clone();
#[cfg(not(unix))]
let _ = disk;
tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
root.try_clone().map_err(DiskError::from)
}
#[cfg(not(unix))]
{
Err(DiskError::other("artifact disk leases require Unix"))
}
})
.await
.map_err(DiskError::from)?
}
pub(super) async fn acquire(root: File, key: [u8; 32]) -> Result<Arc<ArtifactPutLease>> {
acquire_in(root, key, Domain::Put).await
}
pub(super) async fn acquire_in(root: File, key: [u8; 32], domain: Domain) -> Result<Arc<ArtifactLease>> {
#[cfg(unix)]
{
unix::acquire(root, key, domain).await
}
#[cfg(not(unix))]
{
let _ = (root, key, domain);
Err(DiskError::other("artifact disk leases require Unix"))
}
}
pub(super) async fn seal(root: File, key: [u8; 32]) -> Result<()> {
seal_in(root, key, Domain::Put).await
}
pub(super) async fn seal_in(root: File, key: [u8; 32], domain: Domain) -> Result<()> {
#[cfg(unix)]
{
unix::seal(root, key, domain).await
}
#[cfg(not(unix))]
{
let _ = (root, key, domain);
Err(DiskError::other("artifact disk leases require Unix"))
}
}
#[cfg(unix)]
mod unix {
use super::*;
use rustix::fs::{FlockOperation, Mode, OFlags, flock, mkdirat, openat};
use rustix::io::Errno;
use std::{io, io::Read, io::Write, os::unix::fs::MetadataExt, time::Duration};
const POLL_INTERVAL: Duration = Duration::from_millis(10);
const LOCK_TIMEOUT: Duration = Duration::from_secs(30);
struct Opened {
root: File,
seals: File,
key: File,
admission: File,
active: File,
domain: Domain,
}
fn invalid() -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, "invalid artifact seal filesystem state")
}
fn directory(parent: &File, name: &str, device: u64) -> io::Result<File> {
match mkdirat(parent, name, Mode::RWXU) {
Ok(()) | Err(Errno::EXIST) => {}
Err(error) => return Err(error.into()),
}
let file = File::from(openat(
parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
let metadata = file.metadata()?;
if !metadata.is_dir() || metadata.dev() != device || metadata.mode() & 0o077 != 0 {
return Err(invalid());
}
Ok(file)
}
fn regular(file: &File, device: u64) -> io::Result<std::fs::Metadata> {
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.dev() != device || metadata.nlink() != 1 || metadata.mode() & 0o077 != 0 {
return Err(invalid());
}
Ok(metadata)
}
fn lock_file(parent: &File, name: &str, device: u64) -> io::Result<File> {
let file = File::from(openat(
parent,
name,
OFlags::CREATE | OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::RUSR | Mode::WUSR,
)?);
if regular(&file, device)?.len() != 0 {
return Err(invalid());
}
Ok(file)
}
impl Opened {
fn open(root: File, key: [u8; 32], domain: Domain) -> io::Result<Self> {
let metadata = root.metadata()?;
if !metadata.is_dir() {
return Err(invalid());
}
let seals = directory(&root, domain.directory(), metadata.dev())?;
let name = hex_simd::encode_to_string(key, hex_simd::AsciiCase::Lower);
let key = directory(&seals, &name, metadata.dev())?;
// Neither lock inode is ever removed or replaced, including after
// closing. Independent processes must always lock the same inode.
let admission = lock_file(&key, "admission.lock", metadata.dev())?;
let active = lock_file(&key, "active.lock", metadata.dev())?;
Ok(Self {
root,
seals,
key,
admission,
active,
domain,
})
}
fn closed(&self) -> io::Result<Option<File>> {
self.closed_with_flags(OFlags::RDWR)
}
fn closed_with_flags(&self, access: OFlags) -> io::Result<Option<File>> {
let mut file = match openat(
&self.key,
"closed",
access | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::empty(),
) {
Ok(file) => File::from(file),
Err(Errno::NOENT) => return Ok(None),
Err(error) => return Err(error.into()),
};
let length = u64::try_from(self.domain.closed().len()).map_err(|_| invalid())?;
if regular(&file, self.root.metadata()?.dev())?.len() != length {
return Err(invalid());
}
let mut bytes = vec![0; self.domain.closed().len()];
file.read_exact(&mut bytes)?;
if bytes != self.domain.closed() {
return Err(invalid());
}
Ok(Some(file))
}
fn admit(self) -> io::Result<Arc<ArtifactLease>> {
if self.closed()?.is_some() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"artifact attempt mutation domain is closed",
));
}
// Admission serializes the closed check with taking this shared
// physical owner. A sealer cannot pass between those two steps.
flock(&self.active, FlockOperation::NonBlockingLockShared)?;
flock(&self.admission, FlockOperation::Unlock)?;
Ok(Arc::new(ArtifactLease {
_active: self.active,
_key_directory: self.key,
_seal_directory: self.seals,
_root: self.root,
}))
}
fn close(self) -> io::Result<Self> {
let marker = match self.closed()? {
Some(marker) => marker,
None => {
let mut marker = File::from(openat(
&self.key,
"closed",
OFlags::CREATE | OFlags::EXCL | OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::RUSR | Mode::WUSR,
)?);
regular(&marker, self.root.metadata()?.dev())?;
marker.write_all(self.domain.closed())?;
marker
}
};
// No admission is released until the permanent denial and every
// ancestor entry are durable. A failed/cancelled close never removes it.
marker.sync_all()?;
self.admission.sync_all()?;
self.active.sync_all()?;
self.key.sync_all()?;
self.seals.sync_all()?;
self.root.sync_all()?;
flock(&self.admission, FlockOperation::Unlock)?;
Ok(self)
}
}
async fn lock(file: &File, operation: FlockOperation) -> io::Result<()> {
tokio::time::timeout(LOCK_TIMEOUT, async {
loop {
match flock(file, operation) {
Ok(()) => return Ok(()),
Err(Errno::WOULDBLOCK | Errno::INTR) => tokio::time::sleep(POLL_INTERVAL).await,
Err(error) => return Err(error.into()),
}
}
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "artifact disk lease lock deadline"))?
}
async fn open(root: File, key: [u8; 32], domain: Domain) -> Result<Opened> {
tokio::task::spawn_blocking(move || Opened::open(root, key, domain))
.await
.map_err(DiskError::from)?
.map_err(DiskError::from)
}
fn read_error(error: io::Error) -> DiskError {
match error.kind() {
io::ErrorKind::NotFound => DiskError::other("artifact inventory requires closed PUT and repair domains"),
io::ErrorKind::InvalidData | io::ErrorKind::NotADirectory => DiskError::FileCorrupt,
io::ErrorKind::TimedOut => DiskError::Timeout,
_ if error.raw_os_error() == Some(Errno::LOOP.raw_os_error()) => DiskError::FileCorrupt,
_ => DiskError::from(error),
}
}
fn existing_directory(parent: &File, name: &str, device: u64) -> io::Result<File> {
let file = File::from(openat(
parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
let metadata = file.metadata()?;
if metadata.dev() != device || metadata.mode() & 0o077 != 0 {
return Err(invalid());
}
Ok(file)
}
fn existing(root: File, key: [u8; 32], domain: Domain) -> io::Result<Opened> {
let device = root.metadata()?.dev();
let seals = existing_directory(&root, domain.directory(), device)?;
let name = hex_simd::encode_to_string(key, hex_simd::AsciiCase::Lower);
let key = existing_directory(&seals, &name, device)?;
let lock = |name| -> io::Result<File> {
let file = File::from(openat(
&key,
name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::empty(),
)?);
if regular(&file, device)?.len() != 0 {
return Err(invalid());
}
Ok(file)
};
let admission = lock("admission.lock")?;
let active = lock("active.lock")?;
let opened = Opened {
root,
seals,
key,
admission,
active,
domain,
};
if opened.closed_with_flags(OFlags::RDONLY)?.is_none() {
return Err(io::Error::from(io::ErrorKind::NotFound));
}
Ok(opened)
}
fn retained(opened: Opened) -> Arc<ArtifactLease> {
Arc::new(ArtifactLease {
_active: opened.active,
_key_directory: opened.key,
_seal_directory: opened.seals,
_root: opened.root,
})
}
pub(super) async fn drain_closed(
root: File,
key: [u8; 32],
deadline: tokio::time::Instant,
) -> Result<ArtifactInventoryLease> {
let (put, repair) = tokio::task::spawn_blocking(move || {
let put = existing(root.try_clone()?, key, Domain::Put)?;
let repair = existing(root, key, Domain::Repair)?;
Ok::<_, io::Error>((put, repair))
})
.await
.map_err(DiskError::from)?
.map_err(read_error)?;
// Lock order: closed PUT active exclusive -> closed repair active
// exclusive. No admission lock is acquired and no seal is created.
tokio::time::timeout_at(deadline, async {
lock(&put.active, FlockOperation::NonBlockingLockExclusive).await?;
lock(&repair.active, FlockOperation::NonBlockingLockExclusive).await
})
.await
.map_err(|_| DiskError::Timeout)?
.map_err(read_error)?;
Ok(ArtifactInventoryLease {
_put: retained(put),
_repair: retained(repair),
})
}
pub(super) async fn acquire(root: File, key: [u8; 32], domain: Domain) -> Result<Arc<ArtifactLease>> {
let opened = open(root, key, domain).await?;
// Lock order: admission exclusive -> active shared. Contention sleeps
// asynchronously; no blocking executor thread waits for another owner.
lock(&opened.admission, FlockOperation::NonBlockingLockExclusive).await?;
tokio::task::spawn_blocking(move || opened.admit())
.await
.map_err(DiskError::from)?
.map_err(DiskError::from)
}
pub(super) async fn seal(root: File, key: [u8; 32], domain: Domain) -> Result<()> {
let opened = open(root, key, domain).await?;
lock(&opened.admission, FlockOperation::NonBlockingLockExclusive).await?;
let opened = tokio::task::spawn_blocking(move || opened.close())
.await
.map_err(DiskError::from)??;
// Closing is already durable. Cancellation or timeout here only loses
// the confirmation; admission stays permanently closed across reopen.
lock(&opened.active, FlockOperation::NonBlockingLockExclusive).await?;
Ok(())
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::{path::Path, time::Duration};
const KEY: [u8; 32] = [7; 32];
fn root(path: &Path) -> File {
use rustix::fs::{Mode, OFlags, open};
File::from(
open(
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.expect("open pinned test root"),
)
}
fn directory(path: &Path) -> std::path::PathBuf {
path.join(Domain::Put.directory())
.join(hex_simd::encode_to_string(KEY, hex_simd::AsciiCase::Lower))
}
async fn closed(path: &Path) {
tokio::time::timeout(Duration::from_secs(5), async {
while !directory(path).join("closed").exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("sealer reaches its monotonic close point");
}
#[tokio::test]
async fn artifact_repair_closing_domain_is_independent_and_survives_reopen() {
let temp = tempfile::tempdir().expect("disk root");
seal(root(temp.path()), KEY).await.expect("PUT closes first");
let repair = acquire_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("PUT closure permits existing-version repair");
assert!(acquire(root(temp.path()), KEY).await.is_err());
drop(repair);
seal_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("repair closes independently");
assert!(acquire_in(root(temp.path()), KEY, Domain::Repair).await.is_err());
seal_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("reopened repair close is durable");
let other = [8; 32];
seal_in(root(temp.path()), other, Domain::Repair)
.await
.expect("close only repair admission");
drop(
acquire(root(temp.path()), other)
.await
.expect("repair closure permits PUT domain"),
);
}
#[tokio::test]
async fn artifact_repair_cancelled_waiter_keeps_physical_owner_and_durable_close() {
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("repair admitted");
let (entered, started) = tokio::sync::oneshot::channel();
let (release, waiting) = std::sync::mpsc::channel();
let owner = tokio::spawn(async move {
tokio::task::spawn_blocking(move || {
let _owner = writer;
let _ = entered.send(());
let _ = waiting.recv();
})
.await
.expect("repair worker");
});
started.await.expect("physical repair entered");
owner.abort();
assert!(owner.await.expect_err("cancel repair waiter").is_cancelled());
let sealer = tokio::spawn(seal_in(root(temp.path()), KEY, Domain::Repair));
let marker = temp
.path()
.join(Domain::Repair.directory())
.join(hex_simd::encode_to_string(KEY, hex_simd::AsciiCase::Lower))
.join("closed");
tokio::time::timeout(Duration::from_secs(5), async {
while !marker.exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("repair admission durably closes");
assert!(!sealer.is_finished(), "ARTIFACT_REPAIR_CANCELLED_PHYSICAL_TAIL");
sealer.abort();
assert!(sealer.await.expect_err("cancel repair close waiter").is_cancelled());
assert!(acquire_in(root(temp.path()), KEY, Domain::Repair).await.is_err());
let pending = seal_in(root(temp.path()), KEY, Domain::Repair);
tokio::pin!(pending);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_REPAIR_REOPEN_MUST_DRAIN_PHYSICAL_TAIL"
);
release.send(()).expect("release physical repair");
tokio::time::timeout(Duration::from_secs(5), &mut pending)
.await
.expect("physical drain bounded")
.expect("reopened close confirms repair drain");
assert!(acquire_in(root(temp.path()), KEY, Domain::Repair).await.is_err());
}
#[tokio::test]
async fn artifact_put_seal_waits_for_independent_openers_and_last_clone() {
let temp = tempfile::tempdir().expect("disk root");
let first = acquire(root(temp.path()), KEY).await.expect("first admitted writer");
let tail = first.clone();
let second = acquire(root(temp.path()), KEY).await.expect("independent shared writer");
let pending = seal(root(temp.path()), KEY);
tokio::pin!(pending);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_PUT_ACTIVE_WRITERS_DRAIN"
);
drop(first);
drop(second);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_PUT_PHYSICAL_CLONE_RETAINED"
);
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CLOSE_REJECTS_ADMISSION");
drop(tail);
tokio::time::timeout(Duration::from_secs(5), &mut pending)
.await
.expect("physical tail drain bounded")
.expect("seal complete");
seal(root(temp.path()), KEY)
.await
.expect("independent reopen preserves closed state");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CLOSED_AFTER_REOPEN");
}
#[tokio::test]
async fn artifact_put_close_cancellation_leaves_durable_denial() {
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire(root(temp.path()), KEY).await.expect("admitted writer");
let sealer = tokio::spawn(seal(root(temp.path()), KEY));
closed(temp.path()).await;
sealer.abort();
assert!(sealer.await.expect_err("cancel close waiter").is_cancelled());
assert!(
acquire(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_CANCELLED_CLOSE_STAYS_CLOSED"
);
drop(writer);
seal(root(temp.path()), KEY).await.expect("reopen finishes physical drain");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CANCELLED_CLOSE_REOPEN");
}
#[tokio::test]
async fn artifact_put_physical_worker_retains_lease_after_waiter_cancellation() {
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire(root(temp.path()), KEY).await.expect("physical writer admitted");
let (entered, started) = tokio::sync::oneshot::channel();
let (release, waiting) = std::sync::mpsc::channel();
let owner = tokio::spawn(async move {
tokio::task::spawn_blocking(move || {
let _physical_owner = writer;
let _ = entered.send(());
let _ = waiting.recv();
})
.await
.expect("physical worker");
});
started.await.expect("physical syscall entered");
owner.abort();
assert!(owner.await.expect_err("cancel async writer").is_cancelled());
let pending = seal(root(temp.path()), KEY);
tokio::pin!(pending);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_PUT_CANCELLED_PHYSICAL_TAIL"
);
release.send(()).expect("release physical worker");
tokio::time::timeout(Duration::from_secs(5), &mut pending)
.await
.expect("physical worker drains")
.expect("seal confirms real completion");
}
#[tokio::test]
async fn artifact_put_seal_waits_for_another_process() {
const CHILD_ROOT: &str = "RUSTFS_TEST_ARTIFACT_PUT_SEAL_ROOT";
if let Some(path) = std::env::var_os(CHILD_ROOT) {
seal(root(Path::new(&path)), KEY)
.await
.expect("child closes and drains parent writer");
assert!(acquire(root(Path::new(&path)), KEY).await.is_err(), "ARTIFACT_PUT_CHILD_CLOSED");
return;
}
struct Child(std::process::Child);
impl Drop for Child {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire(root(temp.path()), KEY).await.expect("parent owns physical write");
let mut child = Child(
std::process::Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"disk::local::artifact_lease::tests::artifact_put_seal_waits_for_another_process",
])
.env(CHILD_ROOT, temp.path())
.spawn()
.expect("start independent sealer"),
);
closed(temp.path()).await;
assert!(child.0.try_wait().expect("probe child").is_none(), "ARTIFACT_PUT_CROSS_PROCESS_DRAIN");
assert!(
acquire(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_CROSS_PROCESS_ADMISSION_CLOSED"
);
drop(writer);
let status = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Some(status) = child.0.try_wait().expect("child wait") {
break status;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("child physically drains");
assert!(status.success(), "ARTIFACT_PUT_CROSS_PROCESS_SEAL");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_PROCESS_REOPEN_CLOSED");
}
#[tokio::test]
async fn artifact_put_lease_rejects_symlinks_at_every_seal_component() {
use std::os::unix::fs::{PermissionsExt, symlink};
for component in ["directory", "key", "admission.lock", "active.lock", "closed"] {
let temp = tempfile::tempdir().expect("disk root");
let outside = tempfile::tempdir().expect("symlink destination");
let sentinel = outside.path().join("untouched");
std::fs::write(&sentinel, b"unchanged").expect("sentinel");
let target = match component {
"directory" => temp.path().join(Domain::Put.directory()),
"key" => {
std::fs::create_dir(temp.path().join(Domain::Put.directory())).expect("seal directory");
std::fs::set_permissions(temp.path().join(Domain::Put.directory()), std::fs::Permissions::from_mode(0o700))
.expect("private seal directory");
directory(temp.path())
}
file => {
drop(acquire(root(temp.path()), KEY).await.expect("initialize seal files"));
let target = directory(temp.path()).join(file);
if file != "closed" {
std::fs::remove_file(&target).expect("replace fixture lock");
}
target
}
};
symlink(
if matches!(component, "directory" | "key") {
outside.path()
} else {
&sentinel
},
target,
)
.expect("symlink fixture");
assert!(
acquire(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_SYMLINK_REJECTED: {component}"
);
assert!(
seal(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_SYMLINK_SEAL_REJECTED: {component}"
);
assert_eq!(std::fs::read(&sentinel).expect("sentinel intact"), b"unchanged");
}
}
#[tokio::test]
async fn artifact_put_lease_rejects_corrupt_closed_marker() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("disk root");
drop(acquire(root(temp.path()), KEY).await.expect("initialize seal files"));
let marker = directory(temp.path()).join("closed");
std::fs::write(&marker, b"partial").expect("corrupt marker fixture");
std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o600)).expect("marker permissions");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CORRUPT_MARKER_FAILS_CLOSED");
assert!(seal(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CORRUPT_MARKER_NOT_CONFIRMED");
assert_eq!(std::fs::read(marker).expect("corruption retained"), b"partial");
}
#[tokio::test]
async fn artifact_put_lease_stays_on_pinned_root_after_path_replacement() {
let temp = tempfile::tempdir().expect("disk root parent");
let current = temp.path().join("current");
let original = temp.path().join("original");
std::fs::create_dir(&current).expect("initial disk root");
let pin = root(&current);
drop(
acquire(pin.try_clone().expect("root pin clone"), KEY)
.await
.expect("initialize pinned seal"),
);
std::fs::rename(&current, &original).expect("replace configured pathname");
std::fs::create_dir(&current).expect("replacement disk root");
seal(pin, KEY).await.expect("close original disk through its descriptor");
assert!(directory(&original).join("closed").exists(), "ARTIFACT_PUT_ROOT_PINNED");
assert!(!current.join(Domain::Put.directory()).exists(), "ARTIFACT_PUT_REPLACEMENT_UNTOUCHED");
assert!(acquire(root(&original), KEY).await.is_err());
}
}
+213 -444
View File
@@ -281,12 +281,10 @@ type PreparedInlineMetadata = (
Option<StagedRollbackBackup>,
bool,
os::PreparedRenameSource,
Option<Arc<os::PreparedPoolMetaBackup>>,
Option<super::pool_meta_native::DurableHeadReceipt>,
);
enum InlinePreparationOutcome {
Committed(RenameDataResp, Option<super::pool_meta_native::DurableHeadReceipt>),
Committed(RenameDataResp),
CommitErr(DiskError),
Prepared(PreparedInlineMetadata),
}
@@ -347,6 +345,8 @@ async fn prepare_rename_commit_and_destination_metadata(
let publication_root = publication_root.clone();
os::run_blocking_namespace_operation(mutation_lease, move || {
if create_source_parent {
#[cfg(test)]
super::volume_mutation_test_hooks::run(super::volume_mutation_test_hooks::Stage::Create, &source_parent);
std::fs::create_dir_all(&source_parent)?;
}
let guard = match os::prepare_rename_commit_guard(&source_parent, &destination_parent, &base_dir, &publication_root) {
@@ -367,9 +367,15 @@ async fn prepare_rename_commit_and_destination_metadata(
};
#[cfg(not(any(windows, target_os = "linux")))]
let result = {
let _ = mutation_lease;
if create_source_parent {
fs::create_dir_all(source_parent).await.map_err(to_file_error)?;
let source_parent = source_parent.to_path_buf();
os::run_blocking_namespace_operation(mutation_lease, move || {
#[cfg(test)]
super::volume_mutation_test_hooks::run(super::volume_mutation_test_hooks::Stage::Create, &source_parent);
std::fs::create_dir_all(source_parent)
})
.await
.map_err(to_file_error)?;
}
let guard =
os::prepare_rename_commit_guard(source_parent, destination_parent, base_dir, publication_root).map_err(|err| {
@@ -400,80 +406,31 @@ pub(in crate::disk) struct LocalRenamePreflightRejection(());
pub(super) struct RenameDataState {
namespace_owner: Option<Arc<dyn Send + Sync>>,
preflight_rejection: Option<LocalRenamePreflightRejection>,
prepared: Option<Arc<os::NamespaceMutationLease>>,
}
// Keep optional native MPU state machines out of ordinary rename frames.
// Construct the future here as well as pinning it, so its temporary stays off
// the caller's poll stack. Call only from the existing native MPU branches.
#[inline(never)]
fn pin_multipart_future<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
Box::pin(build())
pub(in crate::disk) struct PreparedRenameData {
file_info: FileInfo,
mutation_lease: Arc<os::NamespaceMutationLease>,
}
impl LocalDisk {
#[tracing::instrument(name = "rename_data", target = "rustfs_ecstore::disk::local", level = "trace", skip_all)]
pub(super) async fn rename_data_inner(
async fn prepare_rename_data_admission(
&self,
src_volume: &str,
src_path: &str,
fi: FileInfo,
dst_volume: &str,
dst_path: &str,
state: &mut RenameDataState,
) -> Result<RenameDataResp> {
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), src_volume, src_path).inspect_err(
|_| {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
},
)?;
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), dst_volume, dst_path).inspect_err(
|_| {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
},
)?;
super::pool_meta_native::reject_raw_path(&self.root, src_volume, src_path).inspect_err(|_| {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
})?;
super::pool_meta_native::reject_noncanonical_object(&self.root, dst_volume, dst_path).inspect_err(|_| {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
})?;
crate::hp_guard!("LocalDisk::rename_data");
#[cfg(feature = "e2e-test-hooks")]
if fi.is_healing() {
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
}
source: (&str, &str),
destination: (&str, &str),
source_volume: Option<&str>,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<PreparedRenameData> {
let (src_volume, src_path) = source;
let (dst_volume, dst_path) = destination;
let mut fi = fi;
let pool_meta_condition = if super::pool_meta_native::is_object(&self.root, dst_volume, dst_path) {
Some(super::pool_meta_native::PoolMetaNativeCasCondition::take(&mut fi).inspect_err(|_| {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
})?)
} else {
None
};
let native_timing = pool_meta_condition.is_some();
if pool_meta_condition
.as_ref()
.is_some_and(|condition| fi.data_dir == Some(condition.attempt_id))
{
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
return Err(DiskError::FileCorrupt);
}
if let Some(condition) = &pool_meta_condition {
let new_identity = super::pool_meta_native::PoolMetaNativeFileIdentity::from_file_info(&fi)?;
if condition
.parent
.as_ref()
.is_some_and(|parent| new_identity.mod_time_unix_nanos <= parent.mod_time_unix_nanos)
{
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
return Err(DiskError::ConditionalDeleteConflict);
}
}
// A non-force DeleteBucket must not remove a directory while a local
// object commit is publishing into it. The peer's empty scan remains
// optimistic; this lease establishes the local commit/delete order and
// remains owned by any blocking syscall that outlives async cancellation.
let (dst_volume_dir, destination_object_path) = self.io_get_bucket_and_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) => {
@@ -494,32 +451,8 @@ impl LocalDisk {
Some(token) => Some(self.claim_quota_mutation_fence(dst_volume, dst_path, token).await?),
None => None,
};
// A failed fanout can undo the destination after this disk succeeded.
// Keep the original upload authoritative until coordinator quorum: only
// this private attempt's links and generated metadata may be consumed.
let multipart_staging = if src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
Some(
pin_multipart_future(|| {
self.prepare_multipart_commit_staging(src_path, &fi, dst_volume, state.namespace_owner.clone())
})
.await?,
)
} else {
None
};
let multipart_publication = if let Some(staging) = multipart_staging.as_ref() {
Some(pin_multipart_future(|| self.prepare_native_multipart_publication(src_path, &mut fi, staging)).await?)
} else {
None
};
let (src_volume, src_path) = match multipart_staging.as_ref() {
Some(staging) => (super::super::RUSTFS_META_TMP_BUCKET, staging.path.as_str()),
None => (src_volume, src_path),
};
// Quota admission -> metadata RMW -> namespace/volume publication.
// MPU completion remains parts-backed even for a zero-byte part; its
// version-owned retirement anchor is published with that same DD.
let no_inline = fi.data.is_none() && (fi.size > 0 || multipart_publication.is_some());
let no_inline = fi.data.is_none() && fi.size > 0;
let fi_healing = fi.is_healing();
let staging_prefix = if fi_healing && !no_inline && src_volume == super::super::RUSTFS_META_TMP_BUCKET {
Some(tmp_staging_mutation_path(&self.root, src_path)?)
@@ -531,50 +464,127 @@ impl LocalDisk {
.strip_prefix(self.io_root())
.map_err(|_| DiskError::InvalidPath)?,
);
let metadata_wait = os::StageTimer::new(native_timing, "pool_meta_native_metadata_lease_wait");
let metadata_owner: Option<Arc<dyn Send + Sync>> = match multipart_staging.as_ref() {
Some(staging) => Some(Arc::new((state.namespace_owner.take(), staging.lease.clone()))),
None => state.namespace_owner.take(),
};
let metadata_lease = os::acquire_metadata_mutation_lease(&root_destination_object_path, metadata_owner).await;
drop(metadata_wait);
#[cfg(unix)]
let parent_receipt = if native_timing {
self.pool_meta_durable_head.lock().take().filter(|receipt| {
pool_meta_condition.as_ref().is_some_and(|condition| {
condition.parent_raw_sha256.is_none() && condition.parent.as_ref() == Some(&receipt.identity)
})
})
let metadata_lease = os::acquire_metadata_mutation_lease(&root_destination_object_path, namespace_owner).await;
let mutation_lease = if let Some(source_volume) = source_volume {
os::acquire_rename_data_mutation_lease_for_volumes(
&self.root,
source_volume,
dst_volume,
&destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await
} else {
None
os::acquire_rename_data_mutation_lease_with_owner(
&self.root,
dst_volume,
&destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await
};
#[cfg(unix)]
let mut receipt_mutation = native_timing
.then(|| super::pool_meta_native::ReceiptMutationGuard::new(&self.pool_meta_durable_head, metadata_lease.clone()));
let namespace_wait = os::StageTimer::new(native_timing, "pool_meta_native_namespace_lease_wait");
let mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
&self.root,
dst_volume,
&root_destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await;
drop(namespace_wait);
if let Some(claim) = quota_fence_claim {
mutation_lease.attach_external_guard(claim);
}
if let Some(condition) = &pool_meta_condition {
let _parent_check = os::StageTimer::new(native_timing, "pool_meta_native_parent_check");
if let Err(err) = self.check_pool_meta_parent(condition, &destination_object_path).await {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
return Err(err);
}
}
if fi.is_legacy_indexed_delete_marker() {
fi.erasure.index = 0;
}
fi.validate_for_metadata_read()?;
Ok(PreparedRenameData {
file_info: fi,
mutation_lease,
})
}
pub(in crate::disk) async fn begin_legacy_rename_data(
&self,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
permit: crate::disk::io_admission::DiskIoPermit,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<PreparedRenameData> {
// Capture the same permit, mount FD and listener-instance owner before
// quota -> metadata -> namespace -> sorted volume READ admission.
#[cfg(target_os = "linux")]
let owner: Arc<dyn Send + Sync> = Arc::new((permit, self.mount_lease.clone(), namespace_owner));
#[cfg(not(target_os = "linux"))]
let owner: Arc<dyn Send + Sync> = Arc::new((permit, namespace_owner));
let prepared = self
.prepare_rename_data_admission(fi.clone(), source, destination, Some(source.0), Some(owner))
.await?;
for volume in [Some(source.0), (source.0 != destination.0).then_some(destination.0)]
.into_iter()
.flatten()
{
if !skip_access_checks(volume) {
let path = self.io_get_bucket_path(volume)?;
let metadata = fs::symlink_metadata(path)
.await
.map_err(crate::disk::error_conv::to_volume_error)?;
if !metadata.is_dir() {
return Err(DiskError::VolumeNotFound);
}
}
}
Ok(prepared)
}
pub(in crate::disk) async fn rename_data_prepared(
&self,
source: (&str, &str),
destination: (&str, &str),
prepared: PreparedRenameData,
) -> Result<RenameDataResp> {
let PreparedRenameData {
file_info,
mutation_lease,
} = prepared;
let mut state = RenameDataState {
prepared: Some(mutation_lease),
..Default::default()
};
self.rename_data_inner(source.0, source.1, file_info, destination.0, destination.1, &mut state)
.await
}
#[tracing::instrument(name = "rename_data", target = "rustfs_ecstore::disk::local", level = "trace", skip_all)]
pub(super) async fn rename_data_inner(
&self,
src_volume: &str,
src_path: &str,
fi: FileInfo,
dst_volume: &str,
dst_path: &str,
state: &mut RenameDataState,
) -> Result<RenameDataResp> {
crate::hp_guard!("LocalDisk::rename_data");
#[cfg(feature = "e2e-test-hooks")]
if fi.is_healing() {
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
}
let PreparedRenameData {
file_info: fi,
mutation_lease,
} = if let Some(mutation_lease) = state.prepared.take() {
PreparedRenameData {
file_info: fi,
mutation_lease,
}
} else {
self.prepare_rename_data_admission(
fi,
(src_volume, src_path),
(dst_volume, dst_path),
None,
state.namespace_owner.take(),
)
.await?
};
let no_inline = fi.data.is_none() && fi.size > 0;
let fi_healing = fi.is_healing();
// Snapshot the destination part paths before `fi` is consumed below. These
// are the descriptors a reader may hold for the version this call is about
// to replace (backlog#1145); readers build the identical string in
@@ -610,6 +620,7 @@ impl LocalDisk {
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
}
let dst_volume_dir = self.io_get_bucket_path(dst_volume)?;
if !skip_access_checks(dst_volume)
&& let Err(e) = super::super::fs::access_std(&dst_volume_dir)
{
@@ -627,19 +638,15 @@ impl LocalDisk {
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
}
// Native publication consumes its canonical head. The legacy path
// keeps main's independent recovery authority and staging filename.
let preserve_upload_metadata =
multipart_publication.is_none() && src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET;
// Keep recovery authority separate from active upload metadata so a
// committed upload cannot reopen when set-level cleanup fails.
let preserve_upload_metadata = src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET;
let source_metadata_name = if preserve_upload_metadata {
super::super::MULTIPART_COMMIT_METADATA_FILE
} else {
STORAGE_FORMAT_FILE
};
let src_file_path = match multipart_publication.as_ref() {
Some(publication) => publication.canonical_head(),
None => self.io_get_object_path(src_volume, format!("{src_path}/{source_metadata_name}").as_str())?,
};
let src_file_path = self.io_get_object_path(src_volume, format!("{src_path}/{source_metadata_name}").as_str())?;
let dst_file_path = self.io_get_object_open_path(dst_volume, format!("{}/{}", dst_path, STORAGE_FORMAT_FILE).as_str())?;
// data_dir path (only used by the non-inline commit branch)
@@ -665,22 +672,6 @@ impl LocalDisk {
None
};
if multipart_publication.is_none()
&& let Some((source_directory, target_directory)) = has_data_dir_path.as_ref()
{
// Reject imported provenance before any DD purge or publication.
// Same-disk heal copies only an already verified local anchor.
self.retain_owned_multipart_anchor(
&fi,
dst_volume,
dst_path,
source_directory,
target_directory,
mutation_lease.clone(),
)
.await?;
}
check_path_length(src_file_path.to_string_lossy().to_string().as_str())?;
check_path_length(dst_file_path.to_string_lossy().to_string().as_str())?;
@@ -700,10 +691,8 @@ impl LocalDisk {
.ok_or_else(|| DiskError::other("missing object metadata parent"))?;
// Acquire the common trees before reading destination metadata. On
// Windows this pins the object directory identity across publication.
// Multipart authority already created its source tree. Recreating it
// here would resurrect staging retired before a delayed request arrived.
let (rename_commit_guard, has_dst_buf) = prepare_rename_commit_and_destination_metadata(
!no_inline && !preserve_upload_metadata,
!no_inline,
src_file_parent,
dst_file_parent,
&dst_file_path,
@@ -724,10 +713,10 @@ impl LocalDisk {
if preserve_upload_metadata {
let source_object_path = self.get_object_path(src_volume, src_path)?;
let authority_owner: Arc<dyn Send + Sync> = if source_object_path == root_destination_object_path {
let authority_owner: Arc<dyn Send + Sync> = if source_object_path == self.get_object_path(dst_volume, dst_path)? {
commit.mutation_lease.clone()
} else {
os::acquire_metadata_mutation_lease(&source_object_path, Some(commit.mutation_lease.clone())).await
os::try_acquire_metadata_mutation_lease_with_owner(&source_object_path, commit.mutation_lease.clone())?
};
let authority = self.io_get_object_path(src_volume, &format!("{src_path}/{STORAGE_FORMAT_FILE}"))?;
let recovery = self.io_get_object_path(
@@ -735,14 +724,10 @@ impl LocalDisk {
&format!("{src_path}/{}/{STORAGE_FORMAT_FILE}", super::super::MULTIPART_RECOVERY_DIR),
)?;
check_path_length(recovery.to_string_lossy().as_ref())?;
os::rename_all_with_owner(
&authority,
&recovery,
src_file_parent,
commit.publication_root,
Some(authority_owner.clone()),
)
.await?;
// Both volume READs are already held. Neither source key may wait
// behind a competing owner that is queued behind a volume WRITE.
let recovery_lease = os::try_acquire_namespace_mutation_lease_with_owner(&recovery, authority_owner.clone())?;
os::rename_all_with_lease(&authority, &recovery, &src_volume_dir, commit.publication_root, recovery_lease).await?;
// This is system metadata authority, so use its existing strict
// durability policy even when the object payload is relaxed.
if effective_durability(src_volume).syncs_commit_metadata() {
@@ -784,17 +769,13 @@ impl LocalDisk {
let (old_current_size, old_current_source_checked, old_current_source) =
observe_old_current(has_dst_buf.is_some(), dst_meta_unparsable, &xlmeta, dst_volume, dst_path, version_id);
let has_old_data_dir = xlmeta.find_unshared_data_dir_for_version(Some(version_id));
let rollback_data_dir = if let Some(condition) = &pool_meta_condition {
condition.has_parent().then_some(condition.attempt_id)
} else {
has_old_data_dir.or_else(|| {
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
} else {
None
}
})
};
let rollback_data_dir = has_old_data_dir.or_else(|| {
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
} else {
None
}
});
if let Some(old_data_dir) = has_old_data_dir.as_ref() {
let _ = xlmeta.data.remove_two(version_id, *old_data_dir);
}
@@ -808,10 +789,7 @@ impl LocalDisk {
// after the commit rename, and a crash before the rename means the
// PUT was never acknowledged. A metadata commit: relaxed tiers
// leave it to the page cache.
if let Some(publication) = multipart_publication.as_ref() {
pin_multipart_future(|| publication.prepare_metadata(&new_dst_buf, has_dst_buf.as_deref())).await?;
}
let tmp_meta_sync = if durability.syncs_commit_metadata() || multipart_publication.is_some() {
let tmp_meta_sync = if durability.syncs_commit_metadata() {
SyncMode::FileOnly
} else {
SyncMode::None
@@ -831,9 +809,7 @@ impl LocalDisk {
// rename below to report through the existing rollback path. Payload
// durability is kept by both strict and relaxed.
let tmp_meta_write = {
let src_file_path = multipart_publication
.as_ref()
.map_or_else(|| commit.source_metadata.to_path_buf(), |publication| publication.prepared_path.clone());
let src_file_path = commit.source_metadata.to_path_buf();
let dst_file_path = dst_file_path.clone();
let owned_directory_guard = commit.directory_guard.clone();
let owned_mutation_lease = commit.mutation_lease.clone();
@@ -870,10 +846,6 @@ impl LocalDisk {
// sequential version did.
let prepared_metadata_source = tmp_meta_res?;
shard_sync_res?;
let prepared_metadata_source = match multipart_publication.as_ref() {
Some(publication) => pin_multipart_future(|| publication.install_head(prepared_metadata_source)).await?,
None => prepared_metadata_source,
};
commit.directory_guard = remove_dst_base_before_commit(
dst_path,
commit.directory_guard,
@@ -912,49 +884,30 @@ impl LocalDisk {
"Healing commit could not purge the stale destination data dir"
);
}
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref() {
#[cfg(target_os = "linux")]
let payload_guard = if multipart_publication.is_some() {
Some(
os::multipart_payload_commit_guard(
&commit.directory_guard,
src_data_path.parent().ok_or(DiskError::InvalidPath)?,
commit.mutation_lease.clone(),
)
.await
.map_err(to_file_error)?,
)
} else {
None
};
#[cfg(target_os = "linux")]
let directory_guard = payload_guard.as_ref().unwrap_or(&commit.directory_guard);
#[cfg(not(target_os = "linux"))]
let directory_guard = &commit.directory_guard;
if let Err(err) = os::rename_all_with_commit_guard(
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref()
&& let Err(err) = os::rename_all_with_commit_guard(
src_data_path,
dst_data_path,
&skip_parent,
commit.publication_root,
directory_guard,
&commit.directory_guard,
commit.mutation_lease.clone(),
)
.await
{
info!(
target: "rustfs_ecstore::disk::local",
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "rename_all_data_path_failed",
src_path = ?src_data_path,
dst_path = ?dst_data_path,
error = ?err,
"Disk local rename flow failed"
);
commit.restore_published_data_source().await?;
return Err(err);
}
{
info!(
target: "rustfs_ecstore::disk::local",
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "rename_all_data_path_failed",
src_path = ?src_data_path,
dst_path = ?dst_data_path,
error = ?err,
"Disk local rename flow failed"
);
commit.restore_published_data_source().await?;
return Err(err);
}
#[cfg(test)]
if has_data_dir_path.is_some() {
@@ -992,12 +945,10 @@ impl LocalDisk {
} else {
SyncMode::None
};
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref())
&& multipart_publication.as_ref().is_none_or(|publication| publication.legacy)
{
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref()) {
let backup_parent = dst_file_parent.join(old_data_dir.to_string());
#[cfg(not(windows))]
if let Err(err) = os::make_dir_all(&backup_parent, &skip_parent).await {
if let Err(err) = os::make_dir_all_with_owner(&backup_parent, &skip_parent, commit.mutation_lease.clone()).await {
commit.restore_published_data_source().await?;
return Err(err);
}
@@ -1086,11 +1037,7 @@ impl LocalDisk {
return Err(err);
}
if should_fail_after_metadata_commit(dst_path) {
if let Some(publication) = multipart_publication.as_ref() {
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
} else {
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
}
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
return Err(DiskError::Unexpected);
}
@@ -1117,11 +1064,7 @@ impl LocalDisk {
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_DST_DIR_FSYNC,
fsync_started,
);
if let Some(publication) = multipart_publication.as_ref() {
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
} else {
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
}
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
// The commit rename changed the dst part inodes before this fsync
// failed and rolled them back; drop any fd cached during that
// window so readers re-open the restored inode (rustfs/backlog#1177).
@@ -1158,12 +1101,7 @@ impl LocalDisk {
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_ANCESTOR_DIR_FSYNC,
fsync_started,
);
if let Some(publication) = multipart_publication.as_ref() {
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone()))
.await?;
} else {
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
}
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
// Same post-commit rollback window as above — drop cached
// dst part fds so readers re-open the restored inode
// (rustfs/backlog#1177).
@@ -1189,10 +1127,7 @@ impl LocalDisk {
#[cfg(windows)]
drop(commit.directory_guard);
if multipart_publication.is_none()
&& !preserve_upload_metadata
&& let Some(parent) = src_file_path.parent()
{
if !preserve_upload_metadata && let Some(parent) = src_file_path.parent() {
let _ = std::fs::remove_dir(parent);
}
@@ -1211,24 +1146,9 @@ impl LocalDisk {
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
}
let multipart_source = match multipart_publication.as_ref() {
Some(publication) => match pin_multipart_future(|| publication.finish(commit.mutation_lease.clone())).await {
Ok(result) => Some(Box::new(result)),
Err(error) => {
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
return Err(error);
}
},
None => None,
};
Ok(RenameDataResp {
multipart_source,
old_data_dir: has_old_data_dir,
rollback_data_dir: if multipart_publication.as_ref().is_some_and(|publication| !publication.legacy) {
None
} else {
rollback_data_dir
},
rollback_data_dir,
cleanup_data_dir: has_old_data_dir,
sign: version_signature,
old_current_size,
@@ -1246,42 +1166,14 @@ impl LocalDisk {
let dst_path_for_source = dst_path.to_string();
let dst_volume_dir_for_commit = dst_volume_dir.clone();
let publication_root_for_commit = self.publication_root.clone();
let cleanup_src_parent = multipart_publication.is_none() && !preserve_upload_metadata;
let cleanup_src_parent = !preserve_upload_metadata;
#[cfg(windows)]
let source_parent = src_file_parent.to_path_buf();
let rename_commit_guard_for_preparation = commit.directory_guard.clone();
let sync = durability.syncs_commit_metadata();
#[cfg(unix)]
let receipt_identity = if sync
&& pool_meta_condition
.as_ref()
.is_some_and(|condition| condition.parent_raw_sha256.is_none())
{
Some(super::pool_meta_native::PoolMetaNativeFileIdentity::from_file_info(&fi)?)
} else {
None
};
#[cfg(unix)]
let owned_backup_path = pool_meta_condition
.as_ref()
.filter(|condition| condition.has_parent())
.map(|condition| {
destination_object_path
.join(condition.attempt_id.to_string())
.join(STORAGE_FORMAT_FILE_BACKUP)
});
#[cfg(unix)]
if let Some(backup) = &owned_backup_path {
let parent = backup
.parent()
.ok_or_else(|| DiskError::other("missing owned backup parent"))?;
commit.mutation_lease =
os::acquire_namespace_mutation_lease_with_owner(parent, Some(commit.mutation_lease.clone())).await;
}
#[cfg(test)]
run_inline_before_file_sync_admission(dst_path);
let mut file_sync_admission = if sync {
let _admission = os::StageTimer::new(native_timing, "pool_meta_native_file_sync_admission");
Some(
os::acquire_file_sync_admission(self.file_sync_permits.clone())
.await
@@ -1291,10 +1183,7 @@ impl LocalDisk {
} else {
None
};
let preparation_dispatch = os::StageTimer::new(native_timing, "pool_meta_native_prepare_dispatch_wait");
let prepare_inline_metadata = move |is_cancelled: &dyn Fn() -> bool| {
drop(preparation_dispatch);
let _preparation = os::StageTimer::new(native_timing, "pool_meta_native_prepare_execute");
let mut prepared_metadata_source =
os::create_prepared_rename_source_with_commit_guard(&src, &dst, &rename_commit_guard_for_preparation)?;
#[cfg(windows)]
@@ -1328,9 +1217,7 @@ impl LocalDisk {
// Healing commits never run set-level post-return rollback; keep
// their intra-commit rollback backup in the staging directory so
// `<object>/<rollback_data_dir>/xl.meta.bkp` is never stranded.
let rollback_data_dir = if let Some(condition) = &pool_meta_condition {
condition.has_parent().then_some(condition.attempt_id)
} else if fi_healing {
let rollback_data_dir = if fi_healing {
None
} else {
old_data_dir.or_else(|| {
@@ -1353,61 +1240,13 @@ impl LocalDisk {
// relaxed tiers do no per-object fsync here at all (aligned
// with MinIO's default), trading a documented power-loss
// window for latency.
{
let _new_file = os::StageTimer::new(native_timing, "pool_meta_native_staged_write_sync");
prepared_metadata_source.write_all(&new_buf, sync)?;
}
prepared_metadata_source.write_all(&new_buf, sync)?;
run_inline_preparation_before_backup(&dst_path_for_failpoint);
#[cfg(unix)]
let mut receipt_candidate = receipt_identity.and_then(|identity| {
os::DurableFileReceipt::capture(&publication_root_for_commit, &src, &new_buf)
.ok()
.map(|physical| super::pool_meta_native::DurableHeadReceipt { physical, identity })
});
#[cfg(not(unix))]
let receipt_candidate = None;
#[cfg(unix)]
let owned_backup = match (owned_backup_path.as_ref(), has_dst_buf.as_ref()) {
(Some(backup_path), Some(old_metadata)) => {
let backup = {
let _create = os::StageTimer::new(native_timing, "pool_meta_native_backup_create");
Arc::new(os::prepare_pool_meta_owned_backup(
&dst,
backup_path,
&dst_volume_dir_for_commit,
&publication_root_for_commit,
old_metadata,
should_fail_local_inline_rollback_hardlink(&dst),
)?)
};
let reuses_durability = parent_receipt.as_ref().is_some_and(|receipt| {
backup.can_reuse_durability(&receipt.physical, &publication_root_for_commit, old_metadata)
});
if !backup.is_linked() {
receipt_candidate = None;
}
if sync && !reuses_durability {
let _backup_sync = os::StageTimer::new(native_timing, "pool_meta_native_backup_file_sync");
backup.sync_data()?;
} else if sync {
rustfs_io_metrics::record_put_object_stage_count("pool_meta_native_backup_sync_reused", 1);
}
Some(backup)
}
(Some(_), None) => {
return Err(std::io::Error::new(ErrorKind::InvalidData, "pool metadata predecessor disappeared"));
}
(None, _) => None,
};
#[cfg(not(unix))]
let owned_backup = None;
if let Some(ref old_metadata) = has_dst_buf
&& owned_backup.is_none()
&& (rollback_data_dir.is_some() || fi_healing || sync || cfg!(test))
{
#[cfg(windows)]
let backup_path = {
let _backup = os::StageTimer::new(native_timing, "pool_meta_native_backup_create_sync");
let backup_path = src
.parent()
.ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "missing staging metadata parent"))?
@@ -1416,13 +1255,9 @@ impl LocalDisk {
backup_path
};
#[cfg(not(windows))]
let backup_path = {
let _backup = os::StageTimer::new(native_timing, "pool_meta_native_backup_create");
create_local_inline_rollback_backup(&dst, &src, old_metadata)?
};
let backup_path = create_local_inline_rollback_backup(&dst, &src, old_metadata)?;
#[cfg(not(windows))]
if sync {
let _backup_sync = os::StageTimer::new(native_timing, "pool_meta_native_backup_file_sync");
std::fs::File::open(&backup_path)?.sync_data()?;
}
staged_rollback_path = Some(StagedRollbackBackup(backup_path));
@@ -1481,19 +1316,15 @@ impl LocalDisk {
let _ = std::fs::remove_dir(parent);
}
return Ok::<_, std::io::Error>(InlinePreparationOutcome::Committed(
RenameDataResp {
multipart_source: None,
old_data_dir,
rollback_data_dir,
cleanup_data_dir: old_data_dir,
sign: version_signature,
old_current_size,
old_current_source_checked,
old_current_source,
},
receipt_candidate,
));
return Ok::<_, std::io::Error>(InlinePreparationOutcome::Committed(RenameDataResp {
old_data_dir,
rollback_data_dir,
cleanup_data_dir: old_data_dir,
sign: version_signature,
old_current_size,
old_current_source_checked,
old_current_source,
}));
}
Ok::<_, std::io::Error>(InlinePreparationOutcome::Prepared((
@@ -1506,11 +1337,8 @@ impl LocalDisk {
staged_rollback_path,
destination_was_absent,
prepared_metadata_source,
owned_backup,
receipt_candidate,
)))
};
let preparation_total = os::StageTimer::new(native_timing, "pool_meta_native_prepare_total");
let inline_preparation = if let Some(admission) = file_sync_admission.as_ref() {
os::run_cancellable_blocking_namespace_file_sync_operation(
commit.mutation_lease.clone(),
@@ -1523,7 +1351,6 @@ impl LocalDisk {
}
.map_err(to_file_error)
.map_err(DiskError::from);
drop(preparation_total);
let (
rollback_data_dir,
cleanup_data_dir,
@@ -1534,16 +1361,9 @@ impl LocalDisk {
mut local_rollback_path,
destination_was_absent,
prepared_metadata_source,
owned_backup,
receipt_candidate,
) = match inline_preparation {
Ok(InlinePreparationOutcome::Committed(resp, receipt_candidate)) => {
Ok(InlinePreparationOutcome::Committed(resp)) => {
drop(file_sync_admission.take());
#[cfg(unix)]
self.publish_pool_meta_durable_head(receipt_candidate, dst_file_path.clone(), commit.mutation_lease.clone())
.await;
#[cfg(not(unix))]
let _ = receipt_candidate;
#[cfg(windows)]
drop(commit.directory_guard);
#[cfg(windows)]
@@ -1553,10 +1373,6 @@ impl LocalDisk {
for part_path in &invalidate_part_paths {
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
}
#[cfg(unix)]
if let Some(receipt_mutation) = receipt_mutation.as_mut() {
receipt_mutation.complete();
}
return Ok(resp);
}
Ok(InlinePreparationOutcome::CommitErr(err)) | Err(err) => {
@@ -1590,42 +1406,6 @@ impl LocalDisk {
return Err(DiskError::FileNotFound);
}
#[cfg(unix)]
if let Some(backup) = &owned_backup {
// The backup namespace lease owns the head transaction; retain
// its directory and inode guards in every later detached job.
commit.mutation_lease.attach_external_guard(backup.clone());
#[cfg(test)]
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
let backup_base = dst_volume_dir.clone();
let backup_root = commit.publication_root.clone();
if let Some(admission) = file_sync_admission.as_ref() {
let _backup_dir = os::StageTimer::new(native_timing, "pool_meta_native_backup_dir_sync");
let fsync_started = rustfs_io_metrics::put_stage_timer();
let backup = backup.clone();
let result =
os::run_blocking_namespace_file_sync_operation(commit.mutation_lease.clone(), admission, move || {
backup.verify_paths(&backup_base, &backup_root)?;
backup.sync_directory()
})
.await;
rustfs_io_metrics::record_put_object_stage_duration_from(
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_BACKUP_DIR_FSYNC,
fsync_started,
);
result.map_err(to_file_error)?;
} else {
let backup = backup.clone();
os::run_blocking_namespace_operation(commit.mutation_lease.clone(), move || {
backup.verify_paths(&backup_base, &backup_root)
})
.await
.map_err(to_file_error)?;
}
}
#[cfg(not(unix))]
let _ = owned_backup;
if let (Some(rollback_data_dir), Some(staged_backup)) =
(rollback_data_dir, local_rollback_path.as_ref().map(StagedRollbackBackup::path))
{
@@ -1635,19 +1415,24 @@ impl LocalDisk {
let backup_path = dst_parent
.join(rollback_data_dir.to_string())
.join(STORAGE_FORMAT_FILE_BACKUP);
// rename_all acquires the backup path's namespace lease. Do not
// hold a disk admission while acquiring another namespace lock.
// Keep backup mutations serialized, but never wait for a second
// namespace while holding the volume READ: its owner can be
// queued behind a volume DELETE that is waiting for this commit.
drop(file_sync_admission.take());
let backup_publish = os::StageTimer::new(native_timing, "pool_meta_native_backup_publish");
let backup_result = os::rename_all_with_owner(
staged_backup,
&backup_path,
&dst_volume_dir,
commit.publication_root,
Some(commit.mutation_lease.clone()),
)
.await;
drop(backup_publish);
let backup_result =
match os::try_acquire_namespace_mutation_lease_with_owner(&backup_path, commit.mutation_lease.clone()) {
Ok(lease) => {
os::rename_all_with_lease(
staged_backup,
&backup_path,
&dst_volume_dir,
commit.publication_root,
lease,
)
.await
}
Err(err) => Err(err),
};
if let Err(err) = backup_result {
let _ = remove_file_if_exists(staged_backup);
return Err(err);
@@ -1655,7 +1440,6 @@ impl LocalDisk {
#[cfg(test)]
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
if sync {
let _admission = os::StageTimer::new(native_timing, "pool_meta_native_file_sync_readmission");
file_sync_admission = Some(
os::acquire_file_sync_admission(self.file_sync_permits.clone())
.await
@@ -1666,7 +1450,6 @@ impl LocalDisk {
if let Some(admission) = file_sync_admission.as_ref()
&& let Some(backup_parent) = backup_path.parent()
{
let _backup_dir = os::StageTimer::new(native_timing, "pool_meta_native_backup_dir_sync");
let fsync_started = rustfs_io_metrics::put_stage_timer();
if let Err(err) =
os::fsync_dir_with_namespace_file_sync_limit(backup_parent, commit.mutation_lease.clone(), admission)
@@ -1686,7 +1469,6 @@ impl LocalDisk {
local_rollback_path = None;
}
let head_publish = os::StageTimer::new(native_timing, "pool_meta_native_head_publish");
let commit_result = if should_fail_commit_rename(dst_path) {
Err(DiskError::other("test fail during metadata commit rename"))
} else {
@@ -1701,7 +1483,6 @@ impl LocalDisk {
)
.await
};
drop(head_publish);
if let Err(err) = commit_result {
if let Some(backup_path) = local_rollback_path.as_ref().map(StagedRollbackBackup::path) {
let _ = remove_file_if_exists(backup_path);
@@ -1722,7 +1503,6 @@ impl LocalDisk {
if let Some(admission) = file_sync_admission.as_ref()
&& let Some(dst_parent) = dst_file_path.parent()
{
let _head_dir = os::StageTimer::new(native_timing, "pool_meta_native_head_dir_sync");
let fsync_started = rustfs_io_metrics::put_stage_timer();
if let Err(err) = os::fsync_commit_directory(
dst_parent,
@@ -1803,12 +1583,6 @@ impl LocalDisk {
return Err(DiskError::from(err));
}
#[cfg(unix)]
self.publish_pool_meta_durable_head(receipt_candidate, dst_file_path.clone(), commit.mutation_lease.clone())
.await;
#[cfg(not(unix))]
let _ = receipt_candidate;
// The commit no longer has a rollback path. Release the Windows
// object identity guard before best-effort staging cleanup.
#[cfg(windows)]
@@ -1838,12 +1612,7 @@ impl LocalDisk {
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
}
#[cfg(unix)]
if let Some(receipt_mutation) = receipt_mutation.as_mut() {
receipt_mutation.complete();
}
Ok(RenameDataResp {
multipart_source: None,
old_data_dir: cleanup_data_dir,
rollback_data_dir,
cleanup_data_dir,
File diff suppressed because it is too large Load Diff
@@ -1,93 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::disk::endpoint::Endpoint;
use futures::FutureExt;
#[inline(never)]
fn heap_scope<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
Box::pin(build())
}
#[tokio::test]
async fn generic_delete_recovery_cannot_restore_or_prepare_any_physical_multipart_scope() {
let temporary = tempfile::tempdir().expect("recovery scope disk");
let endpoint = Endpoint::try_from(temporary.path().to_string_lossy().as_ref()).expect("local endpoint");
let disk = heap_scope(|| LocalDisk::new(&endpoint, false)).await.expect("local disk");
let hash = "a".repeat(64);
let upload = Uuid::new_v4().to_string();
let object = format!("{hash}/{upload}");
let source = disk
.io_get_object_path(RUSTFS_META_MULTIPART_BUCKET, &object)
.expect("actual source root");
std::fs::create_dir_all(&source).expect("existing source root");
// Scope rejection precedes any attempt to interpret or replace these bytes.
let head = source.join(STORAGE_FORMAT_FILE);
std::fs::write(&head, b"existing source head must not be read or replaced").expect("existing head bytes");
let original = std::fs::read(&head).expect("original physical bytes");
let logical = disk
.get_object_path(RUSTFS_META_MULTIPART_BUCKET, &object)
.expect("configured metadata key");
let owner: Option<Arc<dyn Send + Sync>> = Some(os::acquire_metadata_mutation_lease(&logical, None).await);
let transaction = Uuid::new_v4();
let aliases = [
(RUSTFS_META_MULTIPART_BUCKET.to_owned(), object.clone()),
(format!("{RUSTFS_META_MULTIPART_BUCKET}/."), object.clone()),
(format!("{RUSTFS_META_MULTIPART_BUCKET}/{hash}"), upload),
(crate::disk::RUSTFS_META_BUCKET.to_owned(), format!("multipart/{object}")),
];
for (volume, object) in aliases {
let result = heap_scope(|| {
disk.delete_recovery_settle(&volume, &object, transaction, [0; 32], DeleteRecoveryAction::Rollback, owner.clone())
})
.now_or_never()
.expect("scope is rejected before metadata lock acquisition");
assert_eq!(
result.expect_err("generic recovery cannot mutate MPI"),
DiskError::ConditionalDeleteConflict
);
let result = heap_scope(|| {
disk.delete_recovery_settle_owned(
&volume,
&object,
transaction,
[0; 32],
DeleteRecoveryAction::Rollback,
owner.clone(),
)
})
.now_or_never()
.expect("owned rollback is rejected before I/O");
assert_eq!(
result.expect_err("owned recovery cannot restore MPI head"),
DiskError::ConditionalDeleteConflict
);
let result = heap_scope(|| {
disk.prepare_delete_recovery(
&volume,
&object,
Some(&original),
&[],
None,
DeletePreparationContext {
transaction,
bucket_incarnation: None,
namespace_owner: owner.clone(),
},
)
})
.now_or_never()
.expect("preparation is rejected before decoding or writing metadata");
assert_eq!(
result.expect_err("generic recovery cannot prepare MPI"),
DiskError::ConditionalDeleteConflict
);
assert_eq!(std::fs::read(&head).expect("source remains present"), original);
assert_eq!(std::fs::read_dir(&source).expect("source inventory").count(), 1);
}
assert!(
disk.reject_multipart_delete_recovery_mutation("ordinary-user-bucket", "object")
.is_ok()
);
}
@@ -1,350 +0,0 @@
use super::*;
use crate::disk::SnapshotLeaseToken;
async fn deferred_owned_undo() -> (Fixture, PublicationIdentity, SnapshotLeaseToken) {
let fixture = Fixture::new().await;
let result = fixture
.publish_zero_on(&[0])
.await
.pop()
.expect("real first-key native publication")
.1;
let publication = result.publication.clone();
let path = format!("object/{}", publication.final_data_dir);
let token = fixture.disks[0]
.acquire_snapshot_lease("bucket", &path)
.await
.expect("retain the actual published DD");
fixture.disks[0]
.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
expected: Box::new(result.proof.snapshot),
publication: publication.clone(),
capability: result.undo_capability,
round: Fixture::round_for(&publication),
}))
.await
.expect("owned Undo restores absence and defers only its exact DD");
assert!(!fixture.disks[0].root.join("bucket/object/xl.meta").exists());
(fixture, publication, token)
}
#[tokio::test]
async fn native_multipart_owned_undo_deferred_reclaim_rechecks_successors_references_and_ownership() {
for changed in [
"successor",
"referenced",
"child",
"foreign-anchor",
"missing-anchor",
"unknown-file",
] {
let (fixture, publication, token) = Box::pin(deferred_owned_undo()).await;
let disk = &fixture.disks[0];
let path = format!("object/{}", publication.final_data_dir);
let data = disk.io_get_object_path("bucket", &path).expect("actual failed DD");
let part = std::fs::read(data.join("part.1")).expect("real selected shard before deferred callback");
let sidecar = std::fs::read(data.join("part.1.meta")).expect("real selected generation before callback");
let mut successor = None;
let mut child = None;
match changed {
"successor" | "referenced" => {
let referenced = (changed == "referenced").then_some(publication.final_data_dir);
let info = super::super::super::test::test_file_info(
"object",
Uuid::new_v4(),
referenced,
Some(bytes::Bytes::from_static(b"newer PUT body")),
);
disk.rename_data(RUSTFS_META_TMP_BUCKET, "real-newer-put", info, "bucket", "object")
.await
.expect("real ordinary PUT while old failed DD is snapshot-protected");
let raw = std::fs::read(disk.io_get_object_path("bucket", "object/xl.meta").expect("target FI"))
.expect("actual successor FI");
let refs = FileMeta::load(&raw)
.expect("successor metadata")
.get_data_dirs()
.expect("all successor versions");
assert_eq!(refs.contains(&Some(publication.final_data_dir)), changed == "referenced");
successor = Some(raw);
}
"child" => {
let child_path = format!("{path}/child");
let info = super::super::super::test::test_file_info(
"child",
Uuid::nil(),
None,
Some(bytes::Bytes::from_static(b"child PUT body")),
);
disk.rename_data(RUSTFS_META_TMP_BUCKET, "real-child-put", info, "bucket", &child_path)
.await
.expect("real child PUT under the UUID-shaped S3 prefix");
child = Some(std::fs::read(data.join("child/xl.meta")).expect("actual child metadata"));
}
"foreign-anchor" => {
let mut anchor: UntrustedAnchorBlob =
rmp_serde::from_slice(&std::fs::read(data.join(".mpu-source-retirement")).unwrap()).unwrap();
anchor.disk_id = Uuid::new_v4();
std::fs::write(data.join(".mpu-source-retirement"), rmp_serde::to_vec_named(&anchor).unwrap()).unwrap();
}
"missing-anchor" => {
std::fs::remove_file(data.join(".mpu-source-retirement")).unwrap();
}
"unknown-file" => {
std::fs::write(data.join("unknown"), b"protected").unwrap();
}
_ => unreachable!(),
}
let anchor = std::fs::read(data.join(".mpu-source-retirement")).ok();
let reclaimed = disk.release_snapshot_lease("bucket", &path, token).await;
if changed == "successor" {
reclaimed.expect("unreferenced owned failed DD is reclaimable beside a newer real PUT");
assert!(!data.exists());
} else {
assert!(reclaimed.is_err(), "{changed} must reject the actual callback before deleting payload");
assert_eq!(std::fs::read(data.join("part.1")).expect("protected actual shard"), part);
assert_eq!(std::fs::read(data.join("part.1.meta")).expect("protected actual sidecar"), sidecar);
assert_eq!(std::fs::read(data.join(".mpu-source-retirement")).ok(), anchor);
}
if let Some(raw) = successor {
assert_eq!(std::fs::read(disk.io_get_object_path("bucket", "object/xl.meta").unwrap()).unwrap(), raw);
}
if let Some(raw) = child {
assert_eq!(std::fs::read(data.join("child/xl.meta")).unwrap(), raw);
}
if changed == "unknown-file" {
assert_eq!(std::fs::read(data.join("unknown")).unwrap(), b"protected");
}
}
}
#[cfg(not(windows))]
#[tokio::test]
async fn native_multipart_owned_undo_reclaim_keeps_anchor_until_payload_barrier_and_retries_empty_dd() {
use crate::disk::os::prepared_publication_test_hooks as hooks;
for after_anchor in [false, true] {
let (fixture, publication, token) = Box::pin(deferred_owned_undo()).await;
let disk = &fixture.disks[0];
let path = format!("object/{}", publication.final_data_dir);
let data = disk.io_get_object_path("bucket", &path).expect("actual DD barrier path");
let anchor_path = data.join(".mpu-source-retirement");
let anchor = std::fs::read(&anchor_path).expect("actual owner anchor");
let _hook = if after_anchor {
let directory = data.clone();
Some(hooks::install_at(hooks::Stage::Remove, &anchor_path, move || {
os::fsync_dir_recorder::set_failure(&directory, std::io::ErrorKind::Other);
}))
} else {
os::fsync_dir_recorder::set_failure(&data, std::io::ErrorKind::Other);
None
};
assert!(disk.release_snapshot_lease("bucket", &path, token).await.is_err());
assert!(
os::fsync_dir_recorder::take_failure(&data).is_none(),
"the real selected DD barrier must fail"
);
assert!(!data.join("part.1").exists() && !data.join("part.1.meta").exists());
if after_anchor {
assert!(
std::fs::read_dir(&data).unwrap().next().is_none(),
"only an empty DD remains after the final anchor unlink"
);
} else {
assert_eq!(std::fs::read(&anchor_path).expect("failed payload barrier retains authority"), anchor);
}
let retry = disk
.acquire_snapshot_lease("bucket", &path)
.await
.expect("finished failed callback releases deleting");
disk.release_snapshot_lease("bucket", &path, retry)
.await
.expect("same privately owned pending operation retries");
assert!(!data.exists());
disk.delete_volume("bucket", false)
.await
.expect("no owned native payload residue blocks empty bucket deletion");
}
}
#[cfg(not(windows))]
#[tokio::test]
async fn native_multipart_owned_undo_cancelled_snapshot_release_keeps_physical_delete_claim() {
use crate::disk::os::prepared_publication_test_hooks as hooks;
let (fixture, publication, token) = Box::pin(deferred_owned_undo()).await;
let disk = fixture.disks[0].clone();
let path = format!("object/{}", publication.final_data_dir);
let data = disk.io_get_object_path("bucket", &path).expect("actual callback DD");
let (entered, receive_entered) = std::sync::mpsc::channel();
let (release, receive_release) = std::sync::mpsc::channel();
let _hook = hooks::install_at(hooks::Stage::Remove, &data.join("part.1"), move || {
entered.send(()).expect("actual unlink callback entered");
receive_release
.recv_timeout(std::time::Duration::from_secs(5))
.expect("release physical unlink");
});
let owned_disk = disk.clone();
let owned_path = path.clone();
let caller = tokio::spawn(async move { owned_disk.release_snapshot_lease("bucket", &owned_path, token).await });
tokio::task::spawn_blocking(move || receive_entered.recv_timeout(std::time::Duration::from_secs(5)))
.await
.expect("hook waiter joins")
.expect("real unlink is blocked before cancelling its waiter");
caller.abort();
assert!(caller.await.expect_err("cancelled waiter").is_cancelled());
let key = super::super::super::SnapshotLeaseKey {
volume: "bucket".to_owned(),
path: path.clone(),
};
assert!(
disk.snapshot_leases
.lock()
.entries
.get(&key)
.is_some_and(|entry| entry.deleting),
"only the actual physical callback may release the deleting claim"
);
release.send(()).expect("finish actual unlink after waiter cancellation");
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
if !data.exists() && !disk.snapshot_leases.lock().entries.contains_key(&key) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("physical callback finishes and clears its owned registry claim");
assert!(!data.exists());
assert!(matches!(disk.acquire_snapshot_lease("bucket", &path).await, Err(DiskError::FileNotFound)));
}
#[tokio::test]
async fn native_multipart_owned_undo_rpc_retries_a_failed_physical_reclaim() {
let fixture = Fixture::new().await;
let result = fixture
.publish_zero_on(&[0])
.await
.pop()
.expect("actual failed-owner publication")
.1;
let publication = result.publication.clone();
let round = Fixture::round_for(&publication);
let expected = result.proof.snapshot.clone();
let capability_wire = rmp_serde::to_vec_named(&result.undo_capability).expect("original opaque native cap");
let path = format!("object/{}", publication.final_data_dir);
let data = fixture.disks[0].io_get_object_path("bucket", &path).expect("actual final DD");
let anchor = std::fs::read(data.join(".mpu-source-retirement")).expect("real native anchor");
os::fsync_dir_recorder::set_failure(&data, std::io::ErrorKind::Other);
assert!(
fixture.disks[0]
.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
expected: Box::new(expected.clone()),
publication: publication.clone(),
capability: result.undo_capability,
round: round.clone(),
}))
.await
.is_err(),
"a real reclaim barrier failure cannot become a successful first RPC"
);
assert!(
os::fsync_dir_recorder::take_failure(&data).is_none(),
"the selected physical reclaim barrier was reached"
);
let after_failure = fixture.snapshot(0).await;
assert_eq!(after_failure.phase, MultipartSourcePhase::Recovering);
assert!(after_failure.head_sha256.is_some() && after_failure.capability_sha256.is_none());
assert!(!fixture.disks[0].root.join("bucket/object/xl.meta").exists());
assert_eq!(
std::fs::read(data.join(".mpu-source-retirement")).expect("failed payload barrier retains exact anchor"),
anchor
);
let capability = rmp_serde::from_slice(&capability_wire).expect("same opaque capability on RPC replay");
let response = fixture.disks[0]
.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
expected: Box::new(expected),
publication,
capability,
round,
}))
.await
.expect("same UndoAndClaim RPC resumes its qualified reclaim before returning Claimed");
let MultipartSourceResponse::Claimed(proof) = response else {
panic!("native owned RPC replay must return its durable claim");
};
assert_eq!(proof.snapshot, after_failure);
assert!(
!data.exists(),
"the early same-claim RPC branch must actually finish its interrupted reclaim"
);
fixture.disks[0]
.delete_volume("bucket", false)
.await
.expect("RPC replay leaves an actually empty bucket");
}
#[tokio::test]
async fn native_multipart_owned_undo_pending_proof_survives_both_generic_request_orders() {
for ordinary_first in [false, true] {
let fixture = Fixture::new().await;
let result = fixture.publish_zero_on(&[0]).await.pop().expect("actual native producer").1;
let publication = result.publication.clone();
let path = format!("object/{}", publication.final_data_dir);
let disk = &fixture.disks[0];
let data = disk.io_get_object_path("bucket", &path).expect("actual failed native DD");
let token = disk
.acquire_snapshot_lease("bucket", &path)
.await
.expect("real snapshot protects this DD");
let ordinary = || DeleteOptions {
orphan_data_dir: true,
..Default::default()
};
if ordinary_first {
assert_eq!(
disk.delete_data_dir("bucket", &path, ordinary())
.await
.expect("generic request parks behind actual snapshot"),
crate::disk::DataDirDeleteStatus::Deferred
);
}
disk.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
expected: Box::new(result.proof.snapshot),
publication: publication.clone(),
capability: result.undo_capability,
round: Fixture::round_for(&publication),
}))
.await
.expect("actual owned Undo qualifies missing-FI cleanup");
if !ordinary_first {
assert_eq!(
disk.delete_data_dir("bucket", &path, ordinary())
.await
.expect("later generic request remains deferred"),
crate::disk::DataDirDeleteStatus::Deferred
);
}
let key = super::super::super::SnapshotLeaseKey {
volume: "bucket".to_owned(),
path: path.clone(),
};
assert!(
matches!(
disk.snapshot_leases
.lock()
.entries
.get(&key)
.and_then(|entry| entry.pending_delete.as_ref()),
Some(super::super::super::SnapshotPendingDelete::OwnedMultipart(_))
),
"the exact native proof cannot be lost to generic options, ordinary_first={ordinary_first}"
);
assert!(data.join(".mpu-source-retirement").exists());
disk.release_snapshot_lease("bucket", &path, token)
.await
.expect("release replays the qualified native owner");
assert!(!data.exists(), "both request orders must reclaim the owned DD after its snapshot drains");
disk.delete_volume("bucket", false)
.await
.expect("no generic pending request strands an owned absent-target DD");
}
}
@@ -1,695 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
//! All-participant, irreversible source closure. No operation in this module undoes a target.
use super::{
LocalDisk,
multipart_source_state::{
LoadedSource, SourceHead, SourceState, SourceStore, decode, encode, load_source, raw_sha, read_optional,
read_optional_in_pinned,
},
};
use crate::disk::{
DiskError, Error, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_DELETED_BUCKET, Result, STORAGE_FORMAT_FILE,
multipart_source::*, os,
};
use rustfs_filemeta::FileMeta;
use serde::{Deserialize, Serialize};
use std::{
io::{self, ErrorKind},
path::{Path, PathBuf},
sync::Arc,
};
use uuid::Uuid;
const STATE: &str = ".mpu-source-state";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RetiredHead {
pub sha256: [u8; 32],
pub length: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct AbortCertificateRef {
pub round: MultipartAbortRound,
pub sha256: [u8; 32],
pub slot: u8,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct NativeAbortState {
pub round: MultipartAbortRound,
pub pending_from: MultipartSourceSnapshot,
pub retired_head: Option<RetiredHead>,
pub committed: Option<AbortCertificateRef>,
}
impl NativeAbortState {
pub fn validate(&self, state: &SourceState) -> Result<()> {
self.round.validate()?;
if state.phase != MultipartSourcePhase::Aborting
|| state.capability_sha256.is_some()
|| state.generation != self.round.generation
|| state.lineage != self.round.lineage
|| self.pending_from.lineage != state.lineage
|| self.pending_from.disk_id != state.disk_id
|| self.pending_from.physical_slot != state.physical_slot
|| self.pending_from.generation.epoch > state.generation.epoch
|| self.committed.as_ref().is_some_and(|reference| {
reference.round.lineage != state.lineage
|| reference.slot > 1
|| reference.round.generation.epoch > state.generation.epoch
|| (reference.round.generation.epoch == state.generation.epoch
&& reference.round.generation != state.generation)
})
{
return Err(DiskError::FileCorrupt);
}
if let Some(reference) = &self.committed {
reference.round.validate()?;
}
Ok(())
}
}
fn retired_path(source: &Path, state: &SourceState) -> PathBuf {
source
.join(".mpu-completions")
.join(state.backup_owner.to_string())
.join("aborted-head.xl.meta")
}
fn abort_certificate_path(source: &Path, owner: Uuid, slot: u8) -> PathBuf {
source
.join(".mpu-completions")
.join(owner.to_string())
.join(format!("abort-{slot}.msgpack"))
}
/// The only cold local provenance rehydration token; callers cannot construct it.
pub(in crate::disk) struct ValidatedAbortCertificate {
digest: [u8; 32],
round: MultipartAbortRound,
}
impl ValidatedAbortCertificate {
pub(in crate::disk) fn validate_certificate(&self, certificate: &MultipartAbortCertificate) -> Result<()> {
if certificate.digest()? != self.digest || certificate.round != self.round {
return Err(DiskError::FileCorrupt);
}
Ok(())
}
}
pub(super) fn validate_retired_head(
source: &Path,
base: &Path,
root: &os::PublicationRoot,
state: &SourceState,
head: Option<&SourceHead>,
) -> Result<()> {
let Some(abort) = &state.abort else {
return Ok(());
};
abort.validate(state)?;
if let Some(head) = head {
if abort.pending_from.head_sha256 != Some(head.sha256)
&& abort
.retired_head
.as_ref()
.is_none_or(|receipt| receipt.sha256 != head.sha256 || receipt.length != head.length)
{
return Err(DiskError::ConditionalDeleteConflict);
}
} else if let Some(receipt) = &abort.retired_head {
let observed = os::observe_multipart_source_metadata(&retired_path(source, state), base, root, receipt.length)?;
if observed != (receipt.length, receipt.sha256) {
return Err(DiskError::FileCorrupt);
}
}
Ok(())
}
impl SourceStore {
fn verify_abort_cohort(&self, certificate: &MultipartAbortCertificate) -> Result<()> {
certificate.verify_with_authority(&self.authority)?;
if certificate.disk_ids != self.disk_ids
|| certificate.disk_ids.get(self.physical_slot) != Some(&self.disk_id)
|| certificate.round.lineage.path != self.path
{
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(())
}
pub async fn read_abort_certificate(&self, loaded: &LoadedSource) -> Result<Option<MultipartAbortCertificate>> {
let Some(reference) = loaded
.state
.as_ref()
.and_then(|state| state.abort.as_ref())
.and_then(|abort| abort.committed.as_ref())
else {
return Ok(None);
};
let owner = loaded.state.as_ref().ok_or(DiskError::FileCorrupt)?.backup_owner;
let path = abort_certificate_path(&self.source, owner, reference.slot);
let base = self.base.clone();
let root = self.publication_root.clone();
let raw = os::run_blocking_namespace_operation(self.lease.clone(), move || {
os::read_multipart_source_metadata(&path, &base, &root)
})
.await?;
let mut certificate: MultipartAbortCertificate = decode(&raw)?;
let validated = ValidatedAbortCertificate {
digest: reference.sha256,
round: reference.round.clone(),
};
certificate.hydrate_owned(&self.authority, &validated)?;
self.verify_abort_cohort(&certificate)?;
Ok(Some(certificate))
}
async fn persist_abort_certificate(
&self,
state: &SourceState,
certificate: &MultipartAbortCertificate,
) -> Result<AbortCertificateRef> {
self.verify_abort_cohort(certificate)?;
let raw = encode(certificate)?;
let digest = raw_sha(&raw);
let slot = state
.abort
.as_ref()
.and_then(|abort| abort.committed.as_ref())
.map_or(0, |current| 1 - current.slot);
let path = abort_certificate_path(&self.source, state.backup_owner, slot);
let source = self.source.clone();
let base = self.base.clone();
let root = self.publication_root.clone();
os::run_blocking_namespace_operation(self.lease.clone(), move || {
let _source = os::pin_multipart_source_directory(&source, &base, &root)?;
let parent = path
.parent()
.ok_or_else(|| io::Error::other("abort certificate has no parent"))?;
let _parents = os::mkdir_all_below_existing_base_std(parent, &base, &root)?;
// Only the unreferenced slot may be replaced. The old committed
// slot remains recoverable until the subsequent state barrier.
os::replace_multipart_source_metadata(&path, &raw, &base, &root)?;
os::sync_multipart_owned_record_parents(&source, parent, true, &base, &root)
})
.await?;
Ok(AbortCertificateRef {
round: certificate.round.clone(),
sha256: digest,
slot,
})
}
async fn retire_abort_head(&self, state: &SourceState) -> Result<()> {
let abort = state.abort.as_ref().ok_or(DiskError::FileCorrupt)?;
let source = self.source.clone();
let base = self.base.clone();
let root = self.publication_root.clone();
let state = state.clone();
let receipt = abort.retired_head.clone();
os::run_blocking_namespace_operation(self.lease.clone(), move || {
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
let canonical = source.join(STORAGE_FORMAT_FILE);
if let Some(receipt) = receipt {
let retired = retired_path(&source, &state);
os::retire_multipart_source_head_std(&canonical, &retired, &base, &root, receipt.length, receipt.sha256)?;
os::sync_multipart_owned_record_parents(
&source,
retired
.parent()
.ok_or_else(|| io::Error::other("retired head parent missing"))?,
true,
&base,
&root,
)
} else {
match os::observe_multipart_source_metadata(&canonical, &base, &root, 0) {
Err(error) if error.kind() == ErrorKind::NotFound => os::fsync_dir_std(&source),
Err(error) => Err(error),
Ok(_) => Err(io::Error::new(ErrorKind::AlreadyExists, "closed source unexpectedly has a head")),
}
}
})
.await
.map_err(DiskError::from)
}
async fn abort_proof(
&self,
kind: MultipartAbortProofKind,
round: &MultipartAbortRound,
certificate_sha256: Option<[u8; 32]>,
publication: Option<PublicationIdentity>,
challenge: Uuid,
) -> Result<MultipartAbortProof> {
let loaded = self.load().await?;
let snapshot = loaded.snapshot.ok_or(DiskError::ConditionalDeleteConflict)?;
if loaded.head.is_some() {
return Err(DiskError::ConditionalDeleteConflict);
}
MultipartAbortProof::issue(&self.authority, kind, challenge, round.clone(), snapshot, certificate_sha256, publication)
}
async fn abort_inspection(&self) -> Result<MultipartSourceInspection> {
let loaded = self.load().await?;
let certificate = self.read_abort_certificate(&loaded).await?;
Ok(MultipartSourceInspection {
disk_id: self.disk_id,
root_missing: loaded.root_missing,
abort_round: loaded
.state
.as_ref()
.and_then(|state| state.abort.as_ref().map(|abort| abort.round.clone())),
abort_certificate: certificate,
snapshot: loaded.snapshot,
original: loaded.original,
recovery: loaded.state.as_ref().and_then(|state| state.recovery.clone()),
negative_certificate: None,
ready_certificate: None,
terminal_certificate: loaded.state.map_or_else(Vec::new, |state| state.terminal_certificate),
})
}
}
impl LocalDisk {
pub(super) async fn recover_multipart_abort_owned(
&self,
store: SourceStore,
operation: MultipartSourceOperation,
) -> Result<MultipartSourceResponse> {
match operation {
MultipartSourceOperation::UpgradeLegacy { expected } => {
let loaded = store.load().await?;
if loaded.snapshot.as_ref() != Some(&expected)
|| loaded.state.is_some()
|| expected.phase != MultipartSourcePhase::Active
|| expected.generation.epoch != 0
|| !loaded.head_matches_state
{
return Err(DiskError::ConditionalDeleteConflict);
}
let mut info = loaded.info.ok_or(DiskError::FileCorrupt)?;
if native_source_protocol(&info)? {
return Ok(MultipartSourceResponse::LegacyUpgraded(store.abort_inspection().await?));
}
if recognized_native_source_controls(&info)? {
return Err(DiskError::ConditionalDeleteConflict);
}
let source = store.source.clone();
let base = store.base.clone();
let root = store.publication_root.clone();
os::run_blocking_namespace_operation(store.lease.clone(), move || {
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
for entry in std::fs::read_dir(&source)? {
let entry = entry?;
if entry
.file_name()
.to_str()
.is_some_and(|name| name.get(..5).is_some_and(|prefix| prefix.eq_ignore_ascii_case(".mpu-")))
{
return Err(io::Error::new(ErrorKind::InvalidData, "legacy source has native owned records"));
}
}
Ok(())
})
.await?;
mark_native_source(&mut info);
let mut metadata = FileMeta::new();
metadata.add_version(info).map_err(Error::other)?;
store.replace_head(&metadata.marshal_msg().map_err(Error::other)?).await?;
Ok(MultipartSourceResponse::LegacyUpgraded(store.abort_inspection().await?))
}
MultipartSourceOperation::PrepareAbort { expected, round } => {
round.validate()?;
let loaded = store.load().await?;
if loaded.snapshot.as_ref() != Some(&expected) || expected.lineage != round.lineage {
return Err(DiskError::ConditionalDeleteConflict);
}
let mut state = store.ensure_state(&loaded).await?;
if state
.abort
.as_ref()
.and_then(|abort| abort.committed.as_ref())
.is_some_and(|committed| committed.round == round)
{
return Err(DiskError::ConditionalDeleteConflict);
}
if state.abort.as_ref().is_none_or(|abort| abort.round != round) {
if round.generation.epoch <= state.generation.epoch {
return Err(DiskError::ConditionalDeleteConflict);
}
if let Some(head) = loaded.head.as_ref() {
let owned = head.sha256 == state.original_sha256
|| Some(head.sha256) == state.prepared_sha256
|| loaded.head_matches_state;
if !owned {
return Err(DiskError::ConditionalDeleteConflict);
}
}
let previous = state.abort.take();
let retired = previous.as_ref().and_then(|abort| abort.retired_head.clone()).or_else(|| {
loaded.head.as_ref().map(|head| RetiredHead {
sha256: head.sha256,
length: head.length,
})
});
state.abort = Some(NativeAbortState {
round: round.clone(),
pending_from: expected,
retired_head: retired,
committed: previous.and_then(|abort| abort.committed),
});
state.generation = round.generation;
state.capability_sha256 = None;
state.advance(MultipartSourcePhase::Aborting)?;
store.persist(&state).await?;
}
store.retire_abort_head(&state).await?;
Ok(MultipartSourceResponse::AbortPrepared(
store
.abort_proof(MultipartAbortProofKind::Prepared, &round, None, None, round.round)
.await?,
))
}
MultipartSourceOperation::CommitAbort { expected, certificate } => {
let loaded = store.load().await?;
if loaded.snapshot.as_ref() != Some(&expected) {
return Err(DiskError::ConditionalDeleteConflict);
}
store.verify_abort_cohort(&certificate)?;
let mut state = loaded.state.ok_or(DiskError::ConditionalDeleteConflict)?;
let abort = state.abort.as_ref().ok_or(DiskError::ConditionalDeleteConflict)?;
if state.phase != MultipartSourcePhase::Aborting
|| loaded.head.is_some()
|| certificate.round.lineage != state.lineage
|| certificate.round.generation.epoch > state.generation.epoch
{
return Err(DiskError::ConditionalDeleteConflict);
}
let digest = certificate.digest()?;
if abort.committed.as_ref().is_none_or(|reference| reference.sha256 != digest) {
let reference = store.persist_abort_certificate(&state, &certificate).await?;
state.abort.as_mut().ok_or(DiskError::FileCorrupt)?.committed = Some(reference);
state.advance(MultipartSourcePhase::Aborting)?;
store.persist(&state).await?;
} else {
store
.read_abort_certificate(&store.load().await?)
.await?
.ok_or(DiskError::FileCorrupt)?;
}
Ok(MultipartSourceResponse::AbortCommitted(
store
.abort_proof(
MultipartAbortProofKind::Committed,
&certificate.round,
Some(digest),
None,
certificate.round.round,
)
.await?,
))
}
MultipartSourceOperation::DeleteAborted { expected, certificate } => {
let loaded = store.load().await?;
if loaded.snapshot.as_ref() != Some(&expected) {
return Err(DiskError::ConditionalDeleteConflict);
}
store.verify_abort_cohort(&certificate)?;
let current = store
.read_abort_certificate(&loaded)
.await?
.ok_or(DiskError::ConditionalDeleteConflict)?;
if current != certificate || loaded.head.is_some() {
return Err(DiskError::ConditionalDeleteConflict);
}
let permit = AbortDeletePermit::new(&store, &expected, certificate.clone())?;
let destination = self.io_get_object_path(RUSTFS_META_TMP_DELETED_BUCKET, &Uuid::new_v4().to_string())?;
let source = store.source.clone();
let base = store.base.clone();
let root = store.publication_root.clone();
let lease = os::acquire_namespace_mutation_lease_with_owner(&destination, Some(store.lease.clone())).await;
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
permit.validate()?;
os::rename_multipart_source_owned_std(&source, &destination, &base, &root, || permit.validate())
})
.await?;
let proof = MultipartAbortProof::issue(
&store.authority,
MultipartAbortProofKind::Deleted,
certificate.round.round,
certificate.round.clone(),
expected,
Some(certificate.digest()?),
None,
)?;
Ok(MultipartSourceResponse::AbortDeleted(proof))
}
MultipartSourceOperation::ConfirmCleanupTarget {
expected,
publication,
challenge,
} => {
let loaded = store.load().await?;
if loaded.snapshot.as_ref() != Some(&expected)
|| expected.phase != MultipartSourcePhase::Aborting
|| loaded.head.is_some()
|| expected.lineage != publication.lineage
{
return Err(DiskError::ConditionalDeleteConflict);
}
let certificate = store
.read_abort_certificate(&loaded)
.await?
.ok_or(DiskError::ConditionalDeleteConflict)?;
let target = self.get_object_path(&publication.lineage.bucket, &publication.lineage.object)?;
let _owner = os::acquire_metadata_mutation_lease(&target, Some(store.lease.clone())).await;
store.validate_cleanup_target(&publication).await?;
store
.abort_proof(
MultipartAbortProofKind::CleanupTarget,
&certificate.round,
Some(certificate.digest()?),
Some(publication),
challenge,
)
.await
.map(MultipartSourceResponse::CleanupTargetConfirmed)
}
_ => Err(DiskError::UnsupportedDisk),
}
}
}
/// This permit is neither serializable nor constructible by a generic delete RPC.
struct AbortDeletePermit {
source: PathBuf,
base: PathBuf,
root: os::PublicationRoot,
path: String,
disk_id: Uuid,
slot: usize,
disk_ids: Vec<Uuid>,
expected: MultipartSourceSnapshot,
certificate: MultipartAbortCertificate,
authority: ProofVerificationContext,
}
impl AbortDeletePermit {
fn new(store: &SourceStore, expected: &MultipartSourceSnapshot, certificate: MultipartAbortCertificate) -> Result<Self> {
store.verify_abort_cohort(&certificate)?;
Ok(Self {
source: store.source.clone(),
base: store.base.clone(),
root: store.publication_root.clone(),
path: store.path.clone(),
disk_id: store.disk_id,
slot: store.physical_slot,
disk_ids: store.disk_ids.clone(),
expected: expected.clone(),
certificate,
authority: store.authority.clone(),
})
}
fn validate(&self) -> io::Result<()> {
(|| -> Result<()> {
self.certificate.verify_with_authority(&self.authority)?;
let loaded = load_source(&self.source, &self.base, &self.root, &self.path, self.disk_id, self.slot, &self.disk_ids)?;
if loaded.snapshot.as_ref() != Some(&self.expected) || loaded.head.is_some() {
return Err(DiskError::ConditionalDeleteConflict);
}
let reference = loaded
.state
.as_ref()
.and_then(|state| state.abort.as_ref())
.and_then(|abort| abort.committed.as_ref())
.ok_or(DiskError::ConditionalDeleteConflict)?;
if reference.sha256 != self.certificate.digest()? || reference.round != self.certificate.round {
return Err(DiskError::ConditionalDeleteConflict);
}
let raw = read_optional(
&abort_certificate_path(
&self.source,
loaded.state.as_ref().ok_or(DiskError::FileCorrupt)?.backup_owner,
reference.slot,
),
&self.base,
&self.root,
)?
.ok_or(DiskError::FileCorrupt)?;
if raw_sha(&raw) != reference.sha256 {
return Err(DiskError::FileCorrupt);
}
Ok(())
})()
.map_err(io::Error::other)
}
}
/// A local ownership token can only be obtained by acquiring the upload's
/// SourceRW. Appending another owner preserves that guard in the Arc chain.
#[derive(Clone)]
pub(super) struct MultipartDeleteOwner {
owner: Option<Arc<dyn Send + Sync>>,
source_key: Option<PathBuf>,
}
impl MultipartDeleteOwner {
pub(super) fn namespace_owner(&self) -> Option<Arc<dyn Send + Sync>> {
self.owner.clone()
}
pub(super) fn chain(self, owner: Option<Arc<dyn Send + Sync>>) -> Self {
let owner = match (self.owner, owner) {
(None, owner) | (owner, None) => owner,
(Some(first), Some(second)) if Arc::ptr_eq(&first, &second) => Some(first),
(Some(first), Some(second)) => Some(Arc::new((first, second)) as Arc<dyn Send + Sync>),
};
Self {
owner,
source_key: self.source_key,
}
}
}
impl LocalDisk {
fn multipart_delete_owner_key(&self, volume: &str, path: &str) -> Result<Option<PathBuf>> {
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), volume, path)?;
Ok((volume == RUSTFS_META_MULTIPART_BUCKET).then(|| {
self.root
.join(RUSTFS_META_MULTIPART_BUCKET)
.join(super::multipart_staging::upload_scope_root(path, true).to_ascii_lowercase())
}))
}
pub(super) async fn acquire_multipart_delete_owner(
&self,
volume: &str,
path: &str,
owner: Option<Arc<dyn Send + Sync>>,
) -> Result<MultipartDeleteOwner> {
let source_key = self.multipart_delete_owner_key(volume, path)?;
let owner = self.multipart_source_owner(volume, path, true, true, owner).await?;
Ok(MultipartDeleteOwner { owner, source_key })
}
pub(super) fn validate_multipart_delete_owner(
&self,
token: &MultipartDeleteOwner,
volume: &str,
path: &str,
) -> Result<Option<Arc<dyn Send + Sync>>> {
if token.source_key != self.multipart_delete_owner_key(volume, path)? {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(token.namespace_owner())
}
}
/// No prefix-wide upload lock exists. Ordinary recursive ancestor deletion is
/// refused instead of scanning children and racing a newly closed upload.
#[derive(Clone)]
pub(super) struct OrdinaryMultipartDeleteCheck {
source: PathBuf,
base: PathBuf,
root: os::PublicationRoot,
}
impl OrdinaryMultipartDeleteCheck {
pub(super) fn validate(&self) -> Result<()> {
let pinned = match os::pin_multipart_source_directory(&self.source, &self.base, &self.root) {
Ok(pinned) => pinned,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
};
if let Some(raw) = read_optional_in_pinned(&pinned, &self.source, STATE, &self.base, &self.root)? {
let state: SourceState = decode(&raw)?;
if state.abort.is_some() || state.phase == MultipartSourcePhase::Aborting {
return Err(DiskError::ConditionalDeleteConflict);
}
} else if os::multipart_source_has_native_controls_in_pinned(&self.source, &pinned)? {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(())
}
pub(super) fn callback(&self) -> Arc<dyn Fn() -> io::Result<()> + Send + Sync> {
let check = self.clone();
Arc::new(move || check.validate().map_err(io::Error::other))
}
}
impl LocalDisk {
/// Admission is read-only but may read metadata. The queued callback keeps
/// SourceRW ownership and the job's resource unit after waiter cancellation.
pub(super) async fn validate_multipart_delete_admission(
&self,
check: OrdinaryMultipartDeleteCheck,
owner: Option<Arc<dyn Send + Sync>>,
) -> Result<()> {
self.blocking_queue
.run_cancellable(move |_| {
let _owner = owner;
Ok(check.validate())
})
.await
.map_err(DiskError::from)?
}
pub(super) fn multipart_delete_check(&self, path: &Path, recursive: bool) -> Result<Option<OrdinaryMultipartDeleteCheck>> {
let Ok(relative) = path.strip_prefix(self.io_root()) else {
return Ok(None);
};
let mut components = relative.components();
if !components
.next()
.and_then(|component| component.as_os_str().to_str())
.is_some_and(|component| component.eq_ignore_ascii_case(crate::disk::RUSTFS_META_BUCKET))
{
return Ok(None);
}
match components.next().and_then(|component| component.as_os_str().to_str()) {
Some(component) if component.eq_ignore_ascii_case("multipart") => {}
None if recursive => return Err(DiskError::ConditionalDeleteConflict),
_ => return Ok(None),
}
let relative = components.as_path();
let relative = relative.to_str().ok_or(DiskError::InvalidPath)?;
let upload = super::multipart_staging::upload_scope_root(relative, true);
let source = self.io_get_object_path(RUSTFS_META_MULTIPART_BUCKET, upload)?;
// Only a production hash/upload root owns a recursive upload scope.
// Empty SHA rmdir remains nonrecursive and unchanged.
let canonical = Path::new(upload)
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.split(['x', 'X']).next())
.and_then(|name| Uuid::parse_str(name).ok())
.is_some_and(|id| !id.is_nil());
let hashed_parent = Path::new(upload)
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.is_some_and(|name| name.len() == 64 && name.bytes().all(|byte| byte.is_ascii_hexdigit()));
if recursive && !(canonical && hashed_parent) {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(Some(OrdinaryMultipartDeleteCheck {
source,
base: self.io_root().to_path_buf(),
root: self.publication_root.clone(),
}))
}
}
#[cfg(test)]
mod lock_order_tests {
include!("multipart_source_lock_order_tests.rs");
}
@@ -1,460 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::{Fixture, put_actual_predecessor};
use crate::disk::{
DiskAPI, DiskError, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET,
multipart_source::{
MultipartAbortCertificate, MultipartAbortProofKind, MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase,
MultipartSourceResponse, PublicationIdentity, SourceGeneration, native_source_protocol, publication_identity,
recognized_native_source_controls,
},
os::{self, multipart_source_syscall_failures as failures, prepared_publication_test_hooks as hooks},
};
use futures::FutureExt as _;
use rustfs_filemeta::{FileInfoOpts, get_file_info};
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use time::OffsetDateTime;
use uuid::Uuid;
// Construct concrete native fixture futures off the caller's poll frame;
// boxing an already-built future would retain its large stack temporary.
#[inline(never)]
fn heap_native<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
Box::pin(build())
}
async fn publication_volumes(fixture: &Fixture) {
for disk in &fixture.disks {
for volume in ["bucket", RUSTFS_META_TMP_BUCKET] {
match disk.make_volume(volume).await {
Ok(()) | Err(DiskError::VolumeExists) => {}
Err(error) => panic!("prepare actual publication volume {volume}: {error}"),
}
}
}
}
async fn abort_round(fixture: &Fixture) -> MultipartAbortRound {
let snapshot = heap_native(|| fixture.snapshot(0)).await;
MultipartAbortRound {
lineage: snapshot.lineage,
generation: SourceGeneration {
epoch: snapshot.generation.epoch.checked_add(1).expect("fresh native abort epoch"),
nonce: Uuid::new_v4(),
},
round: Uuid::new_v4(),
}
}
async fn close_all(fixture: &Fixture, round: &MultipartAbortRound) -> MultipartAbortCertificate {
let authority = fixture.disks[0].multipart_proof_verification_context();
let mut proofs = Vec::with_capacity(4);
for slot in 0..4 {
let expected = heap_native(|| fixture.snapshot(slot)).await;
let response = heap_native(|| {
fixture.disks[slot].recover_multipart_source_native(fixture.request(MultipartSourceOperation::PrepareAbort {
expected,
round: round.clone(),
}))
})
.await
.expect("each actual participant durably closes its source");
let MultipartSourceResponse::AbortPrepared(proof) = response else {
panic!("actual PrepareAbort response");
};
proof
.verify_with_authority(&authority)
.expect("actual native preparation proof");
assert_eq!(proof.kind, MultipartAbortProofKind::Prepared);
assert_eq!(proof.snapshot.physical_slot, slot);
assert_eq!(proof.snapshot.disk_id, fixture.disk_ids[slot]);
assert_eq!(proof.snapshot.phase, MultipartSourcePhase::Aborting);
assert!(proof.snapshot.head_sha256.is_none());
proofs.push(proof);
}
let certificate = MultipartAbortCertificate {
round: round.clone(),
disk_ids: fixture.disk_ids.clone(),
proofs,
};
certificate
.verify_with_authority(&authority)
.expect("all-N actual native closure certificate");
for slot in 0..4 {
let expected = heap_native(|| fixture.snapshot(slot)).await;
let response = heap_native(|| {
fixture.disks[slot].recover_multipart_source_native(fixture.request(MultipartSourceOperation::CommitAbort {
expected,
certificate: certificate.clone(),
}))
})
.await
.expect("all actual roots durably bind the closure certificate");
let MultipartSourceResponse::AbortCommitted(proof) = response else {
panic!("actual CommitAbort response");
};
proof
.verify_with_authority(&authority)
.expect("actual committed closure proof");
assert_eq!(proof.kind, MultipartAbortProofKind::Committed);
assert_eq!(proof.certificate_sha256, Some(certificate.digest().expect("exact certificate digest")));
assert_eq!(heap_native(|| fixture.inspect(slot)).await.abort_certificate.as_ref(), Some(&certificate));
assert!(!fixture.head(slot).exists(), "closure never leaves a reusable canonical head");
}
certificate
}
type TargetBytes = Option<(Vec<u8>, Vec<u8>, Vec<u8>)>;
fn target_bytes(fixture: &Fixture) -> Vec<TargetBytes> {
fixture
.disks
.iter()
.enumerate()
.map(|(slot, disk)| {
let target = disk.io_get_object_path("bucket", "object").expect("actual physical target");
let raw = match std::fs::read(target.join("xl.meta")) {
Ok(raw) => raw,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
Err(error) => panic!("read actual target on slot {slot}: {error}"),
};
let info = get_file_info(
&raw,
"bucket",
"object",
"",
FileInfoOpts {
data: false,
include_free_versions: false,
include_part_checksums: true,
},
)
.expect("actual target metadata decodes");
assert_eq!(info.erasure.distribution.get(slot), Some(&info.erasure.index));
let data = target.join(info.data_dir.expect("actual native target DD").to_string());
Some((
raw,
std::fs::read(data.join("part.1")).expect("actual native selected shard"),
std::fs::read(data.join("part.1.meta")).expect("actual native selected sidecar"),
))
})
.collect()
}
fn assert_target_publications(fixture: &Fixture, expected: &PublicationIdentity, producers: &[usize]) {
let targets = target_bytes(fixture);
for (slot, target) in targets.iter().enumerate() {
if !producers.contains(&slot) {
assert!(target.is_none(), "slot {slot} never physically published the target");
continue;
}
let raw = &target.as_ref().expect("actual physical target vote").0;
let info = get_file_info(
raw,
"bucket",
"object",
"",
FileInfoOpts {
data: false,
include_free_versions: false,
include_part_checksums: true,
},
)
.expect("actual target vote decodes");
assert_eq!(publication_identity(&info).expect("actual publication tuple").as_ref(), Some(expected));
assert_eq!(info.data_dir, Some(expected.final_data_dir));
}
}
#[cfg(unix)]
#[tokio::test]
async fn native_abort_waits_for_a_real_late_third_publication_before_closing_all_sources() {
let fixture = heap_native(Fixture::new).await;
publication_volumes(&fixture).await;
let published = heap_native(|| fixture.publish_zero_on(&[0, 1])).await;
let expected_publication = published[0].1.publication.clone();
assert_target_publications(&fixture, &expected_publication, &[0, 1]);
let expected = heap_native(|| fixture.snapshot(2)).await;
let round = abort_round(&fixture).await;
let completed = fixture.zero_info(
2,
expected_publication.final_data_dir,
OffsetDateTime::from_unix_timestamp_nanos(expected_publication.modified_unix_nanos).expect("shared publication time"),
);
let target = fixture.disks[2]
.io_get_object_path("bucket", "object")
.expect("late physical target");
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let hook = hooks::install(&target.join("xl.meta"), move || {
let _ = entered_tx.send(());
let _ = release_rx.recv();
});
let disk = fixture.disks[2].clone();
let path = fixture.path.clone();
let mut publication = tokio::spawn(async move {
disk.rename_data(RUSTFS_META_MULTIPART_BUCKET, &path, completed, "bucket", "object")
.await
});
let mut release = Some(release_tx);
let mut joined = false;
let observations = std::panic::AssertUnwindSafe(async {
tokio::time::timeout(Duration::from_secs(10), entered_rx)
.await
.expect("actual third publication callback must enter")
.expect("physical callback notification");
let prepared_head = std::fs::read(fixture.head(2)).expect("the actual callback owns a prepared source head");
let mut abort = heap_native(|| {
fixture.disks[2].recover_multipart_source_native(fixture.request(MultipartSourceOperation::PrepareAbort {
expected,
round: round.clone(),
}))
});
// The native entry's first await is the SourceRW writer, before
// disk inspection or mutation; polling reaches the real held owner.
assert!(futures::poll!(abort.as_mut()).is_pending());
assert!(
tokio::time::timeout(Duration::from_millis(100), abort.as_mut())
.await
.is_err(),
"PrepareAbort must wait for the actual publication's SourceRW owner"
);
assert_eq!(std::fs::read(fixture.head(2)).expect("head while publication is blocked"), prepared_head);
assert_target_publications(&fixture, &expected_publication, &[0, 1]);
drop(release.take());
let response = tokio::time::timeout(Duration::from_secs(10), &mut publication)
.await
.expect("actual publication callback drains")
.expect("native publication task")
.expect("third actual publication");
joined = true;
let result = response.multipart_source.expect("actual native publication result");
result
.verify_with_authority(&fixture.disks[2].multipart_proof_verification_context())
.expect("actual third proof");
assert_eq!(result.publication, expected_publication);
assert!(
matches!(
tokio::time::timeout(Duration::from_secs(10), abort.as_mut())
.await
.expect("stale closure waiter drains"),
Err(DiskError::ConditionalDeleteConflict)
),
"the pre-publication snapshot cannot authorize a post-publication closure"
);
drop(abort);
let before = target_bytes(&fixture);
assert_target_publications(&fixture, &expected_publication, &[0, 1, 2]);
heap_native(|| close_all(&fixture, &round)).await;
assert_eq!(target_bytes(&fixture), before, "source closure cannot Undo the three actual target votes");
})
.catch_unwind()
.await;
drop(release.take());
drop(hook);
if !joined {
let _ = tokio::time::timeout(Duration::from_secs(10), &mut publication)
.await
.expect("release and drain actual callback");
}
if let Err(panic) = observations {
std::panic::resume_unwind(panic);
}
drop(published);
}
#[cfg(unix)]
#[tokio::test]
async fn native_abort_preserves_target_votes_when_compensation_restores_prepared_head_but_target_undo_fails() {
let fixture = heap_native(Fixture::new).await;
publication_volumes(&fixture).await;
let predecessor = heap_native(|| put_actual_predecessor(&fixture, 2)).await;
let published = heap_native(|| fixture.publish_zero_on(&[0, 1])).await;
let publication = published[0].1.publication.clone();
let completed = fixture.zero_info(
2,
publication.final_data_dir,
OffsetDateTime::from_unix_timestamp_nanos(publication.modified_unix_nanos).expect("shared publication time"),
);
let stale = completed.clone();
let original = std::fs::read(fixture.head(2)).expect("exact original before physical publication");
let target = fixture.disks[2]
.io_get_object_path("bucket", "object")
.expect("actual target for failed compensation");
let undo_failure = Arc::new(Mutex::new(None::<failures::FailureGuard>));
let undo_failure_callback = undo_failure.clone();
let sync_hook = Arc::new(Mutex::new(None::<hooks::Guard>));
let sync_hook_callback = sync_hook.clone();
let target_callback = target.clone();
let hook = hooks::install(&target.join("xl.meta"), move || {
// macOS checks Rename again after PreparedRename. Arm the Undo fault
// only at the actual parent sync, after the initial rename completes.
let sync_target = target_callback.clone();
let sync_key = if os::dst_dir_fsync_group_commit_enabled() {
target_callback
.canonicalize()
.expect("group commit uses the canonical physical directory")
} else {
target_callback.clone()
};
let reached = hooks::install_at(hooks::Stage::DirFsync, &sync_key, move || {
if os::dst_dir_fsync_group_commit_enabled() {
os::fsync_dir_recorder::set_grouped_failure(&sync_target, std::io::ErrorKind::Other);
} else {
os::fsync_dir_recorder::set_failure(&sync_target, std::io::ErrorKind::Other);
}
*undo_failure_callback.lock().expect("retain exact Undo fault") = Some(failures::fail_once(
failures::Stage::Rename,
&sync_target.join("xl.meta"),
std::io::ErrorKind::Other,
));
});
*sync_hook_callback.lock().expect("retain actual parent sync hook") = Some(reached);
});
let response = tokio::time::timeout(
Duration::from_secs(10),
fixture.disks[2].rename_data(RUSTFS_META_MULTIPART_BUCKET, &fixture.path, completed, "bucket", "object"),
)
.await
.expect("actual failed compensation is bounded");
assert!(
response.is_err(),
"failed durability and physical Undo cannot ACK the publication: {response:?}"
);
let native_error = format!("{:?}", response.as_ref().err());
drop(response);
drop(hook);
drop(sync_hook);
assert!(
undo_failure
.lock()
.expect("actual Undo fault")
.as_ref()
.expect("target callback armed Undo fault")
.was_triggered(),
"the physical target Undo must actually fail: native_error={native_error}"
);
drop(undo_failure);
let pending_sync_failure = if os::dst_dir_fsync_group_commit_enabled() {
os::fsync_dir_recorder::take_grouped_failure(&target)
} else {
os::fsync_dir_recorder::take_failure(&target)
};
assert!(
pending_sync_failure.is_none(),
"the required real target-parent sync failure must be consumed: native_error={native_error}"
);
let inspection = heap_native(|| fixture.inspect(2)).await;
let snapshot = inspection.snapshot.expect("actual prepared state after failed Undo");
assert_eq!(snapshot.phase, MultipartSourcePhase::Prepared);
assert!(
snapshot.head_sha256.is_some(),
"compensation restored the exact prepared source proxy first"
);
assert_eq!(inspection.original.as_deref(), Some(original.as_slice()));
let before = target_bytes(&fixture);
assert_ne!(before[2].as_ref().expect("failed Undo leaves actual target vote").0, predecessor);
assert_eq!(
std::fs::read(fixture.head(2)).expect("actual restored prepared head"),
before[2].as_ref().expect("the failed Undo retained its real target vote").0
);
assert_target_publications(&fixture, &publication, &[0, 1, 2]);
let round = abort_round(&fixture).await;
heap_native(|| close_all(&fixture, &round)).await;
assert_eq!(
target_bytes(&fixture),
before,
"Abort never treats the restored prepared head as a target-negative vote"
);
assert!(
fixture.disks[2]
.rename_data(RUSTFS_META_MULTIPART_BUCKET, &fixture.path, stale, "bucket", "object")
.await
.is_err(),
"a captured old publication cannot reopen the closed source"
);
assert_eq!(target_bytes(&fixture), before, "the old publication cannot change retained target votes");
drop(published);
}
#[cfg(unix)]
#[tokio::test]
async fn native_abort_waits_for_an_all_active_marked_source_stage_captured_before_reserve() {
let fixture = heap_native(Fixture::new).await;
publication_volumes(&fixture).await;
let expected = heap_native(|| fixture.snapshot(0)).await;
let round = abort_round(&fixture).await;
for slot in 0..4 {
let inspection = heap_native(|| fixture.inspect(slot)).await;
let snapshot = inspection.snapshot.expect("all-ACTIVE actual source");
assert_eq!(snapshot.phase, MultipartSourcePhase::Active);
assert!(snapshot.publication.is_none());
assert!(snapshot.capability_sha256.is_none());
let raw = std::fs::read(fixture.head(slot)).expect("actual marked initial metadata");
let info = get_file_info(
&raw,
RUSTFS_META_MULTIPART_BUCKET,
&fixture.path,
"",
FileInfoOpts {
data: false,
include_free_versions: false,
include_part_checksums: true,
},
)
.expect("actual marked initial FI");
assert!(native_source_protocol(&info).expect("strict known birth marker"));
assert!(recognized_native_source_controls(&info).expect("native classification includes the birth marker"));
assert!(
!fixture.head(slot).parent().unwrap().join(".mpu-source-state").exists(),
"all-ACTIVE native Init has no private reservation state yet"
);
}
let completed = fixture.zero_info(0, Uuid::new_v4(), OffsetDateTime::now_utc());
let original = std::fs::read(fixture.head(0)).expect("original head before capture");
let staging = heap_native(|| fixture.disks[0].prepare_multipart_commit_staging(&fixture.path, &completed, "bucket", None))
.await
.expect("real private stage captures the original SourceRW owner");
assert!(
!fixture.head(0).parent().unwrap().join(".mpu-source-state").exists(),
"capture must precede native reserve and prepared proxy creation"
);
let mut abort = heap_native(|| {
fixture.disks[0].recover_multipart_source_native(fixture.request(MultipartSourceOperation::PrepareAbort {
expected,
round: round.clone(),
}))
});
assert!(
futures::poll!(abort.as_mut()).is_pending(),
"the actual pre-reserve owner must enqueue Abort"
);
assert!(
tokio::time::timeout(Duration::from_millis(100), abort.as_mut())
.await
.is_err(),
"all-ACTIVE metadata cannot allow Abort to overtake a real pre-reserve source owner"
);
assert_eq!(std::fs::read(fixture.head(0)).expect("head protected by the old stage"), original);
assert_eq!(target_bytes(&fixture), vec![None; 4], "the captured stage has not published any target");
drop(staging);
let response = tokio::time::timeout(Duration::from_secs(10), abort.as_mut())
.await
.expect("PrepareAbort proceeds after the actual stage releases its SourceRW owner")
.expect("unchanged all-ACTIVE snapshot authorizes closure");
assert!(matches!(response, MultipartSourceResponse::AbortPrepared(_)));
drop(abort);
heap_native(|| close_all(&fixture, &round)).await;
assert!(
fixture.disks[0]
.rename_data(RUSTFS_META_MULTIPART_BUCKET, &fixture.path, completed, "bucket", "object")
.await
.is_err(),
"the pre-reserve captured condition cannot publish after certified closure"
);
assert_eq!(target_bytes(&fixture), vec![None; 4], "fenced old staging must not create any target");
}

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