mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
Compare commits
508 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f8d013b8d | |||
| ceddcdd878 | |||
| b8858d0c70 | |||
| 7728eeab1a | |||
| 4fd444852c | |||
| 13dc635d51 | |||
| 05f84793a3 | |||
| 2fa9a433ad | |||
| 08811ec243 | |||
| 0f89e9b8b8 | |||
| de8b465c41 | |||
| ead77d799f | |||
| 67db0f241f | |||
| a94f625bc8 | |||
| b7835f0f9a | |||
| 299609bb83 | |||
| 1800b90d06 | |||
| 2ccc8d8647 | |||
| 124cbfc00a | |||
| 3aa7964c58 | |||
| 42d376c6b7 | |||
| 017b85a5bf | |||
| 55023ecd86 | |||
| 03f77844b7 | |||
| 1cd62c4249 | |||
| b44c76db52 | |||
| d0096af742 | |||
| 35e07b44f4 | |||
| 7d91176cea | |||
| 001d935569 | |||
| cc157adca8 | |||
| e0917fb617 | |||
| 6a30694765 | |||
| 991d647c78 | |||
| aafa85c6e5 | |||
| 01f5aa9c3a | |||
| e27bfb2006 | |||
| f095c6a476 | |||
| a223e74c83 | |||
| 1d1964215d | |||
| 5b23462652 | |||
| 02f6d87201 | |||
| 37a54d3ff2 | |||
| d1aaaee9bf | |||
| 0f24511535 | |||
| 9517db6cf7 | |||
| 3bcba78ccb | |||
| bebd5d0631 | |||
| 0de18a3577 | |||
| af4ce32528 | |||
| 04756b0a93 | |||
| 6fc62edcf7 | |||
| 32c4593587 | |||
| 2f90fb19f5 | |||
| 68d1790b74 | |||
| 6fde372f5b | |||
| 503a852214 | |||
| ab7a8c400f | |||
| 85663e9647 | |||
| 17aa75a175 | |||
| 61e4cfbc2a | |||
| 675722e5f8 | |||
| d0f8475924 | |||
| f6dfe86c58 | |||
| 3c9b9589f2 | |||
| 7130f25af5 | |||
| 900d082764 | |||
| a5ad283c8c | |||
| dcdd53ccf3 | |||
| b23723a51f | |||
| 07b7d4fc18 | |||
| d60ef4873a | |||
| bdd7efc96d | |||
| aaeedcd1d7 | |||
| 8ebec9e71c | |||
| cced213b66 | |||
| 882217221e | |||
| f279cefa9c | |||
| 8aba025bd6 | |||
| 40837f67f8 | |||
| 042f6ba3aa | |||
| 63eac7a387 | |||
| e13336a116 | |||
| 8d5af5981d | |||
| f53c5d41d4 | |||
| 4096d05ca7 | |||
| d2c1434a02 | |||
| c8f14dc337 | |||
| 90c5050a0f | |||
| 334e8d9d3e | |||
| d8e8f6bfa4 | |||
| 4e64e86a29 | |||
| d229c79e38 | |||
| 0c8b05585e | |||
| 911ea265c5 | |||
| 8c7b3996cb | |||
| 68a5b2c89c | |||
| 7f1795f275 | |||
| 3092792c3c | |||
| e542e62f07 | |||
| d90419f4dc | |||
| bbe2f5a3d3 | |||
| b5f275ca3f | |||
| c2e6b974e1 | |||
| b39fcb2e85 | |||
| 301e3a9f34 | |||
| 6535c28f1a | |||
| 76f4342ae2 | |||
| 128634f102 | |||
| abe14e2f97 | |||
| 7d6172e666 | |||
| 3254e79efd | |||
| da7dc74147 | |||
| db55098aa6 | |||
| 47e1850d7e | |||
| 4e88ebcf30 | |||
| cfee2ffaba | |||
| 623601d2f9 | |||
| 2474adc50f | |||
| c8cb1d86d3 | |||
| 607393e6fa | |||
| daa9529fc6 | |||
| 1820a4c40b | |||
| 631cd0ceca | |||
| 5778384b68 | |||
| 44a73b0bef | |||
| 91007466c9 | |||
| 6e3484916f | |||
| e1e710cb5d | |||
| d9ab8760ee | |||
| 67442a5ba3 | |||
| df10dc7864 | |||
| 5c463ff8d3 | |||
| 1e36e18265 | |||
| 0b25604503 | |||
| 89c3282ae2 | |||
| 0d0e6d8497 | |||
| f1a0263ab1 | |||
| bea4be68a2 | |||
| 90a39a5083 | |||
| 11381c260f | |||
| 89b98d65c2 | |||
| 335612f452 | |||
| 0bade0ca3e | |||
| 5749752217 | |||
| 03c65bcba8 | |||
| 2feb09b8eb | |||
| be8357f815 | |||
| 8eb67ac38c | |||
| a47649fb60 | |||
| c0f3f9a108 | |||
| 956395a61b | |||
| 4597bb91a8 | |||
| 87caf45cfe | |||
| 15b7040e78 | |||
| ffd94cfe1b | |||
| 831029e5b6 | |||
| 1e817524e8 | |||
| 66bf2c0a75 | |||
| f06ae3dc34 | |||
| 8af4070d19 | |||
| c1de42af2c | |||
| 179e58d5ef | |||
| 5f5485e332 | |||
| 65f0599c70 | |||
| d5e037faac | |||
| 97e600ae69 | |||
| 84e96512bd | |||
| 62aafc6b95 | |||
| b06923beaf | |||
| 023f2e7258 | |||
| 9fad6104c4 | |||
| d2eda195e6 | |||
| 311f27baa1 | |||
| 1ac35917c2 | |||
| 73ff9a87e8 | |||
| 102cc18767 | |||
| 77a36d92a3 | |||
| 444eb5ddba | |||
| bd7b7321a2 | |||
| d122e8e6bc | |||
| 30c1dffe48 | |||
| 2eeaca4cd0 | |||
| 001e92226e | |||
| 65a721d896 | |||
| f1850f54d4 | |||
| 2c55d9c8f0 | |||
| 5efa6c2173 | |||
| 2110348e58 | |||
| 2253ba9009 | |||
| 72d5c5bf2f | |||
| 13c37bf8b9 | |||
| 38595c1909 | |||
| 9b41835580 | |||
| a5ae294f42 | |||
| 9fbf5d0553 | |||
| 4dba635d48 | |||
| 7cc4a9dbd0 | |||
| 928a369445 | |||
| e7bebb0fc5 | |||
| cacdaa65a0 | |||
| 5313b72baa | |||
| f98d268f84 | |||
| f4f1743d04 | |||
| 8acfde3842 | |||
| f320f791a4 | |||
| 4d2e362e23 | |||
| a55f2f671b | |||
| e7e53f07b2 | |||
| da7fde0e62 | |||
| 6127deaa83 | |||
| ea596b2e2d | |||
| 10a9b158d7 | |||
| efe2255ccf | |||
| 6f582b0548 | |||
| f063a8e862 | |||
| 83fb90cb6e | |||
| 473cddd7dc | |||
| 9a1f3d2c4c | |||
| 7c75f319bc | |||
| c48ff0bbbd | |||
| f4b694ed19 | |||
| 05b483b31a | |||
| f3cef98afc | |||
| 2b57fa8758 | |||
| 20d9277ec3 | |||
| 2534b6175b | |||
| 54e39440d2 | |||
| aa95a32ea2 | |||
| daa3679160 | |||
| 3f3e892d86 | |||
| 874d2babcc | |||
| 90749fbb06 | |||
| 7c85783a08 | |||
| e6f76cb64f | |||
| 1ec620ac9c | |||
| 541fbbe74a | |||
| 08ba7a7976 | |||
| 81f214c99a | |||
| b3bb9d3596 | |||
| d156640451 | |||
| 441b501189 | |||
| c72ed5e909 | |||
| 392ccadb78 | |||
| dff05785de | |||
| cf2e3af4d9 | |||
| f7b2836d7d | |||
| 4d7cd23f8e | |||
| 0de680f4a1 | |||
| ff70c06cef | |||
| fca83c72e4 | |||
| 22740876b3 | |||
| e0f0628bc1 | |||
| ab7001a714 | |||
| b2770312f2 | |||
| e0e18908d5 | |||
| 34e883e16a | |||
| 3a5742c5fd | |||
| 6de3034e36 | |||
| e2583ddcd6 | |||
| 317191ce00 | |||
| ae259e127d | |||
| 8233e3fe4c | |||
| f3c4a790dd | |||
| f88b7b6ba5 | |||
| 0307c9a7e2 | |||
| 6a8d0aba58 | |||
| ee6c2afe43 | |||
| 9ca8bc3ca6 | |||
| 3678703a98 | |||
| fe798c13e7 | |||
| 24b77349aa | |||
| 8c55396bfe | |||
| 5be8badb20 | |||
| 46daaadbb3 | |||
| f3b6c4df6b | |||
| 7183849227 | |||
| 6681dd12e3 | |||
| 3b0ee83466 | |||
| 02c4f4c97f | |||
| c28e02a722 | |||
| 1df96624df | |||
| ee534fa19b | |||
| 668bc7a4ba | |||
| 942ce79c4e | |||
| fec1262f1a | |||
| 5eb46cb29d | |||
| 2cd904d0e7 | |||
| 61eec6da18 | |||
| 5b8cd2a5dc | |||
| d7824049b2 | |||
| e29a76f391 | |||
| d46e4c54a6 | |||
| 3ca0b5f35e | |||
| 00b5ddc0ad | |||
| 3ddd307304 | |||
| 9946c67b53 | |||
| 286e4cf30b | |||
| 453be3dd84 | |||
| 222beaba00 | |||
| aeae6f9980 | |||
| f64cb816db | |||
| 816e18f2c8 | |||
| ad4c2bc9b1 | |||
| ef5fe69d52 | |||
| ba6f37e58d | |||
| 31eb2e5a4a | |||
| e98992bb87 | |||
| ce0c532740 | |||
| 3defc19d5e | |||
| b1f3ebb184 | |||
| b40f1e5fab | |||
| 2f3654793d | |||
| 276cb24e26 | |||
| ce09e7817c | |||
| 31dc8a0fea | |||
| d243a4a29e | |||
| 1048374cbd | |||
| de32dece41 | |||
| 508caab281 | |||
| f79f318ec4 | |||
| 776cdc517b | |||
| 60fe68a04a | |||
| 498be1fa53 | |||
| b798d291d7 | |||
| 57c4d4c719 | |||
| 560b083278 | |||
| 8dd399cd58 | |||
| 353e67bbf3 | |||
| 78ea655d87 | |||
| 5efae5baa6 | |||
| 36afe8bb57 | |||
| 57963574b7 | |||
| c56a597374 | |||
| 5354e6dccf | |||
| 7608c5af11 | |||
| c926e10de0 | |||
| 972dafccde | |||
| 5116bb2bbb | |||
| 08d6e4343d | |||
| 7d3a6acc57 | |||
| aabb26b519 | |||
| 8110ab2893 | |||
| 163e6064da | |||
| c7a9b76543 | |||
| 7cc4ce5447 | |||
| 59ea8bf8ce | |||
| 44fe49e5fa | |||
| 749a1686aa | |||
| 6db3158740 | |||
| c07a5d292c | |||
| dde2c50309 | |||
| d5a8f022bd | |||
| 150d349ade | |||
| 38056f3783 | |||
| 0b40e35e76 | |||
| 458b144ac7 | |||
| d58b80d54e | |||
| f8d0174fa7 | |||
| a57cc0d44c | |||
| 8006cb7eae | |||
| 8d40a995a3 | |||
| a451b53bcc | |||
| 00d1f6ead2 | |||
| adfe60ec7d | |||
| a5c6f6195e | |||
| ec0208480e | |||
| 62b6b6149c | |||
| 741de6f81c | |||
| f5ff8783f4 | |||
| 12ae143bee | |||
| 628663f468 | |||
| d457378c02 | |||
| e76cb67e93 | |||
| 8c8c74cbc3 | |||
| a6127c8fde | |||
| f346c13e5b | |||
| 2634e829ca | |||
| dc15444256 | |||
| ebb19907a9 | |||
| d29babf251 | |||
| d8a9522919 | |||
| 60dc84bf0e | |||
| 061e89ed2b | |||
| 3065142735 | |||
| c6a8e30f5a | |||
| 9d85d21ee6 | |||
| f51b52d7e9 | |||
| c54160c22c | |||
| 1f67b65ab8 | |||
| a7806e65f0 | |||
| 6a3678997a | |||
| 64ea8b8e09 | |||
| 7773aac0fd | |||
| 8763f87ca7 | |||
| 9bbac59977 | |||
| 3bd57517ff | |||
| 8ee78c6891 | |||
| 21c594e625 | |||
| 748095280a | |||
| 960195458f | |||
| de58fdfc7d | |||
| 869d5ec24b | |||
| 19902438a6 | |||
| 4e9b3818eb | |||
| f0b584d61a | |||
| d0fec44808 | |||
| b4f58b81cf | |||
| 7682a12b58 | |||
| 65f30bd5c1 | |||
| 7462c25319 | |||
| 1c663b8207 | |||
| cb2dfec17e | |||
| c1f906d128 | |||
| b08331335a | |||
| aae5cc4919 | |||
| 69e987b4de | |||
| 7a32ca2adb | |||
| d7156290d5 | |||
| 6ec1942ba3 | |||
| 606d8a3a8a | |||
| bd244d13f5 | |||
| 2c2c1133d4 | |||
| 8a96fedfdd | |||
| ce00d8f7e5 | |||
| 38395d35ec | |||
| 68064b5ad9 | |||
| c1da0c361b | |||
| cb7626c1f1 | |||
| 8186a7175c | |||
| cbd53974f9 | |||
| c9e1446126 | |||
| 65ea7db6a9 | |||
| 11d21ec6f4 | |||
| 974ab6f785 | |||
| bdc00c8d0d | |||
| 84f7ab1f5c | |||
| 8b75145a29 | |||
| c9f88cff66 | |||
| 9408b90f13 | |||
| 08ac1ec218 | |||
| 388d1f17e6 | |||
| 22c093e66b | |||
| 24ab34a5ee | |||
| 9068d83400 | |||
| 20fc897036 | |||
| a86117590c | |||
| ceb53fef85 | |||
| c3f4cdf27c | |||
| e82d7c107a | |||
| 9d2d6a8e46 | |||
| 4e0ff037ec | |||
| ff2cbc3c47 | |||
| d65ef3e29a | |||
| fa0ac13e9b | |||
| c7848df9a0 | |||
| 4502a1f9b0 | |||
| 93d51d172f | |||
| b80590f1a7 | |||
| 025ed4c6d1 | |||
| 3b428bffef | |||
| da52303f2a | |||
| a79c6072f1 | |||
| ab512768d5 | |||
| adf17bbfba | |||
| fb5c002a30 | |||
| 8a94c64934 | |||
| 9ca0e9b717 | |||
| bd83bd3f6b | |||
| 34803e8165 | |||
| 9af2f7c04e | |||
| 9d6f48c325 | |||
| e7e6556f83 | |||
| 0a6aeda191 | |||
| 0e0f656ae1 | |||
| a57a3ae51d | |||
| 5d8e143505 | |||
| d6fe5c30ce | |||
| ac6cf8a50d | |||
| a7188a16b4 | |||
| 0195732a1f | |||
| 0ef133bbb0 | |||
| 5228463a1c | |||
| dd90bb7488 | |||
| 0018bbf5d7 | |||
| 13ee76a4a9 | |||
| 3dc09c9397 | |||
| 71c0efa97d | |||
| 4166b9503f | |||
| 201b5f62d2 | |||
| 902c97ce05 | |||
| 7ad4fa439e | |||
| 1266d4c270 | |||
| acd26afd8b | |||
| 5924a55931 | |||
| 2d2679a710 | |||
| 257c9f11ae | |||
| 1324e296f8 | |||
| 38263ba896 | |||
| ba2047b184 | |||
| da42266bfb | |||
| 367f13f3f9 | |||
| 0263d5fcb9 | |||
| 93a64fdca3 | |||
| 9ecb00d388 | |||
| b8889fccc9 | |||
| a424d10fa0 | |||
| 072b57b375 |
@@ -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 | [Maintain milestones, installation references, and the website](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
|
||||
|
||||
@@ -39,4 +39,4 @@ Only a new affirmative user reply to that exact report authorizes Phase 6. The o
|
||||
- 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 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; 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.
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
# Post-release Milestone, Installation, and Website Updates
|
||||
# Post-release Security Advisory, Milestone, Installation, and Website Updates
|
||||
|
||||
Read only after the final non-preview tag has passed Phase 6. Tag creation or a green binary build alone is insufficient: the release assets, source archive, container images, Helm package, and applicable latest channels must be available. Preview tags never enter this phase.
|
||||
|
||||
## Security Advisories
|
||||
|
||||
Reconcile advisories after every completed final release, including Console-only releases. Read-only preparation may start earlier; do not claim an unpublished artifact is an available fix.
|
||||
|
||||
1. Fetch every page of the repository's live Security Advisories, including triage, draft, published, and closed states. A permission error or partial response blocks reconciliation; it does not mean there are no advisories. Keep unpublished advisory details in private task artifacts, never in a public PR, release note, or issue.
|
||||
2. For each potentially relevant advisory, map its fix to the exact published tag and verify the regression evidence. Main-branch reachability, a merged private fix, or a green build alone does not prove the shipped version is fixed. Check cherry-picked equivalents and reverted fixes explicitly. For embedded Console or dependency fixes, verify the shipped component version.
|
||||
3. Determine the first available fixed release and affected version ranges from evidence. Preserve an earlier correct patched version; do not relabel all fixes as the current release. An unchanged server baseline such as a Console-only release requires checking the earlier server release as well. Leave unresolved reports and fixes absent from the tag pending; do not close them merely because a release shipped.
|
||||
4. Prepare any necessary corrections to package identity, affected ranges, patched versions, fix/release links, and advisory text. Preserve unrelated fields, credit, CVE identifiers, and disclosure coordination. Apply updates only within existing user authorization; read back the result to verify it. Publishing a draft, requesting a CVE, or closing a report is a separate action: require explicit authorization for the concrete advisories and respect any embargo. Release-tag approval alone does not authorize those actions. Prepare the exact proposed changes before asking for missing authorization.
|
||||
5. Record each relevant advisory's URL, current state, fix evidence, first fixed version, action taken or proposed, and remaining blocker in private task artifacts. Report advisory follow-up separately from artifact publication. A successful release with unresolved advisory work is not complete follow-up; retain that work for the release monitor or a concrete user decision.
|
||||
|
||||
## Version milestones
|
||||
|
||||
1. Query all pages of both open and closed milestones in the RustFS release repository, matching titles exactly to version strings without a `v` prefix. A failed or incomplete query is not evidence that a milestone is absent; resolve the lookup before making changes.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "RustFS Release Bump"
|
||||
short_description: "Prepare RustFS release branches like PR #2957."
|
||||
default_prompt: "Use $rustfs-release-version-bump to prepare and verify an exact RustFS release-version bump."
|
||||
@@ -36,6 +36,21 @@ changed lesson.
|
||||
|
||||
## Finding Standard
|
||||
|
||||
Each finding includes severity, `file:line`, attacker prerequisites, concrete
|
||||
input/path, impact, smallest safe fix, and a regression check. Do not exaggerate
|
||||
unauthenticated impact when the actual issue requires authenticated low privilege.
|
||||
Every candidate names the lower-trust principal, the boundary it crosses, the
|
||||
affected resource, and the observable result. A best-practice gap or an impact
|
||||
confined to the caller's own principal is hardening, not a candidate.
|
||||
|
||||
Report each candidate under exactly one verdict:
|
||||
|
||||
- `confirmed`: a complete `file:line` source trace and a bounded observed
|
||||
result. Only confirmed findings carry severity; each also states attacker
|
||||
prerequisites, concrete input/path, impact, smallest safe fix, and a
|
||||
regression check.
|
||||
- `needs_validation`: the trace is blocked on one exact unresolved fact, such
|
||||
as deployment or infrastructure behavior. State that fact; assign no severity
|
||||
and do not substitute an assumption.
|
||||
- `rejected`: a disproved candidate, with the caller, invariant, or test that
|
||||
disproves it.
|
||||
|
||||
Do not exaggerate unauthenticated impact when the actual issue requires
|
||||
authenticated low privilege.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Security Advisory Lessons"
|
||||
short_description: "Apply advisory lessons in reviews."
|
||||
default_prompt: "Use $security-advisory-lessons for a dedicated RustFS security review grounded in past advisories."
|
||||
@@ -45,11 +45,13 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
- `GHSA-6r96-hmgc-726c`: request headers collided with lowercase server-derived condition keys such as `userid`, `groups`, `versionid`, and JWT/LDAP claims. Lesson: never let caller-controlled headers append to or replace server-derived policy context; reserve trusted condition keys and keep intentional request-header keys separate.
|
||||
- `GHSA-v9cp-qfw9-9pfp`: quantified negated string conditions applied negation after aggregation, transposing `ForAllValues` and `ForAnyValue` semantics. Lesson: push negation into the per-value predicate for quantified operators and test partially overlapping multi-value sets.
|
||||
- `GHSA-5w8r-p896-6vq2`: OPA policy mode skipped `ExistingObjectTag/*` loading, so tagged objects looked untagged to external policies. Lesson: external authorization plugins need the same object-tag and request context as built-in policy evaluation before they decide.
|
||||
- `GHSA-9wgh-5r68-49p7`: substituted policy-variable values could recreate `${...}` and loop inside a single resolution pass, never reaching the outer iteration limit. Lesson: bound substitutions within each pass and test cycles formed across otherwise literal resolver values; confirm whether attacker-controlled claims can reach those values before assigning remote impact.
|
||||
|
||||
### S3 object actions, copy, multipart, and upload policy validation
|
||||
|
||||
- `GHSA-g8w9-qw9q-fghr` and `GHSA-xm99-m3gq-83g8`: a valid presigned `PutObject` accepted extra `x-amz-*` headers omitted from `SignedHeaders`; `x-amz-copy-source` could turn that upload capability into a cross-bucket read performed as the signer. Lesson: a presigned URL is a bounded capability; inspect all received security-sensitive headers and reject unsigned ones before selecting the S3 operation or reaching storage.
|
||||
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
|
||||
- `GHSA-cg33-pxfp-qcj3` and `GHSA-65wq-gjw8-xr9w`: `HeadObject` and object-tagging paths authorized current-object actions while operating on an explicit historical `versionId`. Lesson: select the corresponding version action for metadata and tag reads and writes too; audit every operation that accepts a version ID.
|
||||
- `GHSA-gjph-xcgw-4p7w`: `PutObject` and browser POST accepted a caller-supplied `versionId` and replaced an unlocked historical version with only `s3:PutObject`. Lesson: only replication-authorized writes may choose an existing version ID; ordinary uploads must not rewrite version history.
|
||||
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
|
||||
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
|
||||
@@ -110,6 +112,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
|
||||
- `GHSA-j548-9grx-fh4f`: Object Lock enforcement treated unreadable, fabricated, or unparsable bucket metadata as absent configuration and allowed retained objects to be deleted or expired. Lesson: retention must fail closed unless Object Lock absence is authoritative, and every delete, lifecycle, scanner, force-delete, and default-retention path needs the same state distinction.
|
||||
- `GHSA-28w4-rp2p-636w` and `GHSA-c7hx-vr97-45rg`: an authorized `DeleteBucket` with `x-rustfs-force-delete: true` removed active COMPLIANCE-retained versions even though ordinary version deletion was denied. Lesson: bucket force-delete must enforce retention across every version, including against root/owner callers.
|
||||
- `GHSA-q23w-fp7p-472v`: `PutObjectRetention` policy conditions read `x-amz-object-lock-*` headers while the handler applied mode and date from the XML body. Lesson: authorize the parsed retention that will be stored, or reject mismatched header/body values; compare retention dates as instants so offsets cannot change the applied bound.
|
||||
|
||||
### Serde deserialization and input validation
|
||||
|
||||
@@ -143,13 +146,13 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
|
||||
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
|
||||
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
||||
- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, storage-class, and cross-bucket copy-source headers; require rejection before source or destination storage access, and verify explicitly signed equivalents still work.
|
||||
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, and multipart-copy source cases.
|
||||
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, multipart-copy source, `HeadObject`, and tag read/write cases.
|
||||
- Version-write fixes: a `PutObject`-only principal must not replace an existing version through `PutObject` or browser POST `?versionId=`; verify replication-authorized writes and versioned, suspended, and unversioned buckets separately.
|
||||
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, plugin mode, and built-in policy mode.
|
||||
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, cyclic variable substitutions, plugin mode, and built-in policy mode.
|
||||
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks; test per-key `DeleteObjects` errors and prefix-scoped denials on versioned buckets.
|
||||
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
|
||||
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
|
||||
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
|
||||
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
|
||||
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together; for ETags, upload equal plaintext under different keys and assert list-only callers cannot derive or correlate the plaintext digest, including multipart objects.
|
||||
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths; an authorized force-delete must preserve active COMPLIANCE versions.
|
||||
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths; an authorized force-delete must preserve active COMPLIANCE versions. For `PutObjectRetention`, test conflicting header/body values and offset dates against policy bounds and read-back retention.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Bound individual tool outputs retained in context; retrieve relevant ranges
|
||||
# from task-owned log files when more evidence is needed.
|
||||
# https://learn.chatgpt.com/docs/config-file/config-reference
|
||||
|
||||
tool_output_token_limit = 4000
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-linux=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
|
||||
sha256-darwin=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
|
||||
sha256-linux=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
|
||||
sha256-darwin=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=98dff0961c6eb21d6128bfd2d4a6100a624ddf1fb7db05dba360a5d3d0a310c2
|
||||
sha256-linux=890f5925b87e1bf3710c356253f3b2276d901e156df9ccc04ec9315374de6d22
|
||||
sha256-darwin=b53fe6d46cdffedfad6a83da0dfffa9aec10cd7c8ace53469f129587bc9f4d50
|
||||
sha256-linux=6be90f5c3886e9852ac27d51f2d47e6d2fa8bb424e0d03ee931268dcd0020db8
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=efe68675f4676a0d2901242c5815b1f7af5cdec8231f7cccd408f6dacf5c75db
|
||||
sha256-linux=003ed6480935a0a95680cc061be143722fbd37bd477b8ff98607b1a064f3aef1
|
||||
sha256-darwin=347a0d4e5e547d5388703062ce66a66167a845b6e57ec591ccb4c732a567c68d
|
||||
sha256-linux=4f9efa47194279185bb68249e82c788fa147abe944b6c764050e3d47f31e7926
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=ba8f392b617cf9f2338142233a417e43927b28096635e628233ce8a60cf49d10
|
||||
sha256=002ec5d973f82288e50a096a2c8b3b38749f62a7a3e169775566874d8ee98b4a
|
||||
|
||||
@@ -52,6 +52,9 @@ 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_release_build_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_migration_gate_evidence.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||
|
||||
+64
-2
@@ -76,18 +76,50 @@ threads-required = "num-test-threads"
|
||||
filter = 'package(rustfs-ecstore) & (test(/^set_disk::tests::streaming_get_blocks_concurrent_/) | test(=store::object::tests::retired_marker_converges_after_bucket_recreation_on_sixteen_disks))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# These real-disk lock probes must finish durable IO within production's 5s
|
||||
# acquisition budget. Reserve capacity from sibling test processes while
|
||||
# preserving the probes' internal concurrency, deadlines, and assertions.
|
||||
[[profile.default.overrides]]
|
||||
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# These eight-disk decommission fixtures exercise concurrent retries,
|
||||
# free-version cleanup, and real capacity probes under production lock deadlines.
|
||||
# Reserve the run's capacity so unrelated IO cannot exhaust those budgets.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(/^store::init::tests::decommission_entry_(retries_source_changed_without_canceling_other_bucket|skips_cleanup_only_marker_when_free_version_is_present)$/) | test(=core::pools::tests::decommission_capacity_mutation_limits_prewrite_sampling_to_target))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# This registration/backup lock probe uses an isolated eight-disk store.
|
||||
# Reserve capacity so sibling disk-heavy tests cannot consume its 2s
|
||||
# registration budget; retain the queued backup and deadlock assertions.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs) & test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits)'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# Native scanner backlog fixtures include 24-set and pool-expansion cases.
|
||||
# Reserve capacity across test processes while preserving each scenario's fixed
|
||||
# budget, assertions, and internal concurrency.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
|
||||
# The admin target-repair fixtures compose the full bucket delete and recreate
|
||||
# path on a real store; measured with RUST_MIN_STACK on 2026-10-15 they need
|
||||
# 2016-2040 KiB of the 2 MiB libtest default and overflowed on Linux CI.
|
||||
[[profile.default.scripts]]
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
@@ -327,18 +359,47 @@ threads-required = "num-test-threads"
|
||||
filter = 'package(rustfs-ecstore) & (test(/^set_disk::tests::streaming_get_blocks_concurrent_/) | test(=store::object::tests::retired_marker_converges_after_bucket_recreation_on_sixteen_disks))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# These real-disk lock probes must finish durable IO within production's 5s
|
||||
# acquisition budget. Reserve capacity from sibling test processes while
|
||||
# preserving the probes' internal concurrency, deadlines, and assertions.
|
||||
[[profile.ci.overrides]]
|
||||
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# These eight-disk decommission fixtures exercise concurrent retries,
|
||||
# free-version cleanup, and real capacity probes under production lock deadlines.
|
||||
# Reserve the run's capacity so unrelated IO cannot exhaust those budgets.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(/^store::init::tests::decommission_entry_(retries_source_changed_without_canceling_other_bucket|skips_cleanup_only_marker_when_free_version_is_present)$/) | test(=core::pools::tests::decommission_capacity_mutation_limits_prewrite_sampling_to_target))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# This registration/backup lock probe uses an isolated eight-disk store.
|
||||
# Reserve capacity so sibling disk-heavy tests cannot consume its 2s
|
||||
# registration budget; retain the queued backup and deadlock assertions.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits)'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# Native scanner backlog fixtures include 24-set and pool-expansion cases.
|
||||
# Reserve capacity across test processes while preserving each scenario's fixed
|
||||
# budget, assertions, and internal concurrency.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
|
||||
[[profile.ci.scripts]]
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
@@ -595,6 +656,7 @@ default-filter = """
|
||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
|
||||
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
|
||||
| test(/^reliant::lifecycle::/)
|
||||
| test(/^conditional_delete_objects_test::/)
|
||||
| test(/^reliant::tiering::/)
|
||||
| test(/^upgrade_compatibility_test::upgrade_write_readiness_tests::/)
|
||||
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
|
||||
|
||||
@@ -1748,7 +1748,7 @@
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_objects_count{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"expr": "max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id=\"$storage_cluster\", job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"legendFormat": "{{bucket}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
@@ -1848,7 +1848,7 @@
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_total_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"expr": "max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id=\"$storage_cluster\", job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"legendFormat": "{{bucket}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
|
||||
@@ -61,6 +61,7 @@ scrape_configs:
|
||||
otlp:
|
||||
promote_resource_attributes:
|
||||
- service.instance.id
|
||||
- process.pid
|
||||
- service.name
|
||||
- service.namespace
|
||||
- cloud.availability_zone
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
evaluation_interval: 1m
|
||||
tests:
|
||||
- name: bucket usage keeps clusters separate and deduplicates reporting nodes
|
||||
interval: 1m
|
||||
input_series:
|
||||
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a1"}
|
||||
values: '4096 1024'
|
||||
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a2"}
|
||||
values: '4096 1024'
|
||||
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="b",job="rustfs",bucket="shared",service_instance_id="b1"}
|
||||
values: '65536 65536'
|
||||
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job="rustfs",bucket="empty",service_instance_id="a1"}
|
||||
values: '0 0'
|
||||
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a1"}
|
||||
values: '3 1'
|
||||
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a2"}
|
||||
values: '3 1'
|
||||
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="b",job="rustfs",bucket="shared",service_instance_id="b1"}
|
||||
values: '70 70'
|
||||
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job="rustfs",bucket="empty",service_instance_id="a1"}
|
||||
values: '0 0'
|
||||
promql_expr_test:
|
||||
- expr: max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job=~"rustfs",bucket=~".*"})
|
||||
eval_time: 1m
|
||||
exp_samples:
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="shared"}'
|
||||
value: 1024
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="empty"}'
|
||||
value: 0
|
||||
- expr: max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job=~"rustfs",bucket=~".*"})
|
||||
eval_time: 1m
|
||||
exp_samples:
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="shared"}'
|
||||
value: 1
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="empty"}'
|
||||
value: 0
|
||||
- expr: max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_total_bytes)
|
||||
eval_time: 1m
|
||||
exp_samples:
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="shared"}'
|
||||
value: 1024
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="empty"}'
|
||||
value: 0
|
||||
- labels: '{rustfs_cluster_id="b",job="rustfs",bucket="shared"}'
|
||||
value: 65536
|
||||
- expr: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",bucket="unknown"}
|
||||
eval_time: 1m
|
||||
exp_samples: []
|
||||
@@ -23,6 +23,11 @@ duplicate its rules here.
|
||||
pass by fixing the cause, not by weakening the gate).
|
||||
- If `shellcheck` is installed, actionlint also lints `run:` scripts; treat
|
||||
those findings as part of the gate.
|
||||
- Runner placement and billing rules live in `.github/workflows/README.md`.
|
||||
A new job defaults to `ubuntu-latest` (free for this public repository);
|
||||
a self-hosted `runs-on` needs one of the reasons listed there, stated in a
|
||||
comment next to it, and a `rust-cache` key is never shared between the
|
||||
hosted and self-hosted images.
|
||||
|
||||
## CI Alignment
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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.
|
||||
|
||||
name: Set up Docker Compose
|
||||
description: Install the verified Compose CLI used by native Linux x86_64 Connect acceptance
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install verified Docker Compose CLI plugin
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ $(uname -s) == Linux && $(uname -m) == x86_64 ]]
|
||||
plugin_dir="${DOCKER_CONFIG:-$HOME/.docker}/cli-plugins"
|
||||
mkdir -p "$plugin_dir"
|
||||
compose_download=$(mktemp "${RUNNER_TEMP:?}/connect-compose.XXXXXX")
|
||||
compose_staged=$(mktemp "$plugin_dir/.docker-compose.XXXXXX")
|
||||
trap 'rm -f "$compose_download" "$compose_staged"' EXIT
|
||||
curl --fail --location --retry 3 \
|
||||
https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 \
|
||||
--output "$compose_download"
|
||||
printf '%s %s\n' \
|
||||
dba9d98e1ba5bfe11d88c99b9bd32fc4a0624a30fafe68eea34d61a3e42fd372 \
|
||||
"$compose_download" | sha256sum --check --strict
|
||||
install -m 0755 "$compose_download" "$compose_staged"
|
||||
mv -f "$compose_staged" "$plugin_dir/docker-compose"
|
||||
[[ $(docker compose version --short) == 2.40.3 ]]
|
||||
@@ -36,6 +36,16 @@ inputs:
|
||||
description: "Install cross-compilation tools"
|
||||
required: false
|
||||
default: "false"
|
||||
zig-version:
|
||||
description: >-
|
||||
Zig version for cargo-zigbuild builds. Pinned so the `<target>.<glibc>`
|
||||
baseline semantics cannot drift with an untested zig release.
|
||||
required: false
|
||||
default: "0.17.0"
|
||||
cargo-zigbuild-version:
|
||||
description: "cargo-zigbuild version installed for cross builds."
|
||||
required: false
|
||||
default: "0.23.4"
|
||||
target:
|
||||
description: "Target architecture to add"
|
||||
required: false
|
||||
@@ -99,10 +109,14 @@ runs:
|
||||
- name: Install Zig
|
||||
if: inputs.install-cross-tools == 'true'
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
|
||||
with:
|
||||
version: ${{ inputs.zig-version }}
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: inputs.install-cross-tools == 'true'
|
||||
uses: taiki-e/install-action@a21ae4029b089b9ddc45704028756f51ab8abe48 # cargo-zigbuild
|
||||
with:
|
||||
tool: cargo-zigbuild@${{ inputs.cargo-zigbuild-version }}
|
||||
|
||||
- name: Install cargo-nextest
|
||||
if: inputs.install-test-tools == 'true'
|
||||
@@ -126,5 +140,5 @@ runs:
|
||||
# normalises, so cargo fingerprints stay valid.
|
||||
cache-all-crates: false
|
||||
cache-on-failure: true
|
||||
shared-key: ${{ inputs.cache-shared-key }}
|
||||
shared-key: ${{ inputs.cache-shared-key }}${{ inputs.install-cross-tools == 'true' && format('-zig-{0}-zigbuild-{1}', inputs.zig-version, inputs.cargo-zigbuild-version) || '' }}
|
||||
save-if: ${{ inputs.cache-save-if }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{ "workflow": ".github/workflows/audit.yml", "max_age_hours": 36 },
|
||||
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 36 },
|
||||
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Workflow runners: what is billed and where a job belongs
|
||||
|
||||
**Use this when:** adding a job, changing a `runs-on`, moving a lane between runner pools, or deciding whether a new check is worth its runner cost.
|
||||
**Source of truth:** `runs-on` in each workflow file for current placement; `.github/actionlint.yaml` for the declared self-hosted labels; the organisation's Settings → Billing → Usage page for what is actually charged; `docs/testing/ci-gates.md` for which checks block a merge (that does not change when a runner changes).
|
||||
|
||||
## Billing facts
|
||||
|
||||
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 by the hour whether or not a job is running well:
|
||||
|
||||
| Label | Paid by | What it is | Use it for |
|
||||
|---|---|---|---|
|
||||
| `ubuntu-latest` | nobody | GitHub-hosted, Ubuntu 24.04, 4 vCPU, 16 GB RAM, about 14 GB of free disk. Ships a C toolchain, `docker`, `gh`, `curl`, `wget`, `jq`, Python. Can `mount` tmpfs and loop devices; io_uring works | the default for every job |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
The self-hosted pods and the hosted image are both Ubuntu 24.04, so a binary built on one runs on the other; `ci.yml` builds the debug binary on `sm-standard-4` and runs it on both pools.
|
||||
|
||||
## Placement rule
|
||||
|
||||
A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason goes in a comment next to its `runs-on`:
|
||||
|
||||
1. **Disk.** It restores or produces the workspace `target/`: `cargo build` or `cargo test` with `--all-targets`, `cargo nextest run --all`, or any reader of the `ci-dev` / `ci-feat-*` caches. The workspace test build leaves about 64 GB in `target/`; a hosted runner has about 14 GB.
|
||||
2. **Critical-path CPU.** It is a build that several downstream lanes wait on and would blow its timeout at 4 vCPU (`build-rustfs-debug-binary` gates the e2e and s3-tests lanes).
|
||||
3. **Hardware.** Native aarch64, Docker-in-Docker, or the lab machines.
|
||||
4. **Cache ownership.** It reads a `rust-cache` key that a self-hosted job writes (see the cache rule below).
|
||||
|
||||
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
|
||||
|
||||
A hosted runner is also the only choice for a few things the pods cannot do: `mount -o loop` and `mount -t tmpfs` fail inside the ARC pods, and a container runtime cannot guarantee that `io_uring_setup` is allowed. `uring-integration` and `e2e-distributed` are on `ubuntu-latest` for that reason, not for cost.
|
||||
|
||||
## Cache rule
|
||||
|
||||
`Swatinem/rust-cache` builds its key from the OS, architecture, toolchain and lockfile. It does not include the runner label or image, so a cache written on an `sm-standard-4` pod and one written on `ubuntu-latest` would overwrite each other under the same `shared-key`.
|
||||
|
||||
- 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.
|
||||
- `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
|
||||
|
||||
These are the patterns that have cost real money here without validating anything more:
|
||||
|
||||
- An `if: always()` summary or alert job on a self-hosted label inside a `workflow_run`-triggered workflow. The upstream workflow completes many times a day, the real jobs skip, and the summary still starts a pod each time. Gate it on the upstream job having run, and keep it on `ubuntu-latest`.
|
||||
- Per-push workflows on `main`. A merge triggers the full `ci.yml` matrix, `cache-warm.yml` and the `build.yml` development build; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
|
||||
- `apt-get install` of tools the hosted image already ships. It is only needed on the minimal pod images.
|
||||
- A throttled `CARGO_BUILD_JOBS` on a 14-CPU pod. The limit exists for link-storm reasons documented in `ci.yml`; measure before raising it, and measure before lowering it further.
|
||||
- Tiny jobs on ARC pods. Each one pays roughly a minute of pod start-up before its first step; a compile-free job that takes ten seconds costs more to schedule than to run.
|
||||
|
||||
## Measuring a run
|
||||
|
||||
To see which pool each job of a run used and for how long:
|
||||
|
||||
```bash
|
||||
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-`, `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
|
||||
|
||||
- `runs-on` is a literal label. A new self-hosted label is declared in `.github/actionlint.yaml` in the same change (`.github/AGENTS.md`).
|
||||
- The job declares `timeout-minutes` (`scripts/security/check_job_timeouts.sh`).
|
||||
- `actionlint` reports nothing.
|
||||
- A self-hosted `runs-on` carries a comment naming its reason from the placement rule.
|
||||
- Every `./.github/actions/setup` call states `cache-save-if`, and a key read from one pool is not written from the other.
|
||||
- Moving a job does not change its check name, so the `main` ruleset and `docs/testing/ci-gates.md` stay as they are; renaming it does, and then that document's change checklist applies.
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
cancel-closed-pr-runs:
|
||||
name: Cancel Closed PR Runs
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Explain cancellation run
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
cargo-deny:
|
||||
name: Cargo Deny
|
||||
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
workflow-pin-report:
|
||||
name: Workflow Pin Report
|
||||
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
if: github.event_name == 'pull_request' && github.event.action != 'closed'
|
||||
permissions:
|
||||
|
||||
+68
-81
@@ -22,9 +22,12 @@
|
||||
# 3. Trigger docker.yml to build and push images using the uploaded binaries
|
||||
#
|
||||
# Platform scope:
|
||||
# - Pushes to main (development builds) build the Linux targets only — they
|
||||
# feed the dev download channel and Docker dev images. Tags, the weekly
|
||||
# schedule and manual dispatch build the full platform matrix.
|
||||
# - Development builds come from the nightly schedule and manual dispatch and
|
||||
# cover the full platform matrix; they feed the dev download channel
|
||||
# (artifacts/rustfs/dev on R2, the *-main-latest packages). Tags build the
|
||||
# release matrix. Main pushes do not build: at the usual merge rate most
|
||||
# per-merge builds were cancelled by the next merge before publishing
|
||||
# anything, and the dev channel does not need per-merge granularity.
|
||||
#
|
||||
# Manual Parameters:
|
||||
# - publish: Publish binaries/releases for manual runs (default: false)
|
||||
@@ -36,24 +39,8 @@ name: Build and Release
|
||||
on:
|
||||
push:
|
||||
tags: [ "*.*.*" ]
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "**.txt"
|
||||
- ".github/**"
|
||||
- "docs/**"
|
||||
- "deploy/**"
|
||||
- "scripts/dev_*.sh"
|
||||
- "LICENSE*"
|
||||
- "README*"
|
||||
- "**/*.png"
|
||||
- "**/*.jpg"
|
||||
- "**/*.svg"
|
||||
- ".gitignore"
|
||||
- ".dockerignore"
|
||||
- "flake.lock"
|
||||
schedule:
|
||||
- cron: "13 1 * * 0" # Weekly on Sunday 01:13 UTC
|
||||
- cron: "13 1 * * *" # Nightly 01:13 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
@@ -93,7 +80,7 @@ jobs:
|
||||
# Build strategy check - determine build type based on trigger
|
||||
build-check:
|
||||
name: Build Strategy Check
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
@@ -144,12 +131,6 @@ jobs:
|
||||
build_type="release"
|
||||
echo "📦 Release build detected: $tag_name"
|
||||
fi
|
||||
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
# Main branch push - development build
|
||||
should_build=true
|
||||
build_type="development"
|
||||
version="dev-${short_sha}"
|
||||
echo "🛠️ Development build detected"
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]] || \
|
||||
[[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# Scheduled or manual build
|
||||
@@ -178,7 +159,7 @@ jobs:
|
||||
prepare-platform-matrix:
|
||||
name: Prepare Platform Matrix
|
||||
needs: build-check
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
matrix: ${{ steps.select.outputs.matrix }}
|
||||
@@ -200,25 +181,14 @@ jobs:
|
||||
selected="all"
|
||||
fi
|
||||
|
||||
# Per-merge development builds only feed the dev download channel
|
||||
# and the Docker dev images, which consume the Linux binaries; at
|
||||
# the usual merge cadence most per-merge builds are cancelled by the
|
||||
# next merge anyway. macOS and Windows stay covered by tag builds,
|
||||
# the weekly scheduled build and manual dispatch.
|
||||
if [[ "${selected}" == "all" \
|
||||
&& "${{ github.event_name }}" == "push" \
|
||||
&& "${{ needs.build-check.outputs.build_type }}" == "development" ]]; then
|
||||
selected="linux-x86_64-musl,linux-aarch64-musl,linux-x86_64-gnu,linux-aarch64-gnu"
|
||||
echo "Development (main push) build: restricting to Linux targets"
|
||||
fi
|
||||
|
||||
all='{"include":[
|
||||
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":""},
|
||||
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":""},
|
||||
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":false,"platform":"linux","rustflags":"","features":"pyroscope"},
|
||||
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":false,"platform":"linux","rustflags":"","features":"pyroscope"},
|
||||
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope"},
|
||||
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":""}
|
||||
{"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":"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":"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":""}
|
||||
]}'
|
||||
|
||||
if [[ "${selected}" == "all" ]]; then
|
||||
@@ -246,7 +216,8 @@ jobs:
|
||||
echo "Selected platforms: ${selected}"
|
||||
|
||||
build-rustfs:
|
||||
name: Build RustFS
|
||||
# Acceptance workflows select artifacts by this stable platform name.
|
||||
name: Build RustFS (${{ matrix.target_id }})
|
||||
needs: [ build-check, prepare-platform-matrix ]
|
||||
if: needs.build-check.outputs.should_build == 'true' && needs.prepare-platform-matrix.result == 'success'
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -273,7 +244,7 @@ jobs:
|
||||
with:
|
||||
rust-version: stable
|
||||
target: ${{ matrix.target }}
|
||||
cache-shared-key: build-${{ matrix.target }}
|
||||
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,
|
||||
@@ -410,35 +381,53 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
BUILD_OS: ${{ matrix.platform }}-${{ startsWith(matrix.target, 'aarch64-') && 'aarch64' || 'x86_64' }}
|
||||
run: |
|
||||
# Force rebuild by touching build.rs
|
||||
touch rustfs/build.rs
|
||||
|
||||
BINARY_NAMES=(rustfs)
|
||||
if [[ "${{ matrix.platform }}" == "linux" ]]; then
|
||||
BINARY_NAMES+=(rustfs-cli)
|
||||
fi
|
||||
FEATURE_ARGS=()
|
||||
if [[ -n "${{ matrix.features }}" ]]; then
|
||||
FEATURE_ARGS=(--features "${{ matrix.features }}")
|
||||
fi
|
||||
|
||||
# Build one binary at a time so release LTO links cannot exhaust a
|
||||
# self-hosted runner by running concurrently.
|
||||
for binary in "${BINARY_NAMES[@]}"; do
|
||||
PROFILE_ARGS=()
|
||||
if [[ "$binary" == "rustfs" && "$BUILD_TARGET" == *-linux-gnu && "${{ matrix.features }}" == "pyroscope" ]]; then
|
||||
# The in-process profiler resolves names from the final executable.
|
||||
# Retain function symbols without enabling DWARF source information.
|
||||
PROFILE_ARGS=(--config 'profile.release.strip="none"')
|
||||
fi
|
||||
if [[ "${{ matrix.cross }}" == "true" ]]; then
|
||||
# All cross targets in the matrix are Linux; zigbuild handles them.
|
||||
cargo zigbuild --release --target ${{ matrix.target }} "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin "$binary"
|
||||
else
|
||||
cargo build --release --target ${{ matrix.target }} "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin "$binary"
|
||||
fi
|
||||
done
|
||||
# gnu targets pin an explicit glibc baseline (`<target>.<glibc>` for
|
||||
# cargo-zigbuild) so release binaries link no symbols newer than it
|
||||
# and run on older distros such as RHEL/Rocky 9 (glibc 2.34); #8062.
|
||||
TARGET_ARG="$BUILD_TARGET"
|
||||
if [[ -n "${{ matrix.glibc }}" ]]; then
|
||||
TARGET_ARG="${TARGET_ARG}.${{ matrix.glibc }}"
|
||||
fi
|
||||
|
||||
PROFILE_ARGS=()
|
||||
if [[ "$BUILD_TARGET" == *-linux-gnu && "${{ matrix.features }}" == "pyroscope" ]]; then
|
||||
# The in-process profiler resolves names from the final executable.
|
||||
# Retain function symbols without enabling DWARF source information.
|
||||
PROFILE_ARGS=(--config 'profile.release.strip="none"')
|
||||
fi
|
||||
if [[ "${{ matrix.cross }}" == "true" ]]; then
|
||||
cargo zigbuild --release --locked --target "$TARGET_ARG" "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin rustfs
|
||||
else
|
||||
cargo build --release --locked --target "$TARGET_ARG" "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin rustfs
|
||||
fi
|
||||
|
||||
# All release targets use same-architecture runners, including GNU
|
||||
# builds that use Zig only to select the glibc ABI floor.
|
||||
bin="target/${{ matrix.target }}/release/rustfs"
|
||||
if [[ -f "${bin}.exe" ]]; then
|
||||
bin="${bin}.exe"
|
||||
fi
|
||||
version_output=$("$bin" --version)
|
||||
if ! grep -Fxq "build target : ${BUILD_TARGET}" <<< "$version_output" || \
|
||||
! grep -Fxq "build os : ${BUILD_OS}" <<< "$version_output" || \
|
||||
! grep -Fxq "rust channel : stable-${BUILD_TARGET}" <<< "$version_output"; then
|
||||
echo "release binary reports an unexpected compile target" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${{ matrix.glibc }}" ]]; then
|
||||
python3 scripts/release/check_glibc.py "${{ matrix.glibc }}" \
|
||||
"target/${{ matrix.target }}/release/rustfs"
|
||||
fi
|
||||
|
||||
- name: Verify macOS binary has no dynamic liblzma dependency
|
||||
if: matrix.platform == 'macos'
|
||||
@@ -554,9 +543,6 @@ jobs:
|
||||
fi
|
||||
|
||||
BINARY_FILES=("$BINARY_NAME")
|
||||
if [[ "${{ matrix.platform }}" == "linux" ]]; then
|
||||
BINARY_FILES+=("rustfs-cli")
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" == *-linux-gnu && "${{ matrix.features }}" == "pyroscope" ]]; then
|
||||
BINARY_FILES+=(rustfs.cpu-symbol-catalog.json rustfs.cpu-symbol-catalog.sha256)
|
||||
@@ -629,7 +615,7 @@ jobs:
|
||||
LATEST_FILES="$LATEST_FILE"
|
||||
fi
|
||||
elif [[ "$BUILD_TYPE" == "development" ]]; then
|
||||
# Development builds (only main branch triggers development builds)
|
||||
# Development builds (nightly schedule and manual dispatch)
|
||||
# Create main-latest version filename
|
||||
# Convert from rustfs-linux-x86_64-dev-abc123 to rustfs-linux-x86_64-main-latest
|
||||
MAIN_LATEST_FILE="${PACKAGE_BASENAME}-main-latest.zip"
|
||||
@@ -671,7 +657,7 @@ jobs:
|
||||
echo "🔧 Build type: ${BUILD_TYPE}"
|
||||
echo "📊 Version: ${VERSION}"
|
||||
|
||||
- name: Verify packaged Linux binaries
|
||||
- name: Verify packaged Linux binary
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -679,7 +665,7 @@ jobs:
|
||||
|
||||
package_file="${{ steps.package.outputs.package_file }}"
|
||||
archive_members="$(unzip -Z1 "$package_file" | sort)"
|
||||
expected_members=$'rustfs\nrustfs-cli'
|
||||
expected_members='rustfs'
|
||||
target="${{ matrix.target }}"
|
||||
features="${{ matrix.features }}"
|
||||
if [[ "$target" == *-linux-gnu && "$features" == "pyroscope" ]]; then
|
||||
@@ -691,13 +677,14 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Every Linux target builds natively, so executing the binaries here
|
||||
# musl targets build natively; the gnu legs are same-arch
|
||||
# cargo-zigbuild builds pinned to the release glibc baseline. Either
|
||||
# way the binaries run on their runner, so executing them here
|
||||
# proves each artifact matches its runner architecture.
|
||||
cli_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$cli_dir"' EXIT
|
||||
unzip -q "$package_file" rustfs rustfs-cli -d "$cli_dir"
|
||||
"$cli_dir/rustfs" --version
|
||||
"$cli_dir/rustfs-cli" --help >/dev/null
|
||||
binary_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$binary_dir"' EXIT
|
||||
unzip -q "$package_file" rustfs -d "$binary_dir"
|
||||
"$binary_dir/rustfs" --version
|
||||
|
||||
- name: Verify packaged console
|
||||
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
@@ -843,7 +830,7 @@ jobs:
|
||||
name: Build Summary
|
||||
needs: [ build-check, build-rustfs ]
|
||||
if: always() && needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Build completion summary
|
||||
@@ -1032,7 +1019,7 @@ jobs:
|
||||
name: Update Latest Version
|
||||
needs: [ build-check, publish-release ]
|
||||
if: needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Update latest.json
|
||||
|
||||
@@ -42,26 +42,23 @@
|
||||
name: Cache Warm
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every six hours; the 22:41 UTC run lands before ci.yml's nightly 00:11
|
||||
# UTC matrix. A reader restores the newest entry under its key and
|
||||
# rebuilds whatever its own diff changed, so a cache up to six hours old
|
||||
# costs a PR the handful of crates merged since. Warming after every merge
|
||||
# (fourteen completed runs a day, about two sm-standard-4 hours each)
|
||||
# spent far more than that to avoid it.
|
||||
- cron: "41 4,10,16,22 * * *"
|
||||
push:
|
||||
branches: [ main ]
|
||||
# Mirrors ci.yml's push paths-ignore: if a commit cannot change what ci.yml
|
||||
# compiles, it cannot change what ci.yml needs restored either.
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
- "deploy/**"
|
||||
- "scripts/dev_*.sh"
|
||||
- "scripts/probe.sh"
|
||||
- "LICENSE*"
|
||||
- ".gitignore"
|
||||
- ".dockerignore"
|
||||
- "README*"
|
||||
- "**/*.png"
|
||||
- "**/*.jpg"
|
||||
- "**/*.svg"
|
||||
- ".github/workflows/build.yml"
|
||||
- ".github/workflows/docker.yml"
|
||||
- ".github/workflows/audit.yml"
|
||||
# A toolchain or lockfile change invalidates most of every key; those
|
||||
# re-warm at once instead of waiting for the next slot.
|
||||
paths:
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- ".github/actions/setup/**"
|
||||
- ".github/workflows/cache-warm.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
emit_timings:
|
||||
@@ -75,7 +72,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Scoped by event. A push run and a dispatch run do not compete: GitHub keeps
|
||||
# Scoped by event. Scheduled, push and dispatch runs do not compete: GitHub keeps
|
||||
# one running plus one pending per group, so with a single shared group a
|
||||
# manually dispatched run was displaced as pending by the next merge and
|
||||
# cancelled — observed three times in a row, which made the --timings gate in
|
||||
@@ -99,7 +96,7 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
# Readers: test-and-lint, test-ilm-integration-serial, build-rustfs-debug-binary,
|
||||
# Readers: test-and-lint (workspace and ILM), build-rustfs-debug-binary,
|
||||
# e2e-tests, e2e-full.
|
||||
warm-ci-dev:
|
||||
name: Warm ci-dev
|
||||
@@ -162,6 +159,17 @@ jobs:
|
||||
cargo build --workspace --all-targets
|
||||
cargo build -p rustfs --bins --features e2e-test-hooks
|
||||
|
||||
# ci.yml runs Clippy on every pull request. Clippy's check-mode artifacts
|
||||
# are separate from the build above, so warm them with the exact
|
||||
# invocation ci.yml uses; a pull request then re-checks only what its diff
|
||||
# touches. A lint failure on main must not stop the warm-up: the cache is
|
||||
# still saved (cache-on-failure), and the failure belongs to ci.yml.
|
||||
- name: Warm Clippy check artifacts
|
||||
run: |
|
||||
if ! cargo clippy --all-targets -- -D warnings; then
|
||||
echo "::warning::cargo clippy reports findings on this commit; check artifacts are still cached"
|
||||
fi
|
||||
|
||||
# Runs before rust-cache's post step, so these are the sizes it is about
|
||||
# to archive. Reported so the cache-all-crates decision stays evidence-led:
|
||||
# registry/src is what that flag prunes, registry/cache is what the pruned
|
||||
@@ -185,9 +193,10 @@ jobs:
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Readers: test-and-lint-rio-v2 (per-PR), build-rustfs-debug-binary-rio-v2
|
||||
# (weekly schedule / manual dispatch only — dormant rio-v2 variant, see
|
||||
# rustfs/backlog#1835 and docs/architecture/minio-file-format-compat.md).
|
||||
# Readers: test-and-lint-rio-v2 (nightly schedule, merge group, dispatch),
|
||||
# build-rustfs-debug-binary-rio-v2 (nightly schedule / manual dispatch only —
|
||||
# dormant rio-v2 variant, see rustfs/backlog#1835 and
|
||||
# docs/architecture/minio-file-format-compat.md).
|
||||
# The second build below stays despite the reduced cadence: it warms the
|
||||
# rio-v2,e2e-test-hooks feature resolution the scheduled build restores,
|
||||
# which keeps that lane inside its 30-minute timeout.
|
||||
|
||||
+137
-184
@@ -42,7 +42,7 @@ on:
|
||||
merge_group:
|
||||
types: [ checks_requested ]
|
||||
schedule:
|
||||
- cron: "11 0 * * 0" # Weekly on Sunday 00:11 UTC
|
||||
- cron: "11 0 * * *" # Nightly 00:11 UTC, after the 22:41 UTC cache-warm run
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -62,10 +62,15 @@ env:
|
||||
|
||||
jobs:
|
||||
|
||||
# Compile-free lanes, and lanes that only run a prebuilt binary, use
|
||||
# GitHub-hosted runners: they are free for this public repository, and the
|
||||
# self-hosted pool is reserved for the lanes that need its CPU count and
|
||||
# disk (the workspace test build leaves a ~64GB target/, hosted runners
|
||||
# have 14GB). Placement and billing rules: README.md in this directory.
|
||||
cancel-closed-pr-runs:
|
||||
name: Cancel Closed PR Runs
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Explain cancellation run
|
||||
@@ -74,7 +79,7 @@ jobs:
|
||||
classify-changes:
|
||||
name: Select CI scope
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
mode: ${{ steps.scope.outputs.mode }}
|
||||
@@ -100,16 +105,12 @@ jobs:
|
||||
typos:
|
||||
name: Typos
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install typos download dependency
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends wget
|
||||
- name: Typos check with custom config file
|
||||
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
|
||||
|
||||
@@ -117,7 +118,7 @@ jobs:
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -168,12 +169,12 @@ jobs:
|
||||
echo "started_at=$(date --utc --iso-8601=seconds)"
|
||||
} > artifacts/test-and-lint/run-metadata.txt
|
||||
|
||||
# Clippy runs before the test pass: lint failures are the most common
|
||||
# CI-only breakage and should surface in minutes, not after 20+ minutes
|
||||
# of tests.
|
||||
# Sampled too: clippy is the natural control arm for any CARGO_BUILD_JOBS
|
||||
# experiment, since --all-targets is check-only for workspace members and
|
||||
# never links the ~100 test binaries the limit exists to throttle.
|
||||
# Clippy gates every event, pull requests included. When it ran only after
|
||||
# merge, a lint regression merged freely and then failed every main run
|
||||
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
|
||||
# writes the same invocation's check artifacts into ci-dev, so a pull
|
||||
# request re-checks only the crates its diff touches. Main keeps the step
|
||||
# because pull requests can merge without being up to date with main.
|
||||
- name: Run clippy lints
|
||||
run: |
|
||||
./scripts/ci/resource_sampler.sh start clippy
|
||||
@@ -182,31 +183,27 @@ jobs:
|
||||
|
||||
- name: Run nextest tests
|
||||
env:
|
||||
# #5394 mitigation, now under a measured experiment (backlog#1601).
|
||||
# #5394 mitigation, settled by the backlog#1601 experiment.
|
||||
#
|
||||
# 2 was chosen when three concurrent workspace test links were believed
|
||||
# to saturate the runner's overlay I/O and wedge Cargo until the 75m
|
||||
# timeout. cgroup v2 readings from the sampler show the pod actually
|
||||
# has 14 CPUs and 28GB (peak use 2.1GB), so 2 throttles compilation to
|
||||
# a seventh of what is available and memory was never the constraint —
|
||||
# the label name "sm-standard-4" had led everyone, including the
|
||||
# original mitigation, to assume 4 cores.
|
||||
# has 14 CPUs and 28GB (peak use 2.1GB): the label name "sm-standard-4"
|
||||
# had led everyone, including the original mitigation, to assume 4
|
||||
# cores, and memory was never the constraint.
|
||||
#
|
||||
# Raised to 3 on main pushes and manual dispatches; PRs keep 2 so the
|
||||
# merge path is untouched while the experiment runs.
|
||||
#
|
||||
# During baseline collection, main pushes cancelled superseded runs:
|
||||
# only 4 of 20 Test and Lint jobs reached a terminal state. Running
|
||||
# main validations now finish; dispatch remains an independent group
|
||||
# for collecting additional samples on demand.
|
||||
#
|
||||
# Baseline over 17 samples at 2:
|
||||
# median nextest/clippy step ratio 1.95, spread 1.85-2.06. The gate-2
|
||||
# criterion is that ratio dropping at least 10% (below ~1.76) with no
|
||||
# 75m timeout and no run showing three consecutive samples of
|
||||
# rustc/collect2/rust-lld in D state. If it does not, the conclusion is
|
||||
# "this limit is not the bottleneck" — fix it back at 2 and record the
|
||||
# experiment, which is a result, not a failure.
|
||||
# Measured on the same ci-dev cache, the test-profile compile took
|
||||
# 28m21s at 2 on a pull request and 16-23 min at 3 across three
|
||||
# successful main runs. The first pull-request run at 3 that met a
|
||||
# busy node (run 39193827281) then hit the 75m timeout before any
|
||||
# test started: the sampler shows cargo spinning for 70 minutes with
|
||||
# the pod's io.pressure "full" at 86-88% and CPU pressure at zero,
|
||||
# while node-level IO pressure was already 36% from co-tenants when
|
||||
# the step began. The constraint is the pod's shared storage, not
|
||||
# CPU or memory, and the limit that storage sustains on a contended
|
||||
# node is 2. Main pushes and dispatches keep 3 (rarer, and three
|
||||
# successes); pull requests stay at 2 until the pods get local disk
|
||||
# for target/, which is the lever that would unlock the 14 cores.
|
||||
#
|
||||
# Must stay step-level: rust-cache hashes CARGO/CC/CFLAGS/CXX/CMAKE/RUST
|
||||
# prefixed variables from process.env into the cache key, so promoting
|
||||
@@ -283,10 +280,55 @@ jobs:
|
||||
- name: Verify rebalance/decommission migration proofs
|
||||
run: ./scripts/check_migration_gate_count.sh evidence artifacts/test-and-lint/core-test-listing.json target/nextest/ci/junit.xml
|
||||
|
||||
# This gate builds into a fresh target directory to isolate the E2E root.
|
||||
# Give its cold build a separate budget from workspace tests and migration proofs.
|
||||
offline-enrollment-e2e:
|
||||
name: Offline Enrollment E2E Root Boundary
|
||||
# The workspace already compiled these ignored tests. Keep the same Cargo
|
||||
# package selection to reuse their binaries, then run them serially in
|
||||
# separate processes because they share fixed ports and global state.
|
||||
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
|
||||
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
|
||||
# disk_index 0), not an EC metadata-distribution issue.
|
||||
# restore_object_usecase_reports_ongoing_conflict_and_completion was
|
||||
# re-enabled by backlog#1304 (restore accepts serialize on a short CAS
|
||||
# guard; the copy-back no longer holds the #4877 whole-copy-back lock,
|
||||
# so the mid-restore ongoing read and fast 409 rejection it asserts are
|
||||
# the implemented contract).
|
||||
- name: Run ignored ILM integration tests serially
|
||||
if: needs.classify-changes.outputs.mode == 'full'
|
||||
env:
|
||||
# Match the preceding workspace build and reuse its feature graph.
|
||||
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||
run: |
|
||||
mkdir -p artifacts/ilm-integration
|
||||
set +e
|
||||
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 80m \
|
||||
cargo nextest run --profile default -j1 --run-ignored ignored-only \
|
||||
--all --exclude e2e_test \
|
||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))' \
|
||||
--status-level all --final-status-level all \
|
||||
2>&1 | tee artifacts/ilm-integration/nextest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
{
|
||||
echo "exit_status=${status}"
|
||||
echo "finished_at=$(date --utc --iso-8601=seconds)"
|
||||
echo
|
||||
echo "Remaining test-related processes:"
|
||||
pgrep -af 'cargo|nextest|target/.*/deps/' || true
|
||||
} > artifacts/ilm-integration/diagnostics.txt
|
||||
exit "${status}"
|
||||
|
||||
- name: Upload ILM test diagnostics
|
||||
if: always() && needs.classify-changes.outputs.mode == 'full'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ilm-integration-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: artifacts/ilm-integration
|
||||
retention-days: 3
|
||||
|
||||
# Both Connect boundaries rebuild only the rustfs crate on top of the restored
|
||||
# ci-dev cache: the two e2e features are empty, so every dependency keeps its
|
||||
# cached dev-profile artifact and each feature set recompiles rustfs once.
|
||||
# The budget covers a ci-dev miss, where the first step builds the whole graph.
|
||||
connect-feature-boundaries:
|
||||
name: Connect Feature Boundaries
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
@@ -311,86 +353,58 @@ jobs:
|
||||
- name: Protect Connect test home
|
||||
run: chmod go-w "$(realpath "$HOME")"
|
||||
|
||||
- name: Run short credential behavior tests
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo test -p rustfs --test connect_registration \
|
||||
--features connect-e2e-short-credentials \
|
||||
registration_enforces_build_profile_credential_lifetime -- --exact
|
||||
cargo test -p rustfs --test connect_registration \
|
||||
--features connect-e2e-short-credentials \
|
||||
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
|
||||
|
||||
# The guard in rustfs/src/connect/registration.rs is cfg(not(debug_assertions)),
|
||||
# and it must be tripped by a profile that turns debug assertions off for
|
||||
# the whole graph. Handing `-C debug-assertions=off` to the rustfs lib alone
|
||||
# through `cargo rustc` leaves its dependencies on the dev-profile cfg, so
|
||||
# the lib also fails on items gated on one side only (E0599 on
|
||||
# Filenames::Embedded) and "fails solely at the compile_error" can never
|
||||
# hold. `cargo check --release` is metadata-only and needs no
|
||||
# link-concurrency limit; it is cold because ci-dev holds no release
|
||||
# artifacts.
|
||||
- name: Reject short credentials in release builds
|
||||
run: |
|
||||
log="$(mktemp)"
|
||||
set +e
|
||||
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
|
||||
--features connect-e2e-short-credentials >"$log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
cat "$log"
|
||||
expected='error: connect-e2e-short-credentials is restricted to debug builds'
|
||||
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
|
||||
expected_count="$(grep -Fxc "$expected" "$log" || true)"
|
||||
summary_count="$(grep -Fc "$summary" "$log" || true)"
|
||||
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
|
||||
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|
||||
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
|
||||
echo "feature gate did not fail solely at the expected compile_error" >&2
|
||||
rm -f "$log"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$log"
|
||||
|
||||
- name: Check offline enrollment E2E root boundary
|
||||
run: ./scripts/check_offline_enrollment_e2e.sh
|
||||
|
||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||
# fixed ports (9002 for the scanner suite, 9003 for the app suite), so they are
|
||||
# marked #[ignore] to keep them out of the default parallel `cargo nextest run
|
||||
# --all` pass. Run them here explicitly with `--run-ignored ignored-only` and
|
||||
# `-j1` so no two of them share global state or race for a port at once.
|
||||
# serial_test's #[serial] does NOT serialize across nextest's process-per-test
|
||||
# boundary (see .config/nextest.toml); `-j1` is what actually serializes them.
|
||||
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
||||
test-ilm-integration-serial:
|
||||
name: ILM Integration (serial)
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
|
||||
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
|
||||
# disk_index 0), not an EC metadata-distribution issue.
|
||||
# restore_object_usecase_reports_ongoing_conflict_and_completion was
|
||||
# re-enabled by backlog#1304 (restore accepts serialize on a short CAS
|
||||
# guard; the copy-back no longer holds the #4877 whole-copy-back lock,
|
||||
# so the mid-restore ongoing read and fast 409 rejection it asserts are
|
||||
# the implemented contract).
|
||||
- name: Run ignored ILM integration tests serially
|
||||
env:
|
||||
# Match the measured Test and Lint link budget. The default exposed
|
||||
# all 14 pod CPUs and a cold cache spent the full 80m compiling
|
||||
# without starting one ILM test (main run 32982910990).
|
||||
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||
run: |
|
||||
mkdir -p artifacts/ilm-integration
|
||||
set +e
|
||||
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 80m \
|
||||
cargo nextest run -j1 --run-ignored ignored-only \
|
||||
-p rustfs-scanner -p rustfs \
|
||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))' \
|
||||
--status-level all --final-status-level all \
|
||||
2>&1 | tee artifacts/ilm-integration/nextest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
{
|
||||
echo "exit_status=${status}"
|
||||
echo "finished_at=$(date --utc --iso-8601=seconds)"
|
||||
echo
|
||||
echo "Remaining test-related processes:"
|
||||
pgrep -af 'cargo|nextest|target/.*/deps/' || true
|
||||
} > artifacts/ilm-integration/diagnostics.txt
|
||||
exit "${status}"
|
||||
|
||||
- name: Upload ILM test diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ilm-integration-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
artifacts/ilm-integration
|
||||
target/nextest/ci/junit.xml
|
||||
|
||||
test-and-lint-rio-v2:
|
||||
name: Test and Lint (rio-v2)
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
# Alternate feature configurations run on the nightly schedule, merge
|
||||
# groups and manual dispatch. The default workspace remains the PR gate and
|
||||
# the per-push main baseline: repeating four feature builds on every merge
|
||||
# bought nothing the nightly run does not report within a day.
|
||||
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["merge_group", "schedule", "workflow_dispatch"]'), github.event_name)
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
@@ -446,67 +460,10 @@ jobs:
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
|
||||
connect-short-credential-boundary:
|
||||
name: Connect Short Credential Boundary
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
install-test-tools: 'false'
|
||||
|
||||
- name: Run short credential behavior tests
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo test -p rustfs --test connect_registration \
|
||||
--features connect-e2e-short-credentials \
|
||||
registration_enforces_build_profile_credential_lifetime -- --exact
|
||||
cargo test -p rustfs --test connect_registration \
|
||||
--features connect-e2e-short-credentials \
|
||||
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
|
||||
|
||||
- name: Reject short credentials in release builds
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
log="$(mktemp)"
|
||||
set +e
|
||||
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
|
||||
--features connect-e2e-short-credentials >"$log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
cat "$log"
|
||||
expected='error: connect-e2e-short-credentials is restricted to debug builds'
|
||||
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
|
||||
expected_count="$(grep -Fxc "$expected" "$log" || true)"
|
||||
summary_count="$(grep -Fc "$summary" "$log" || true)"
|
||||
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
|
||||
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|
||||
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
|
||||
echo "release feature gate did not fail solely at the expected compile_error" >&2
|
||||
rm -f "$log"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$log"
|
||||
|
||||
test-and-lint-protocols:
|
||||
name: "Test and Lint (${{ matrix.features.name }})"
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
# Same cadence as test-and-lint-rio-v2 above.
|
||||
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["merge_group", "schedule", "workflow_dispatch"]'), github.event_name)
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
@@ -662,7 +619,7 @@ jobs:
|
||||
build-rustfs-debug-binary-rio-v2:
|
||||
name: Build RustFS Debug Binary (rio-v2)
|
||||
# Dormant rio-v2 variant (rustfs/backlog#1835): the feature ships in no
|
||||
# default build, so this full-suite lane runs only on the weekly schedule
|
||||
# default build, so this full-suite lane runs only on the nightly schedule
|
||||
# and manual dispatch. Per-PR cfg-seam coverage stays with
|
||||
# test-and-lint-rio-v2. Lifecycle and the promote-or-delete condition:
|
||||
# docs/architecture/minio-file-format-compat.md ("rio-v2 variant lifecycle").
|
||||
@@ -1132,7 +1089,7 @@ jobs:
|
||||
s3-implemented-tests:
|
||||
name: S3 Implemented Tests
|
||||
needs: [ build-rustfs-debug-binary, e2e-tests ]
|
||||
runs-on: sm-standard-4
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -1201,12 +1158,12 @@ jobs:
|
||||
name: S3 Lifecycle Behavior Tests
|
||||
# Also gated on e2e-tests, matching s3-implemented-tests: when the e2e smoke
|
||||
# suite is already red this lane cannot tell us anything new, and it holds a
|
||||
# sm-standard-4 for up to 30 minutes doing so. Both lanes only download the
|
||||
# runner for up to 30 minutes doing so. Both lanes only download the
|
||||
# prebuilt debug binary (no cargo build), and s3-implemented-tests — which
|
||||
# already waits on e2e-tests — finishes later anyway, so a green PR's total
|
||||
# wall clock is unchanged.
|
||||
needs: [ build-rustfs-debug-binary, e2e-tests ]
|
||||
runs-on: sm-standard-4
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -1263,10 +1220,8 @@ jobs:
|
||||
- typos
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
- offline-enrollment-e2e
|
||||
- test-ilm-integration-serial
|
||||
- connect-feature-boundaries
|
||||
- test-and-lint-rio-v2
|
||||
- connect-short-credential-boundary
|
||||
- test-and-lint-protocols
|
||||
- build-rustfs-debug-binary
|
||||
- uring-integration
|
||||
@@ -1276,7 +1231,7 @@ jobs:
|
||||
- build-rustfs-debug-binary-rio-v2
|
||||
- e2e-tests-rio-v2
|
||||
- e2e-full
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
@@ -1292,13 +1247,11 @@ jobs:
|
||||
name: Alert on scheduled failure
|
||||
needs:
|
||||
- classify-changes
|
||||
- connect-short-credential-boundary
|
||||
- connect-feature-boundaries
|
||||
- required-checks
|
||||
- typos
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
- offline-enrollment-e2e
|
||||
- test-ilm-integration-serial
|
||||
- test-and-lint-rio-v2
|
||||
- test-and-lint-protocols
|
||||
- build-rustfs-debug-binary
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
# Echoes one line; the run exists only so the concurrency group cancels the
|
||||
# in-flight run of a closed PR.
|
||||
permissions: {}
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Explain cancellation run
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
checks: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Report CLA result for merge queue
|
||||
|
||||
@@ -57,6 +57,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -94,7 +102,7 @@ jobs:
|
||||
' <<<"$run" >/dev/null
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e '
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -137,7 +145,7 @@ jobs:
|
||||
assert outer.testzip() is None
|
||||
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
|
||||
entries = inner.infolist()
|
||||
assert {item.filename for item in entries} == {'rustfs', 'rustfs-cli'} and len(entries) == 2
|
||||
assert {item.filename for item in entries} == {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 3
|
||||
assert all(regular(item) and 0 < item.file_size <= 1073741824 for item in entries)
|
||||
assert inner.testzip() is None
|
||||
pathlib.Path('binary').mkdir()
|
||||
@@ -162,7 +170,7 @@ jobs:
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
(cd connect-harness && ./tests/connectivity/diagnostic-schedule-evidence.test.sh)
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_DIAGNOSTIC_SCHEDULE_EVIDENCE="$GITHUB_WORKSPACE/diagnostic-schedule-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=diagnostic-schedule
|
||||
|
||||
@@ -57,6 +57,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -127,7 +135,7 @@ jobs:
|
||||
' <<<"$run" >/dev/null
|
||||
jobs=$(gh api --paginate --slurp "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e '
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -172,7 +180,7 @@ jobs:
|
||||
assert outer.testzip() is None
|
||||
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
|
||||
entries = inner.infolist()
|
||||
assert {entry.filename for entry in entries} == {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 4
|
||||
assert {entry.filename for entry in entries} == {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 3
|
||||
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
|
||||
assert inner.testzip() is None
|
||||
pathlib.Path('binary').mkdir()
|
||||
@@ -199,7 +207,7 @@ jobs:
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
(cd connect-harness && ./tests/connectivity/health-service-evidence.test.sh)
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_HEALTH_SERVICE_EVIDENCE="$GITHUB_WORKSPACE/health-service-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=health-service
|
||||
|
||||
@@ -73,6 +73,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -107,7 +115,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -154,7 +162,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_PERFORMANCE_DRIVE_EVIDENCE="$GITHUB_WORKSPACE/performance-drive-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=performance-drive
|
||||
|
||||
@@ -73,6 +73,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -107,7 +115,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -154,7 +162,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_PERFORMANCE_NETWORK_EVIDENCE="$GITHUB_WORKSPACE/performance-network-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=performance-network
|
||||
|
||||
@@ -77,6 +77,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -118,7 +126,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(github_api "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.jobs[] | select(.name == $name)] as $matches
|
||||
@@ -173,7 +181,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_CPU_CATALOG="$GITHUB_WORKSPACE/binary/rustfs.cpu-symbol-catalog.json" \
|
||||
CONNECT_E2E_CPU_CATALOG_SHA256="$CATALOG_SHA256" \
|
||||
|
||||
@@ -57,6 +57,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -127,7 +135,7 @@ jobs:
|
||||
' <<<"$run" >/dev/null
|
||||
jobs=$(gh api --paginate --slurp "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e '
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -172,7 +180,7 @@ jobs:
|
||||
assert outer.testzip() is None
|
||||
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
|
||||
entries = inner.infolist()
|
||||
expected = {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
|
||||
expected = {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
|
||||
assert {entry.filename for entry in entries} == expected and len(entries) == len(expected)
|
||||
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
|
||||
assert inner.testzip() is None
|
||||
@@ -198,7 +206,7 @@ jobs:
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
(cd connect-harness && ./tests/connectivity/profile-memory-evidence.test.sh)
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_PROFILE_MEMORY_EVIDENCE="$GITHUB_WORKSPACE/profile-memory-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=profile-memory
|
||||
|
||||
@@ -81,6 +81,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -148,7 +156,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -201,7 +209,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_PROFILE_THREADS_EVIDENCE="$GITHUB_WORKSPACE/profile-threads-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=profile-threads
|
||||
|
||||
@@ -73,6 +73,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -107,7 +115,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -154,7 +162,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_TOP_API_EVIDENCE="$GITHUB_WORKSPACE/top-api-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=top-api
|
||||
|
||||
@@ -68,6 +68,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -138,7 +146,7 @@ jobs:
|
||||
' <<<"$run" >/dev/null
|
||||
jobs=$(gh api --paginate --slurp "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e '
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -183,7 +191,7 @@ jobs:
|
||||
assert outer.testzip() is None
|
||||
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
|
||||
entries = inner.infolist()
|
||||
expected = {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
|
||||
expected = {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
|
||||
assert {entry.filename for entry in entries} == expected and len(entries) == len(expected)
|
||||
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
|
||||
assert inner.testzip() is None
|
||||
@@ -213,7 +221,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_TOP_DISK_EVIDENCE="$GITHUB_WORKSPACE/top-disk-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=top-disk
|
||||
|
||||
@@ -73,6 +73,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -107,7 +115,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -154,7 +162,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_TOP_LOCKS_EVIDENCE="$GITHUB_WORKSPACE/top-locks-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=top-locks
|
||||
|
||||
@@ -73,6 +73,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -107,7 +115,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -154,7 +162,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_TOP_RPC_EVIDENCE="$GITHUB_WORKSPACE/top-rpc-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=top-rpc
|
||||
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch != 'main')
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
@@ -501,7 +501,7 @@ jobs:
|
||||
name: Scan Docker Images
|
||||
needs: [ build-check, build-docker ]
|
||||
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -556,7 +556,7 @@ jobs:
|
||||
name: Docker Build Summary
|
||||
needs: [ build-check, build-docker ]
|
||||
if: always() && needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Docker build completion summary
|
||||
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
|
||||
- name: Build rustfs binary
|
||||
run: |
|
||||
python3 scripts/e2e_binary.py build --bins
|
||||
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
|
||||
- name: Verify distributed e2e membership
|
||||
env:
|
||||
@@ -170,9 +170,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
rm -f target/nextest/e2e-distributed/junit.xml
|
||||
if [ -n "${FILTER}" ]; then
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||
else
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||
fi
|
||||
test -s target/nextest/e2e-distributed/junit.xml
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ jobs:
|
||||
name: Upstream HEAD classification canary
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
continue-on-error: true
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
|
||||
@@ -25,6 +25,7 @@ on:
|
||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||
- "crates/ecstore/**"
|
||||
- "crates/filemeta/**"
|
||||
- "crates/heal/**"
|
||||
- "crates/kms/**"
|
||||
- "crates/storage-api/**"
|
||||
- "rustfs/**"
|
||||
@@ -46,14 +47,11 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
|
||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
|
||||
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build upgrade candidate
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -66,19 +64,11 @@ jobs:
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: e2e-upgrade-server
|
||||
cache-shared-key: e2e-upgrade-server-hosted
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: "false"
|
||||
install-test-tools: "false"
|
||||
|
||||
- name: Download pinned previous release
|
||||
run: |
|
||||
mkdir -p target/debug
|
||||
archive="target/debug/$UPGRADE_SOURCE_ASSET"
|
||||
curl --fail --location --retry 3 --output "$archive" \
|
||||
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
||||
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
||||
|
||||
- name: Build current RustFS binary
|
||||
run: python3 scripts/e2e_binary.py build
|
||||
|
||||
@@ -89,49 +79,31 @@ jobs:
|
||||
path: |
|
||||
target/debug/rustfs
|
||||
target/debug/rustfs.e2e.json
|
||||
target/debug/${{ env.UPGRADE_SOURCE_ASSET }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
compression-level: 0
|
||||
overwrite: true
|
||||
|
||||
upgrade:
|
||||
name: ${{ matrix.name }}
|
||||
name: Upgrade compatibility (${{ matrix.source.version }})
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The two `_from_rc2_` tests keep their names: they assert
|
||||
# release-independent object contracts and pass unchanged against the
|
||||
# newer pinned source, so renaming them would only churn history and
|
||||
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
|
||||
# single source of truth for which release they actually run against.
|
||||
- name: Direct upgrade from the previous release
|
||||
test: direct_upgrade_from_rc2_preserves_object_contracts
|
||||
artifact: direct-upgrade
|
||||
- name: Mixed-version rolling upgrade from the previous release
|
||||
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
|
||||
artifact: mixed-version-upgrade
|
||||
- name: Bucket configuration survives the upgrade
|
||||
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
|
||||
artifact: bucket-config-upgrade
|
||||
- name: Rollback reads current bucket metadata
|
||||
test: rollback_to_previous_release_reads_current_bucket_metadata
|
||||
artifact: bucket-config-rollback
|
||||
- name: ODM configuration recovery after rc.5 rollback
|
||||
test: rc5_rollback_requires_restoring_odm_configuration
|
||||
artifact: odm-config-rollback
|
||||
- name: Multipart layouts survive the rc.5 upgrade
|
||||
test: direct_upgrade_from_rc5_preserves_multipart_layouts
|
||||
artifact: multipart-layout-upgrade
|
||||
- name: rc.5 multipart replication baseline
|
||||
test: rc5_baseline_replicates_multipart_layouts
|
||||
artifact: multipart-layout-baseline
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 60
|
||||
source:
|
||||
- version: 1.0.0-rc.5
|
||||
asset: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
|
||||
sha256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
|
||||
- version: 1.0.1
|
||||
asset: rustfs-linux-x86_64-gnu-v1.0.1.zip
|
||||
sha256: 9742e183cb0dfb0d48fa909797af572fe47b7f827569036baa4156e0d3958ee5
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
UPGRADE_SOURCE_VERSION: ${{ matrix.source.version }}
|
||||
UPGRADE_SOURCE_ASSET: ${{ matrix.source.asset }}
|
||||
UPGRADE_SOURCE_SHA256: ${{ matrix.source.sha256 }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
@@ -141,9 +113,8 @@ jobs:
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: e2e-upgrade-tests
|
||||
# One main-branch consumer warms the shared test-harness dependencies.
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' && matrix.artifact == 'direct-upgrade' }}
|
||||
cache-shared-key: e2e-upgrade-tests-hosted
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: "false"
|
||||
install-test-tools: "false"
|
||||
|
||||
@@ -163,6 +134,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p "$SOURCE_DIR"
|
||||
archive="target/debug/$UPGRADE_SOURCE_ASSET"
|
||||
curl --fail --location --retry 3 --output "$archive" \
|
||||
"https://github.com/rustfs/rustfs/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
||||
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
||||
unzip -q "$archive" -d "$SOURCE_DIR"
|
||||
chmod +x "$SOURCE_DIR/rustfs"
|
||||
@@ -170,22 +143,54 @@ jobs:
|
||||
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||
echo "RUSTFS_E2E_LOG_DIR=$RUNNER_TEMP/rustfs-upgrade-logs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run upgrade compatibility test
|
||||
# Reuse one compiled harness, while keeping each case in its own process.
|
||||
# Continue after a failure to retain the former matrix's complete verdict.
|
||||
- name: Run upgrade compatibility tests
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||
NO_PROXY: 127.0.0.1,localhost,::1
|
||||
run: |
|
||||
export no_proxy="$NO_PROXY"
|
||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
|
||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||
-- --ignored --exact --nocapture
|
||||
cases=(
|
||||
direct-upgrade:direct_upgrade_from_previous_release_preserves_object_contracts
|
||||
mixed-version-upgrade:rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
||||
bucket-config-upgrade:direct_upgrade_from_previous_release_preserves_bucket_configuration
|
||||
bucket-config-rollback:rollback_to_previous_release_reads_current_bucket_metadata
|
||||
multipart-layout-upgrade:direct_upgrade_from_previous_release_preserves_multipart_layouts
|
||||
multipart-layout-baseline:previous_release_baseline_replicates_multipart_layouts
|
||||
)
|
||||
if [[ "$UPGRADE_SOURCE_VERSION" == "1.0.0-rc.5" ]]; then
|
||||
cases+=(odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration)
|
||||
cases+=(checkpoint-upgrade:rc5_checkpoint_upgrade_rewinds_signed_progress_without_losing_objects)
|
||||
else
|
||||
cases+=(legacy-delete-reservation:stable_upgrade_deletes_foreign_reservations_without_losing_live_objects)
|
||||
fi
|
||||
status=0
|
||||
for entry in "${cases[@]}"; do
|
||||
artifact="${entry%%:*}"
|
||||
test="${entry#*:}"
|
||||
export RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR="$RUNNER_TEMP/rustfs-upgrade-g09-evidence/$artifact"
|
||||
export RUSTFS_E2E_LOG_DIR="$RUNNER_TEMP/rustfs-upgrade-logs/$artifact"
|
||||
mkdir -p "$RUSTFS_E2E_LOG_DIR"
|
||||
echo "::group::$test"
|
||||
if python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
|
||||
"upgrade_compatibility_test::$test" -- --ignored --exact --nocapture \
|
||||
2>&1 | tee "$RUSTFS_E2E_LOG_DIR/test.log"; then
|
||||
echo "PASS: $test"
|
||||
else
|
||||
echo "::error::Upgrade compatibility failed: $test"
|
||||
status=1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
- name: Upload scanner/heal G09 evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||
name: upgrade-g09-evidence-${{ matrix.source.version }}-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
@@ -193,7 +198,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-server-logs-${{ github.run_number }}
|
||||
name: upgrade-server-logs-${{ matrix.source.version }}-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-logs
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
cancel-closed-pr-runs:
|
||||
name: Cancel Closed PR Runs
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Explain cancellation run
|
||||
|
||||
@@ -6,29 +6,20 @@ name: Gateway Integration Sync
|
||||
# Merges `main` into the long-lived `gateway/integration` branch every weekday
|
||||
# so the s3s -> gateway migration (rustfs/backlog#2734, task T0.1 in #2736)
|
||||
# never accumulates merge debt. A clean merge lands as a `--no-ff` merge commit
|
||||
# pushed straight to the branch. A conflicting merge is aborted and surfaced as
|
||||
# a pull request from `sync/<source>-<date>`, whose head is the un-merged
|
||||
# source commit, so the conflict is resolved the same day, in review, with no
|
||||
# other change riding along. The run itself stays green in that case: the pull
|
||||
# request is the signal. Any other failure (push rejected, pull request not
|
||||
# created) fails the run and, on the schedule, files the shared failure issue.
|
||||
# pushed straight to the branch. A conflicting merge is aborted and fails the
|
||||
# run after its summary records the source and target SHAs, run URL, and exact
|
||||
# conflicting paths. It creates no sync branch or pull request. Resolve the
|
||||
# merge in an isolated worktree based on the latest target, preserve the
|
||||
# two-parent merge commit, and push only after the required checks pass and the
|
||||
# target still matches the merge's first parent. If the target moves, restart
|
||||
# from its new head.
|
||||
#
|
||||
# Resolving a conflict pull request: merge `origin/gateway/integration` into
|
||||
# the sync branch, resolve, push, get the review. Land it with a fast-forward
|
||||
# push of the resolved merge commit to `gateway/integration`, not with the
|
||||
# squash button: a squash drops the merge ancestry and the same hunks conflict
|
||||
# again on the next run.
|
||||
#
|
||||
# Write scope: only `gateway/integration` and `sync/*` are ever pushed, and
|
||||
# both push targets below are literal refs. The pushes use a one-hour
|
||||
# installation token of the organisation's own GitHub App (contents: write)
|
||||
# because that app is the only non-admin bypass actor of the
|
||||
# Write scope: only `gateway/integration` is ever pushed. The push uses a
|
||||
# one-hour installation token of the organisation's GitHub App (contents:
|
||||
# write) because that app is the only non-admin bypass actor of the
|
||||
# `gateway/integration` ruleset; GITHUB_TOKEN cannot be a ruleset bypass actor,
|
||||
# so it cannot push through the required reviews and checks. The conflict pull
|
||||
# request is opened with FLAKE_UPDATE_TOKEN, the PAT that already opens the
|
||||
# weekly flake.lock pull requests, because this repository keeps "Allow GitHub
|
||||
# Actions to create and approve pull requests" switched off. GITHUB_TOKEN is
|
||||
# therefore read-only here.
|
||||
# so it cannot push through the required reviews and checks. GITHUB_TOKEN is
|
||||
# read-only here.
|
||||
#
|
||||
# `schedule` fires only from the default branch, so the cron is live once this
|
||||
# file exists on `main`. `workflow_dispatch` runs from any ref that carries the
|
||||
@@ -94,6 +85,7 @@ jobs:
|
||||
id: merge
|
||||
env:
|
||||
SOURCE_REF: ${{ inputs.source_ref || 'main' }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! [[ "$SOURCE_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|
||||
@@ -119,13 +111,25 @@ jobs:
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
git diff --name-only --diff-filter=U > "$RUNNER_TEMP/conflicts.txt"
|
||||
git merge --abort
|
||||
if [[ ! -s "$RUNNER_TEMP/conflicts.txt" ]]; then
|
||||
conflicts=$(git diff --name-only --diff-filter=U)
|
||||
if [[ -z "$conflicts" ]]; then
|
||||
git merge --abort || true
|
||||
echo "::error::git merge failed without reporting conflicting paths; this is not a conflict"
|
||||
exit 1
|
||||
fi
|
||||
echo "result=conflict" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## Gateway integration sync stopped on conflicts"
|
||||
echo
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Source: \`${SOURCE_REF}\` at ${source_sha}"
|
||||
echo "- Target: \`${TARGET_BRANCH}\` at ${target_sha}"
|
||||
echo
|
||||
echo "Conflicting files:"
|
||||
printf '%s\n' "$conflicts" | sed 's/^/- /'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
git merge --abort
|
||||
echo "::error::${SOURCE_REF} cannot be merged cleanly into ${TARGET_BRANCH}; no branch or pull request was created"
|
||||
exit 1
|
||||
|
||||
- name: Push the merge commit
|
||||
if: steps.merge.outputs.result == 'merged'
|
||||
@@ -137,50 +141,12 @@ jobs:
|
||||
git push origin HEAD:refs/heads/gateway/integration
|
||||
echo "Merged \`${SOURCE_REF}\` (${SOURCE_SHA}) into \`${TARGET_BRANCH}\` as $(git rev-parse HEAD)." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Open the conflict pull request
|
||||
if: steps.merge.outputs.result == 'conflict'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.FLAKE_UPDATE_TOKEN }}
|
||||
SOURCE_REF: ${{ steps.merge.outputs.source_ref }}
|
||||
SOURCE_SHA: ${{ steps.merge.outputs.source_sha }}
|
||||
TARGET_SHA: ${{ steps.merge.outputs.target_sha }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
date_utc=$(date -u +%Y-%m-%d)
|
||||
sync_branch="sync/${SOURCE_REF//\//-}-${date_utc}"
|
||||
git push origin "refs/remotes/origin/${SOURCE_REF}:refs/heads/${sync_branch}"
|
||||
{
|
||||
echo "The scheduled merge of \`${SOURCE_REF}\` into \`${TARGET_BRANCH}\` stopped on conflicts and was aborted; \`${TARGET_BRANCH}\` is unchanged."
|
||||
echo
|
||||
echo "This branch is the un-merged \`${SOURCE_REF}\` head. Merge \`origin/${TARGET_BRANCH}\` into it, resolve the files below, push, and get the review. Land the resolved merge commit with a fast-forward push to \`${TARGET_BRANCH}\`; a squash merge drops the merge ancestry and the same hunks conflict again on the next run. Do not include any change that is not conflict resolution."
|
||||
echo
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Source: \`${SOURCE_REF}\` at ${SOURCE_SHA}"
|
||||
echo "- Target: \`${TARGET_BRANCH}\` at ${TARGET_SHA}"
|
||||
echo
|
||||
echo "## Conflicting files"
|
||||
echo
|
||||
echo "Output of \`git diff --name-only --diff-filter=U\` captured before \`git merge --abort\`:"
|
||||
echo
|
||||
sed 's/^/- /' "$RUNNER_TEMP/conflicts.txt"
|
||||
} > "$RUNNER_TEMP/pr-body.md"
|
||||
title="sync: merge ${SOURCE_REF} ${date_utc} (conflicts)"
|
||||
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --state open --json number --jq '.[0].number // empty')
|
||||
if [[ -n "$existing" ]]; then
|
||||
gh pr edit "$existing" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md"
|
||||
pr_url=$(gh pr view "$existing" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
||||
else
|
||||
pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md")
|
||||
fi
|
||||
echo "Conflicts in \`${SOURCE_REF}\` -> \`${TARGET_BRANCH}\`; pull request: ${pr_url}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [sync]
|
||||
# Same ci-8 mechanism audit.yml and coverage.yml use: a scheduled run that
|
||||
# could not push or could not open the conflict pull request must not stay
|
||||
# visible only in the Actions tab.
|
||||
# could not push or encountered merge conflicts must not stay visible only
|
||||
# in the Actions tab.
|
||||
if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -31,7 +31,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build-helm-package:
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
publish-helm-package:
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [ build-helm-package ]
|
||||
if: needs.build-helm-package.result == 'success'
|
||||
|
||||
@@ -32,7 +32,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: usthe/issues-translate-action@b41f55ddc81d7d54bd542a4f289fe28ec081898e # v2.7
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
resolve-source:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Resolve nightly source
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
source_sha: ${{ steps.source.outputs.sha }}
|
||||
@@ -80,13 +80,24 @@ jobs:
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: build-x86_64-unknown-linux-gnu
|
||||
cache-shared-key: build-x86_64-unknown-linux-gnu-glibc-2.31
|
||||
cache-save-if: 'false'
|
||||
install-cross-tools: 'true'
|
||||
target: x86_64-unknown-linux-gnu
|
||||
install-build-packaging-tools: 'false'
|
||||
install-test-tools: 'false'
|
||||
|
||||
- name: Build RustFS
|
||||
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
||||
# Pin the glibc baseline (`<target>.<glibc>` for cargo-zigbuild) so
|
||||
# nightlies run on older distros such as RHEL/Rocky 9 (glibc 2.34); #8062.
|
||||
run: cargo zigbuild --release --locked --target x86_64-unknown-linux-gnu.2.31 -p rustfs --bins
|
||||
|
||||
- name: Verify glibc symbol baseline
|
||||
run: |
|
||||
python3 scripts/release/check_glibc.py 2.31 \
|
||||
target/x86_64-unknown-linux-gnu/release/rustfs \
|
||||
target/x86_64-unknown-linux-gnu/release/rustfs-cli
|
||||
target/x86_64-unknown-linux-gnu/release/rustfs --version | grep -Fx 'build target : x86_64-unknown-linux-gnu'
|
||||
|
||||
- name: Build DEB package
|
||||
id: deb
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
update-flake:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Update flake.lock
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
cancel-closed-pr-runs:
|
||||
name: Cancel Closed PR Runs
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Explain cancellation run
|
||||
|
||||
@@ -20,8 +20,9 @@ on:
|
||||
- ".github/workflows/oidc-keycloak.yml"
|
||||
- "crates/config/src/constants/oidc.rs"
|
||||
- "crates/iam/src/federation/**"
|
||||
- "crates/iam/src/oidc.rs"
|
||||
- "crates/iam/src/oidc/**"
|
||||
- "rustfs/src/admin/handlers/oidc.rs"
|
||||
- "rustfs/src/admin/service/oidc_config.rs"
|
||||
- "rustfs/src/admin/handlers/sts.rs"
|
||||
- "scripts/test/oidc_keycloak_live.sh"
|
||||
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||
@@ -31,8 +32,9 @@ on:
|
||||
- ".github/workflows/oidc-keycloak.yml"
|
||||
- "crates/config/src/constants/oidc.rs"
|
||||
- "crates/iam/src/federation/**"
|
||||
- "crates/iam/src/oidc.rs"
|
||||
- "crates/iam/src/oidc/**"
|
||||
- "rustfs/src/admin/handlers/oidc.rs"
|
||||
- "rustfs/src/admin/service/oidc_config.rs"
|
||||
- "rustfs/src/admin/handlers/sts.rs"
|
||||
- "scripts/test/oidc_keycloak_live.sh"
|
||||
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
cloud-source:
|
||||
name: ${{ matrix.provider }} source (three-case minimum)
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -618,8 +618,10 @@ jobs:
|
||||
summary:
|
||||
name: Summary
|
||||
needs: [ resolve, package ]
|
||||
if: always()
|
||||
runs-on: sm-standard-2
|
||||
# Every completed Build and Release run triggers this workflow; when
|
||||
# resolve declined it (not a release tag), there is nothing to summarise.
|
||||
if: always() && needs.resolve.result != 'skipped'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Print summary
|
||||
|
||||
@@ -279,7 +279,9 @@ jobs:
|
||||
if [ "${{ inputs.pools || '3' }}" = "3" ]; then
|
||||
STEPS="$STEPS,7,8"
|
||||
if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then
|
||||
STEPS="$STEPS,9"
|
||||
# Step 10 asserts the surviving pools after the removal
|
||||
# (rustfs#8726), so it only runs together with step 9.
|
||||
STEPS="$STEPS,9,10"
|
||||
fi
|
||||
fi
|
||||
ARGS=(--steps "$STEPS" --with-warp -y \
|
||||
@@ -293,6 +295,10 @@ jobs:
|
||||
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
|
||||
ARGS+=(--proxy-endpoint "${RUSTFS_POOL_PROXY_ENDPOINT}")
|
||||
fi
|
||||
# Finalize the removal so step 10 can exercise the surviving
|
||||
# pools; passing it without step 9 selected would run step 10
|
||||
# against a topology where nothing was removed.
|
||||
case ",${STEPS}," in *,9,*) ARGS+=(--finalize-decommission) ;; esac
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||
|
||||
@@ -44,7 +44,7 @@ permissions:
|
||||
jobs:
|
||||
forced-failure:
|
||||
name: Forced failure
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Fail on purpose
|
||||
|
||||
@@ -27,7 +27,7 @@ on:
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
|
||||
@@ -15,7 +15,7 @@ concurrency:
|
||||
jobs:
|
||||
update:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: overtrue/repo-visuals-action@ee2c632f6ce617e851fb46ea935ee8af762ebb93 # v1.4.0
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
targets-live:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: PostgreSQL, MySQL, AMQP, and NATS
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: targets-live-lane
|
||||
cache-shared-key: targets-live-lane-hosted
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
|
||||
install-build-packaging-tools: 'false'
|
||||
install-test-tools: 'false'
|
||||
|
||||
@@ -19,8 +19,23 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- "crates/ecstore/src/disk/**"
|
||||
- "crates/ecstore/src/layout/**"
|
||||
- "crates/ecstore/src/store/init_format.rs"
|
||||
- "crates/ecstore/src/set_disk/ops/locking.rs"
|
||||
- "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"
|
||||
- "crates/utils/src/path.rs"
|
||||
- "rustfs/build.rs"
|
||||
- "rustfs/Cargo.toml"
|
||||
- "rustfs/src/main.rs"
|
||||
- "rustfs/src/server/runtime.rs"
|
||||
- "rustfs/src/startup_*.rs"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".github/actions/setup/**"
|
||||
@@ -29,8 +44,23 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- "crates/ecstore/src/disk/**"
|
||||
- "crates/ecstore/src/layout/**"
|
||||
- "crates/ecstore/src/store/init_format.rs"
|
||||
- "crates/ecstore/src/set_disk/ops/locking.rs"
|
||||
- "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"
|
||||
- "crates/utils/src/path.rs"
|
||||
- "rustfs/build.rs"
|
||||
- "rustfs/Cargo.toml"
|
||||
- "rustfs/src/main.rs"
|
||||
- "rustfs/src/server/runtime.rs"
|
||||
- "rustfs/src/startup_*.rs"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".github/actions/setup/**"
|
||||
@@ -77,14 +107,129 @@ jobs:
|
||||
shell: pwsh
|
||||
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
|
||||
|
||||
- name: Test Windows path conversion
|
||||
shell: pwsh
|
||||
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
|
||||
|
||||
- name: Test Windows volume and disk-space probes
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Map a drive letter to the temp drive's administrative share so the
|
||||
# remote-volume probe also covers mapped network drives.
|
||||
$drive = (Split-Path -Qualifier ([System.IO.Path]::GetTempPath())).TrimEnd(':')
|
||||
net use R: "\\localhost\$drive`$" /persistent:no
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$env:RUSTFS_TEST_WINDOWS_MAPPED_DRIVE = 'R:'
|
||||
} else {
|
||||
Write-Output "::warning::could not map R: to \\localhost\$drive`$; mapped-drive test will skip"
|
||||
}
|
||||
cargo test -p rustfs-utils --features os --lib os:: -- --nocapture
|
||||
$status = $LASTEXITCODE
|
||||
if ($env:RUSTFS_TEST_WINDOWS_MAPPED_DRIVE) { net use R: /delete /y | Out-Null }
|
||||
exit $status
|
||||
|
||||
- name: Test Windows handle guards
|
||||
shell: pwsh
|
||||
run: cargo test -p rustfs-ecstore --lib windows_ -- --nocapture
|
||||
|
||||
- name: Test endpoint path parsing
|
||||
shell: pwsh
|
||||
run: "cargo test -p rustfs-ecstore --lib layout::endpoint:: -- --nocapture"
|
||||
|
||||
- name: Test startup temporary-directory cleanup
|
||||
shell: pwsh
|
||||
run: cargo test -p rustfs-ecstore --lib cleanup_tmp_on_startup_ -- --nocapture
|
||||
|
||||
- name: Test object key Windows admission
|
||||
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"
|
||||
|
||||
- name: Test fresh format publication
|
||||
shell: pwsh
|
||||
run: cargo test -p rustfs-ecstore --lib fresh_format_load_initializes_all_disks -- --nocapture
|
||||
|
||||
# The native Windows main thread only gets the 1 MiB PE stack reserve, while
|
||||
# Linux and macOS hand out 8 MiB. Startup runs the whole root future on the
|
||||
# thread that calls `block_on`, so a debug server once overflowed before it
|
||||
# listened on Windows only. Start a real server to keep that covered.
|
||||
startup-smoke:
|
||||
name: Startup Smoke
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: build-x86_64-pc-windows-msvc
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
install-test-tools: 'false'
|
||||
|
||||
- name: Build debug server binary
|
||||
shell: pwsh
|
||||
run: cargo build --locked -p rustfs --bin rustfs
|
||||
|
||||
- name: Print version
|
||||
shell: pwsh
|
||||
run: |
|
||||
& .\target\debug\rustfs.exe --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "rustfs --version exited with $LASTEXITCODE" }
|
||||
|
||||
- name: Start server and wait for readiness
|
||||
shell: pwsh
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: rustfsadmin
|
||||
RUSTFS_SECRET_KEY: rustfsadmin
|
||||
run: |
|
||||
$volume = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke'
|
||||
$stdout = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke.out.log'
|
||||
$stderr = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke.err.log'
|
||||
New-Item -ItemType Directory -Force -Path $volume | Out-Null
|
||||
$server = Start-Process -FilePath .\target\debug\rustfs.exe `
|
||||
-ArgumentList @('--address', '127.0.0.1:9000', "`"$volume`"") `
|
||||
-RedirectStandardOutput $stdout -RedirectStandardError $stderr `
|
||||
-NoNewWindow -PassThru
|
||||
# Cache the process handle now; otherwise ExitCode reads back empty.
|
||||
$null = $server.Handle
|
||||
$ready = $false
|
||||
try {
|
||||
for ($i = 0; $i -lt 180; $i++) {
|
||||
if ($server.HasExited) { break }
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri 'http://127.0.0.1:9000/health/ready' -UseBasicParsing -NoProxy -TimeoutSec 2
|
||||
if ($response.StatusCode -eq 200) { $ready = $true; break }
|
||||
} catch {}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
} finally {
|
||||
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force }
|
||||
$server.WaitForExit()
|
||||
Write-Host '--- server stdout (tail) ---'
|
||||
Get-Content $stdout -Tail 100 -ErrorAction SilentlyContinue
|
||||
Write-Host '--- server stderr (tail) ---'
|
||||
Get-Content $stderr -Tail 100 -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (-not $ready) {
|
||||
throw ("rustfs did not report ready on /health/ready (exit code: {0})" -f $server.ExitCode)
|
||||
}
|
||||
|
||||
@@ -90,3 +90,4 @@ worktrees/*
|
||||
# insta scratch files; the accepted .snap files ARE the assertions and are committed
|
||||
*.snap.new
|
||||
*.pending-snap
|
||||
.mimosa
|
||||
Vendored
+21
-27
@@ -7,7 +7,9 @@
|
||||
"name": "Debug(only) executable 'rustfs'",
|
||||
"env": {
|
||||
"RUST_LOG": "rustfs=info,ecstore=info,s3s=info,iam=info",
|
||||
"RUSTFS_SKIP_BACKGROUND_TASK": "on"
|
||||
"RUSTFS_SKIP_BACKGROUND_TASK": "on",
|
||||
// Local test volumes share one physical disk.
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true"
|
||||
//"RUSTFS_OBS_LOG_DIRECTORY": "./deploy/logs",
|
||||
// "RUSTFS_POLICY_PLUGIN_URL":"http://localhost:8181/v1/data/rustfs/authz/allow",
|
||||
// "RUSTFS_POLICY_PLUGIN_AUTH_TOKEN":"your-opa-token"
|
||||
@@ -44,6 +46,7 @@
|
||||
"env": {
|
||||
"RUST_LOG": "rustfs=debug,ecstore=info,s3s=debug,iam=debug",
|
||||
"RUSTFS_SKIP_BACKGROUND_TASK": "on",
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
|
||||
//"RUSTFS_OBS_LOG_DIRECTORY": "./deploy/logs",
|
||||
// "RUSTFS_POLICY_PLUGIN_URL":"http://localhost:8181/v1/data/rustfs/authz/allow",
|
||||
// "RUSTFS_POLICY_PLUGIN_AUTH_TOKEN":"your-opa-token"
|
||||
@@ -117,7 +120,8 @@
|
||||
"env": {
|
||||
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||
//"RUSTFS_VOLUMES": "./target/volume/test{1...4}",
|
||||
"RUSTFS_VOLUMES": "./target/volume/test{1...4}",
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
|
||||
"RUSTFS_ADDRESS": ":9000",
|
||||
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||
// "RUSTFS_OBS_TRACE_ENDPOINT": "http://127.0.0.1:4318/v1/traces", // jeager otlp http endpoint
|
||||
@@ -135,41 +139,30 @@
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug test_lifecycle_transition_basic",
|
||||
"name": "Debug test_transition_and_restore_flows",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"-p",
|
||||
"rustfs-scanner",
|
||||
"--test",
|
||||
"lifecycle_integration_test",
|
||||
"serial_tests::test_lifecycle_transition_basic",
|
||||
"-j",
|
||||
"1"
|
||||
]
|
||||
],
|
||||
"filter": {
|
||||
"name": "lifecycle_integration_test",
|
||||
"kind": "test"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/test",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/target/debug/deps/lifecycle_integration_test-5915cbfcab491b3b",
|
||||
"args": [
|
||||
"--skip",
|
||||
"test_lifecycle_expiry_basic",
|
||||
"--skip",
|
||||
"test_lifecycle_expiry_deletemarker",
|
||||
//"--skip",
|
||||
//"test_lifecycle_transition_basic",
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
//"stopAtEntry": false,
|
||||
//"preLaunchTask": "cargo build",
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
"serial_tests::test_transition_and_restore_flows",
|
||||
"--exact",
|
||||
"--ignored",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/rustfs with sse kms",
|
||||
@@ -285,7 +278,7 @@
|
||||
"RUSTFS_COMPRESSION_ENABLED": "true",
|
||||
"RUSTFS_KMS_ENABLE": "true",
|
||||
"RUSTFS_KMS_BACKEND": "local",
|
||||
"RUSTFS_KMS_KEY_DIR": "./target/rio-v2/kms-key-dir",
|
||||
"RUSTFS_KMS_KEY_DIR": "${workspaceFolder}/target/rio-v2/kms-key-dir",
|
||||
"RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
},
|
||||
@@ -307,6 +300,7 @@
|
||||
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||
"RUSTFS_VOLUMES": "./target/e2e-test/test{1...4}",
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
|
||||
"RUSTFS_REGION": "us-east-1",
|
||||
"RUSTFS_ADDRESS": ":9000",
|
||||
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||
@@ -315,7 +309,7 @@
|
||||
|
||||
"RUSTFS_KMS_ENABLE": "true",
|
||||
"RUSTFS_KMS_BACKEND": "local",
|
||||
"RUSTFS_KMS_KEY_DIR": "./target/e2e-key-dir",
|
||||
"RUSTFS_KMS_KEY_DIR": "${workspaceFolder}/target/e2e-key-dir",
|
||||
"RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
},
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
This file contains repository-wide rules. Use the nearest subdirectory
|
||||
`AGENTS.md` for path-specific invariants.
|
||||
|
||||
S3-compatible object store in Rust, derived from MinIO. Erasure-coded,
|
||||
multi-pool, supports ILM tiering/lifecycle.
|
||||
|
||||
## Precedence
|
||||
|
||||
1. System/developer instructions.
|
||||
@@ -38,6 +41,27 @@ rules. A skill cannot expand the user's requested scope or grant authorization.
|
||||
pending. Before requesting missing approval, prepare the concrete result that
|
||||
is already authorized; retain explicit merge and release gates.
|
||||
|
||||
- Keep individual tool outputs concise. Save large outputs to task-owned logs
|
||||
and retrieve only the relevant ranges.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
cargo build --release --bin rustfs # production binary
|
||||
cargo check -p <crate> # fast type-check one crate
|
||||
cargo test -p <crate> # test one crate
|
||||
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
|
||||
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
|
||||
make pre-pr # optional full gate for broad cross-module changes
|
||||
make build-docker BUILD_OS=ubuntu22.04
|
||||
```
|
||||
|
||||
> **Docker build note**: `buildx build` without `--load` keeps the image in
|
||||
> the buildx cache only — `docker run` will use a stale local image. The
|
||||
> Makefile already includes `--load`; if you suspect a stale binary, add
|
||||
> `--no-cache` to the `buildx build` invocation inside
|
||||
> `.config/make/build-docker.mak`.
|
||||
|
||||
## Task-Specific Guidance
|
||||
|
||||
Read only the reference needed for the current task, once per unchanged context:
|
||||
@@ -65,6 +89,19 @@ or agent scratch notes. Durable architecture belongs under `docs/architecture/`,
|
||||
operations under `docs/operations/`, and testing references under
|
||||
`docs/testing/`. `scripts/check_no_planning_docs.sh` enforces this boundary.
|
||||
|
||||
## Further References
|
||||
|
||||
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
|
||||
- Crate membership: `Cargo.toml` `[workspace].members`
|
||||
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
|
||||
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
|
||||
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
|
||||
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
|
||||
policy: [docs/testing/README.md](docs/testing/README.md)
|
||||
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
|
||||
[docs/operations/tier-ilm-debugging.md](docs/operations/tier-ilm-debugging.md)
|
||||
|
||||
## Verification
|
||||
|
||||
Select checks from the final task-owned diff. Scoped `AGENTS.md` files may add a
|
||||
|
||||
@@ -19,12 +19,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
|
||||
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
|
||||
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
|
||||
- A `set-remote-target?update=true&creds=true` request on a site-replication peer's bucket target is now rejected with `400 InvalidRequest`, naming the target and why it counts as peer-owned. It used to return success while keeping the stored endpoint, credentials, and TLS settings, so an endpoint edit from the Console's bucket replication form appeared to apply but did not. Change a peer's address with a site-replication edit instead; see "Changing a site's address" in `docs/operations/site-replication-operations.md`. The `sync`, `bandwidth`, `proxy`, and `path` update groups are unchanged.
|
||||
- Site-replication edits that omit the site name, such as `mc admin replicate update --deployment-id <ID> --endpoint <URL>`, now keep the stored name on the editing site and on every receiver instead of renaming the site after the new endpoint host. No persisted format changes.
|
||||
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
|
||||
|
||||
### Security
|
||||
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`mc admin info` and `mc admin replicate resync` work again**: since the cluster snapshot view (#3803), `GET /v3/info` and `GET /v3/storageinfo` returned the madmin `InfoMessage` / `StorageInfo` nested under an `info` key next to `admin_discovery` (and, for `/info`, `bitrotSelftest`). madmin decodes those bodies directly, so `mc admin info` printed empty values and `mc admin replicate resync start/status` rejected every alias with "alias provided is not part of cluster replication" because the local `deploymentID` read as empty. Requests on the MinIO-compatible `/minio/admin/v3` prefix now receive the bare madmin body again; `/rustfs/admin/v3`, which rc and the console use, keeps the envelope unchanged.
|
||||
- **Replication status writebacks no longer invalidate scanner cycles**: every replication status writeback (`persist_replication_state_if_current` in `Update` mode, including the heals the scanner queues during its own walk) marked the bucket dirty on the namespace path, advanced the dirty-usage generation and woke the scanner, so a site-replication resync or any steadily replicated bucket ended every cycle `superseded`, the authoritative usage baseline was never certified, and the console reported usage freshness as stale for as long as replication ran (#8375). Those writebacks are now status class: they do not touch the dirty-usage generation, the activity snapshot, the dirty-usage wake or the durable dirty journal, and only the process-local producer coverage is recorded. They need no rescan queue, because a deployment with an active replication rule already walks every bucket on every cycle and that walk refreshes `replication_stats`. Tier-expiration cleanup, which removes data, stays on the namespace path. No persisted format, protocol, admin API or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
|
||||
- **A bucket written during a scanner walk no longer supersedes the whole cycle**: the post-walk cluster activity proof required the entire activity snapshot and the dirty-usage generation to be unchanged, so a single-node deployment with one bucket written every few seconds never reached a `Complete` cycle, `usage_snapshot_converged` never became true and the authoritative usage baseline was never certified (#8494). Dirty usage is now certified per bucket: a structurally complete walk that the guardrail did not veto is `Complete`, buckets whose dirty generation still matches the start snapshot are cleared, and buckets written during the walk stay dirty and are published as the pending set, so `usage_snapshot_converged` means "no bucket pending" and the next cycle is scoped to the pending buckets. Such a cycle writes the authoritative usage object; the observed object is now selected by an explicit observational mark that vetoed cycles carry. The guardrail itself vetoes only on the fields that change which namespace the walk described (peer set, process instance, protocol version, topology, maintenance generation, data movement, movement generation, publication blocked) and reports the field and host in the scanner log and in the new `usage_freshness` fields `guardrail_veto_total`, `last_guardrail_veto_unix_secs` and `last_guardrail_veto_reason`. In a distributed deployment a remote peer whose dirty usage moved during the walk still supersedes the cycle until scoped acknowledgements can name its buckets. `.usage.v2.json` gains the additive, default-when-absent fields `usage_snapshot_pending_buckets`, `usage_snapshot_pending_bucket_count` and `usage_snapshot_observational`; older readers ignore them and treat the snapshot as an authoritative but unconverged publication. Quota is unchanged: an uncertified bucket keeps its last usage value as the floor and live writes do not advance it. No protocol or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
|
||||
- **SSE-C TLS requirement now covers copy-source keys**: with `RUSTFS_SSE_C_REQUIRE_TLS=true`, a `CopyObject` or `UploadPartCopy` that carried only the `x-amz-copy-source-server-side-encryption-customer-*` headers (an SSE-C source copied to a destination that is not SSE-C) was accepted on a plaintext transport and was not counted in `rustfs_ssec_plaintext_requests_total`, although those headers carry the source object's customer key. They now count as SSE-C headers: such requests are counted, and refused with `400 InvalidRequest` when the switch is on. Requests over TLS are unaffected.
|
||||
- **SSE-KMS encryption context could replace the location entry**: data keys are wrapped under an encryption-context entry, keyed by the bucket name, that ties them to the object's location. A client `x-amz-server-side-encryption-context` entry under the same key replaced it, and because the client context is stored with the object, the replacement also applied on every read. SSE-KMS writes now refuse such a context with `400 InvalidArgument`; other keys, including the names of other buckets, are unaffected. Objects stored with such an entry by an earlier release remain readable. See `docs/operations/kms-backend-security.md`.
|
||||
- **Two admin handlers reported a missing policy or user as `500 InternalError`**: `GET /rustfs/admin/v3/info-canned-policy` and `DELETE /rustfs/admin/v3/remove-user` wrapped every IAM error as an internal fault instead of classifying it, so a lookup for something that does not exist answered `500 InternalError` where the rest of the admin API answers `404 NoSuchResource` — `GET /rustfs/admin/v3/user-info` already did, and `iam_error_to_s3_error` already mapped `NoSuchPolicy`, `NoSuchUser`, `NoSuchServiceAccount` and `NoSuchTempAccount` to `NoSuchResource`. A client could not tell "does not exist" from a real server failure, and a controller that checks existence before creating (a Kubernetes operator reconciling policies or users) could never create the object: the missing-policy lookup failed with a `500` and it retried forever. Both handlers now map their IAM errors through the shared `iam_error_to_s3_error` helper, so a missing policy or user is `404 NoSuchResource` and the `error` is preserved as the S3 error's source; the three IAM calls in the removal path (temporary-user lookup, service-account lookup, delete) are all reclassified, and every other IAM error stays `500 InternalError`. Fixes #8117.
|
||||
@@ -85,6 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
|
||||
|
||||
### Changed
|
||||
- **Scanner bucket walkers are no longer tied to the disk count**: a new scanner key `walkers_per_disk` (`RUSTFS_SCANNER_WALKERS_PER_DISK`, `1` through `16`, default `1`) spawns that many bucket walkers per local disk of an erasure set, so a single-disk deployment can walk several buckets at once instead of one bucket, one object at a time. The walkers of one disk share its scanner admission, so a peer still sees one scan session per physical disk, and remote disks keep one walker. The walker count is what `max_concurrent_disk_scans` caps and the disk count stays the floor; the `disk_concurrency_budget` log line now reports `walkers_per_disk`, `walkers`, and the `reason` for the effective limit. The default keeps today's I/O profile because a local disk cannot yet report whether it is rotational; the design intends a higher default on flash once it can (`docs/architecture/scanner-per-bucket-convergence.md`). As groundwork for pacing, the raw-enumeration page writer inserts into its building page in place and commits pages without copying them, and a regression test pins the cost of recording 20,000 entries in one prefix so the quadratic bookkeeping removed by [#8114](https://github.com/rustfs/rustfs/pull/8114) (rustfs#8113) cannot return. No persisted format, protocol or admin API changes beyond the additional config key, which older releases preserve and ignore.
|
||||
- **Encryption and KMS work merged since `1.0.0-rc.5`** (entries were missing from this section):
|
||||
- **Persisted KMS configuration secrets** are sealed field-by-field with `RUSTFS_KMS_CONFIG_SECRET`. **When the variable is unset the secrets are persisted in cleartext and the server only warns** (`persisted KMS configuration carries cleartext secrets`); it never refuses the write. Set it, identically, on every node, and re-save the configuration to seal an existing one.
|
||||
- **New v2 ciphertext frame format** with per-frame index binding and final-frame authentication. Its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is **off by default**: v2 frames are unreadable by nodes without v2 read support, and encrypted ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so turn it on only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs a release with v2 read support. Reading v2 objects needs no switch.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# RustFS — CLAUDE.md
|
||||
|
||||
S3-compatible object store in Rust, derived from MinIO. Erasure-coded,
|
||||
multi-pool, supports ILM tiering/lifecycle.
|
||||
|
||||
Agent rules (change style, verification, PR conventions, scoped per-crate
|
||||
guidance) live in [AGENTS.md](AGENTS.md) — follow it. This file only adds
|
||||
what Claude Code needs on top: commands and pointers.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cargo build --release --bin rustfs # production binary
|
||||
cargo check -p <crate> # fast type-check one crate
|
||||
cargo test -p <crate> # test one crate
|
||||
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
|
||||
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
|
||||
make pre-pr # optional full gate for broad cross-module changes
|
||||
make build-docker BUILD_OS=ubuntu22.04
|
||||
```
|
||||
|
||||
> **Docker build note**: `buildx build` without `--load` keeps the image in
|
||||
> the buildx cache only — `docker run` will use a stale local image. The
|
||||
> Makefile already includes `--load`; if you suspect a stale binary, add
|
||||
> `--no-cache` to the `buildx build` invocation inside
|
||||
> `.config/make/build-docker.mak`.
|
||||
|
||||
## Where to look (do not duplicate here)
|
||||
|
||||
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
|
||||
- Crate membership: `Cargo.toml` `[workspace].members`
|
||||
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
|
||||
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
|
||||
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
|
||||
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
|
||||
policy: [docs/testing/README.md](docs/testing/README.md)
|
||||
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
|
||||
[docs/operations/tier-ilm-debugging.md](docs/operations/tier-ilm-debugging.md)
|
||||
|
||||
## Domain conventions worth knowing up front
|
||||
|
||||
Repo-wide domain invariants (dual internal metadata keys, defensive UUID
|
||||
reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under
|
||||
"Cross-Cutting Storage Invariants" — read them before touching metadata or
|
||||
tiering code.
|
||||
Generated
+726
-1086
File diff suppressed because it is too large
Load Diff
+116
-103
@@ -18,7 +18,6 @@ members = [
|
||||
"crates/audit", # Audit target management system with multi-target fan-out
|
||||
"crates/checksums", # client checksums
|
||||
"crates/common", # Shared utilities and data structures
|
||||
"crates/capture-authority", # Durable capture authority storage
|
||||
"crates/data-usage", # Shared data usage models and algorithms
|
||||
"crates/config", # Configuration management
|
||||
"crates/connect-symbol-catalog", # Release-only CPU profile symbol catalog generator
|
||||
@@ -75,7 +74,7 @@ edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.99.0"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
@@ -92,63 +91,62 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.1" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.1" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.1" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.1" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.1" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.1" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.1" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.1" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.1" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.1" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.1" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.1" }
|
||||
rustfs-capture-authority = { path = "crates/capture-authority", version = "1.0.1" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.1" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.1" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.1" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.1" }
|
||||
rustfs-license = { path = "crates/license", version = "1.0.1" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.1" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.1" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.1" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.1" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.1" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.1" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.1" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.1" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.1", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.1" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.1" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.1" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.1" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.1" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.1" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.1" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.1" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.1" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.1" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.1" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.1" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.1" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.1" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.1" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.1" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.1" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.1" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.1" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.1" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.1" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.1" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.1" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.1" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.2" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.2" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.2" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.2" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.2" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.2" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.2" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.2" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.2" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.2" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.2" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.2" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.2" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.2" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.2" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.2" }
|
||||
rustfs-license = { path = "crates/license", version = "1.0.2" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.2" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.2" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.2" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.2" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.2" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.2" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.2" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.2" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.2", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.2" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.2" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.2" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.2" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.2" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.2" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.2" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.2" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.2" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.2" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.2" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.2" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.2" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.2" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.2" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.2" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.2" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.2" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.2" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.2" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.2" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.2" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.2" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.2" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
async_zip = { default-features = false, version = "0.0.19" }
|
||||
mysql_async = { default-features = false, version = "0.37.1" }
|
||||
async-compression = { version = "0.4.50" }
|
||||
async-compression = { version = "0.4.51" }
|
||||
async-trait = "0.1.92"
|
||||
async-nats = { version = "0.50.0", default-features = false }
|
||||
axum = "0.8.9"
|
||||
@@ -157,9 +155,10 @@ futures-core = "0.3.34"
|
||||
futures-lite = "2.6.1"
|
||||
futures-util = "0.3.34"
|
||||
pollster = "1.0.1"
|
||||
pulsar = { default-features = false, version = "6.9.0" }
|
||||
lapin = { default-features = false, version = "4.12.0" }
|
||||
pulsar = { default-features = false, version = "=6.9.0" }
|
||||
lapin = { default-features = false, version = "4.12.3" }
|
||||
hyper = { version = "1.12.0" }
|
||||
h2 = { version = "0.4.20" }
|
||||
hyper-rustls = { default-features = false, version = "0.27.10" }
|
||||
hyper-util = { version = "0.1.21" }
|
||||
http = "1.5.0"
|
||||
@@ -170,11 +169,11 @@ minlz = "1.2.3"
|
||||
reqwest = "0.13.5"
|
||||
rustfs-kafka-async = { version = "1.4.0" }
|
||||
socket2 = { version = "0.6.5" }
|
||||
tokio = { version = "1.53.2" }
|
||||
tokio = { version = "1.54.1" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.6" }
|
||||
tokio-stream = { version = "0.1.19" }
|
||||
tokio-test = "0.4.6"
|
||||
tokio-util = { version = "0.7.19" }
|
||||
tokio-util = { version = "0.7.20" }
|
||||
tonic = { version = "0.14.6" }
|
||||
tonic-prost = { version = "0.14.6" }
|
||||
tonic-prost-build = { version = "0.14.6" }
|
||||
@@ -182,7 +181,7 @@ tower = { version = "0.5.3" }
|
||||
tower-http = { version = "0.7.1" }
|
||||
h3 = "0.0.8"
|
||||
h3-quinn = "0.0.10"
|
||||
quinn = { version = "0.11.11", default-features = false, features = [
|
||||
quinn = { version = "0.11.12", default-features = false, features = [
|
||||
"futures-io",
|
||||
"runtime-tokio",
|
||||
"rustls-aws-lc-rs",
|
||||
@@ -202,7 +201,7 @@ rmp-serde = { version = "1.3.1" }
|
||||
serde = { version = "1.0.229" }
|
||||
serde_ignored = { version = "0.1" }
|
||||
serde_with = { version = "3", default-features = false, features = ["macros", "std"] }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_json = { version = "1.0.154" }
|
||||
serde_urlencoded = "0.7.1"
|
||||
|
||||
# Cryptography and Security
|
||||
@@ -221,12 +220,12 @@ openidconnect = { default-features = false, version = "4.0" }
|
||||
pbkdf2 = "0.13.0"
|
||||
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
|
||||
rsa = { version = "=0.10.0-rc.18" }
|
||||
rustls = { default-features = false, version = "0.23.45" }
|
||||
rustls = { default-features = false, version = "0.23.46" }
|
||||
rustls-native-certs = "0.8"
|
||||
rustls-pki-types = "1.15.1"
|
||||
x509-parser = "0.18.1"
|
||||
sha1 = "0.11.0"
|
||||
sha2 = "0.11.0"
|
||||
sha2 = "0.11.1"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9.1" }
|
||||
proptest = "1"
|
||||
@@ -244,22 +243,22 @@ tokio-postgres-rustls = "0.14.0"
|
||||
|
||||
# Utilities and Tools
|
||||
anyhow = "1.0.104"
|
||||
arc-swap = "1.9.2"
|
||||
arc-swap = "1.9.3"
|
||||
rustfs-tokio-tar = { version = "0.7.1" }
|
||||
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
|
||||
tar-codec = "0.0.14"
|
||||
tar-framing = "0.0.14"
|
||||
atoi = "3.1.0"
|
||||
tar-codec = "0.0.15"
|
||||
tar-framing = "0.0.15"
|
||||
atoi = "3.1.1"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.12.0" }
|
||||
aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.123.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.152.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.119.0" }
|
||||
aws-smithy-async = { version = "1.3.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.5.0" }
|
||||
aws-smithy-runtime-api = { version = "1.19.0" }
|
||||
aws-smithy-types = { version = "1.8.1" }
|
||||
aws-config = { version = "1.13.0" }
|
||||
aws-credential-types = { version = "1.3.1" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.126.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.155.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.122.0" }
|
||||
aws-smithy-async = { version = "1.3.1" }
|
||||
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.0.0"
|
||||
clap = { version = "4.6.7" }
|
||||
@@ -270,15 +269,15 @@ crossbeam-queue = "0.3.14"
|
||||
crossbeam-channel = "0.5.17"
|
||||
crossbeam-deque = "0.8.8"
|
||||
crossbeam-utils = "0.8.23"
|
||||
datafusion = { default-features = false, version = "55.1.0" }
|
||||
datafusion = { default-features = false, version = "55.2.0" }
|
||||
derive_builder = "0.20.2"
|
||||
ed25519-dalek = "3.0.0"
|
||||
enumset = "1.1.14"
|
||||
enumset = "1.1.15"
|
||||
faster-hex = "1.0.0"
|
||||
flate2 = "1.1.10"
|
||||
glob = "0.3.4"
|
||||
google-cloud-storage = "1.20.0"
|
||||
google-cloud-auth = "1.17.0"
|
||||
google-cloud-storage = "1.21.0"
|
||||
google-cloud-auth = "1.18.0"
|
||||
hashbrown = { version = "0.17.1" }
|
||||
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
|
||||
# every authenticator app expects). Already in the graph transitively.
|
||||
@@ -288,11 +287,11 @@ highway = { version = "1.3.0" }
|
||||
hostname = "0.4.2"
|
||||
ipnetwork = { version = "0.21.1" }
|
||||
lazy_static = "1.5.1"
|
||||
libc = "0.2.190"
|
||||
libc = "0.2.191"
|
||||
libsystemd = "0.7.2"
|
||||
local-ip-address = "0.6.13"
|
||||
log = "0.4"
|
||||
memmap2 = "0.9.11"
|
||||
memmap2 = "0.9.12"
|
||||
lz4 = "1.28.1"
|
||||
matchit = "0.9.2"
|
||||
md-5 = "0.11.0"
|
||||
@@ -302,7 +301,7 @@ netif = "0.1.6"
|
||||
num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.13.0"
|
||||
object = { version = "=0.40.0", default-features = false }
|
||||
parking_lot = "0.12.5"
|
||||
parking_lot = "0.13.0"
|
||||
path-absolutize = "4.0.1"
|
||||
percent-encoding = "2.3.2"
|
||||
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||
@@ -316,21 +315,19 @@ ratelimit = "2.0.0"
|
||||
rayon = "1.12.0"
|
||||
rustfs-erasure-codec = { version = "9.0.0" }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
openraft = { version = "=0.9.25", default-features = false, features = ["serde"] }
|
||||
redb = "=4.3.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||
redis = { version = "1.7.1" }
|
||||
redis = { version = "1.8.0" }
|
||||
rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.5" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rust-embed = { version = "8.13.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
|
||||
s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0" }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.4"
|
||||
smallvec = { version = "1.16.2" }
|
||||
smallvec = { version = "1.16.4" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.4.0" }
|
||||
@@ -344,12 +341,12 @@ tracing = { version = "0.1.44" }
|
||||
tracing-appender = "0.2.5"
|
||||
tracing-core = "0.1.36"
|
||||
tracing-error = "0.2.1"
|
||||
tracing-opentelemetry = { version = "0.34" }
|
||||
tracing-opentelemetry = { version = "0.34.0" }
|
||||
tracing-subscriber = { version = "0.3.23" }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.27.0" }
|
||||
uuid = { version = "1.29.0" }
|
||||
vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
@@ -357,26 +354,26 @@ winapi-util = "0.1.11"
|
||||
windows = { version = "0.62.2" }
|
||||
windows-sys = "0.61.2"
|
||||
xxhash-rust = { version = "0.8.19" }
|
||||
zip = "8.6.0"
|
||||
zstd = "0.14.0"
|
||||
zip = "9.1.0"
|
||||
zstd = "0.14.3"
|
||||
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.6"
|
||||
metrics-util = "0.20"
|
||||
dial9 = { version = "0.5.3", default-features = false }
|
||||
opentelemetry = { version = "0.33.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.33.0" }
|
||||
opentelemetry-otlp = { version = "0.33.0" }
|
||||
opentelemetry-proto = { version = "0.33.0", default-features = false, features = ["metrics", "gen-tonic-messages"] }
|
||||
opentelemetry_sdk = { version = "0.33.0" }
|
||||
opentelemetry-semantic-conventions = { version = "0.33.0" }
|
||||
opentelemetry-stdout = { version = "0.33.0" }
|
||||
pyroscope = { version = "2.1.1" }
|
||||
dial9 = { version = "0.5.5", default-features = false }
|
||||
opentelemetry = { version = "0.33.1" }
|
||||
opentelemetry-appender-tracing = { version = "0.33.1" }
|
||||
opentelemetry-otlp = { version = "0.33.1" }
|
||||
opentelemetry-proto = { version = "0.33.1", default-features = false, features = ["metrics", "gen-tonic-messages"] }
|
||||
opentelemetry_sdk = { version = "0.33.1" }
|
||||
opentelemetry-semantic-conventions = { version = "0.33.1" }
|
||||
opentelemetry-stdout = { version = "0.33.1" }
|
||||
pyroscope = { version = "2.1.5" }
|
||||
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.23.1" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "12.1.1" }
|
||||
suppaftp = { version = "12.2.1" }
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.64.1" }
|
||||
russh-sftp = "3.0.1"
|
||||
@@ -385,9 +382,9 @@ russh-sftp = "3.0.1"
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.5.6" }
|
||||
rustfs-mimalloc = { version = "0.7.2" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "0.28.5", default-features = false }
|
||||
hotpath = { version = "0.29.1", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.49" }
|
||||
|
||||
@@ -424,3 +421,19 @@ inherits = "release"
|
||||
inherits = "release"
|
||||
debug = true
|
||||
strip = "none"
|
||||
|
||||
# Pin upstream fixes until compatible crates.io releases are available.
|
||||
[patch.crates-io]
|
||||
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
|
||||
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
|
||||
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
|
||||
oauth2 = { git = "https://github.com/ramosbugs/oauth2-rs", rev = "89585f4a280afd7b285eaf1c9be0068874e03d22" }
|
||||
aws-sigv4 = { git = "https://github.com/smithy-lang/smithy-rs", rev = "5c1c4bca2ea7af97496e077c9f1a718eaac64bf8" }
|
||||
|
||||
# Keep the signer's path dependencies on the SDK's existing registry types.
|
||||
[patch."https://github.com/smithy-lang/smithy-rs"]
|
||||
aws-credential-types = { version = "=1.3.1" }
|
||||
aws-smithy-eventstream = { version = "=0.61.6" }
|
||||
aws-smithy-http = { version = "=0.64.2" }
|
||||
aws-smithy-runtime-api = { version = "=1.19.1" }
|
||||
aws-smithy-types = { version = "=1.9.0" }
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
FROM alpine:3.24.1 AS build
|
||||
FROM alpine:3.24.2 AS build
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG RELEASE=latest
|
||||
@@ -70,7 +70,7 @@ RUN set -eux; \
|
||||
rm -rf rustfs.zip /build/.tmp || true
|
||||
|
||||
|
||||
FROM alpine:3.24.1
|
||||
FROM alpine:3.24.2
|
||||
|
||||
ARG RELEASE=latest
|
||||
ARG BUILD_DATE
|
||||
|
||||
@@ -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.1
|
||||
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.1 --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.1 # Build specific version
|
||||
make docker-buildx-version VERSION=1.0.3 # Build specific version
|
||||
make help-docker # Show all Docker-related commands
|
||||
```
|
||||
|
||||
|
||||
+3
-3
@@ -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.1
|
||||
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.1 --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.1 # 构建指定版本
|
||||
make docker-buildx-version VERSION=1.0.3 # 构建指定版本
|
||||
make help-docker # 显示所有 Docker 相关命令
|
||||
```
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ unparseable = "unparseable"
|
||||
# Disaster-recovery objectives: recovery time and recovery point.
|
||||
RTO = "RTO"
|
||||
rto = "rto"
|
||||
# rustix OFlags variant (POSIX O_WRONLY equivalent).
|
||||
WRONLY = "WRONLY"
|
||||
|
||||
[files]
|
||||
extend-exclude = []
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
[package]
|
||||
name = "rustfs-capture-authority"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Durable local storage for capture authority"
|
||||
|
||||
[dependencies]
|
||||
openraft = { workspace = true, features = ["storage-v2"] }
|
||||
redb = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net", "io-util", "time", "sync", "rt"] }
|
||||
url = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
|
||||
rustix = { workspace = true, features = ["process"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,531 +0,0 @@
|
||||
use super::*;
|
||||
use crate::application::{APPLY_RESULTS, EVENTS, OPERATIONS, VERSIONS};
|
||||
use crate::tests::CrashBackend;
|
||||
use redb::ReadableTableMetadata;
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
process::{Command, Stdio},
|
||||
sync::mpsc,
|
||||
time::Duration,
|
||||
};
|
||||
fn identity() -> NodeIdentity {
|
||||
NodeIdentity {
|
||||
cluster_id: [7; 16],
|
||||
node_id: 0,
|
||||
}
|
||||
}
|
||||
fn binding() -> CaptureBinding {
|
||||
CaptureBinding {
|
||||
revision: 9,
|
||||
binding_id: [8; 16],
|
||||
target_id: [9; 16],
|
||||
}
|
||||
}
|
||||
fn command() -> DecideCreated {
|
||||
DecideCreated {
|
||||
operation_id: [1; 16],
|
||||
object: ObjectIdentity {
|
||||
bucket_incarnation: [2; 16],
|
||||
key: vec![0, 255, 3],
|
||||
},
|
||||
expected_head: None,
|
||||
prepared: PreparedIdentity {
|
||||
version_id: [3; 16],
|
||||
preparation_id: [4; 16],
|
||||
content_digest: [5; 32],
|
||||
content_length: 123,
|
||||
},
|
||||
binding_revision: 9,
|
||||
}
|
||||
}
|
||||
fn id(index: u64) -> LogId {
|
||||
LogId {
|
||||
term: 8,
|
||||
leader_node: 0,
|
||||
index,
|
||||
}
|
||||
}
|
||||
fn append(store: &mut Store, index: u64, c: &DecideCreated) {
|
||||
store
|
||||
.append(&[LogEntry {
|
||||
id: id(index),
|
||||
payload: c.encode().expect("encode command"),
|
||||
}])
|
||||
.expect("append command");
|
||||
}
|
||||
fn apply(store: &mut Store, index: u64, c: &DecideCreated) -> CreatedResult {
|
||||
append(store, index, c);
|
||||
store.save_committed(id(index)).expect("commit log");
|
||||
store.apply_next_created(id(index)).expect("apply command")
|
||||
}
|
||||
fn success(c: &DecideCreated, index: u64) -> CreatedResult {
|
||||
CreatedResult::Created {
|
||||
version_id: c.prepared.version_id,
|
||||
event_id: c.operation_id,
|
||||
decision_log_id: id(index),
|
||||
}
|
||||
}
|
||||
fn complete(store: &Store) {
|
||||
let c = command();
|
||||
assert_eq!(store.capture_binding().expect("binding"), Some(binding()));
|
||||
assert_eq!(
|
||||
store.created_head(&c.object).expect("head"),
|
||||
Some(c.prepared.version_id),
|
||||
"U11 acknowledged head lost"
|
||||
);
|
||||
assert_eq!(
|
||||
store.created_version(&c.object, c.prepared.version_id).expect("version"),
|
||||
Some(CreatedVersion {
|
||||
prepared: c.prepared.clone(),
|
||||
decision_log_id: id(0),
|
||||
binding_revision: 9
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
store.created_event(c.operation_id).expect("event"),
|
||||
Some(CreatedEvent {
|
||||
object: c.object.clone(),
|
||||
prepared: c.prepared.clone(),
|
||||
decision_log_id: id(0),
|
||||
binding: binding()
|
||||
}),
|
||||
"U11 complete event missing"
|
||||
);
|
||||
assert_eq!(
|
||||
store.created_operation(c.operation_id).expect("operation"),
|
||||
Some(CreatedOperation {
|
||||
canonical_command: c.encode().expect("encode"),
|
||||
result: success(&c, 0)
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
store.created_apply_result(0).expect("result"),
|
||||
Some(CreatedApplyResult {
|
||||
log_id: id(0),
|
||||
result: success(&c, 0)
|
||||
})
|
||||
);
|
||||
assert_eq!(store.last_applied().expect("last applied"), Some(id(0)));
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, Store) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut store = Store::initialize(dir.path().join("db"), identity()).expect("initialize");
|
||||
store.pin_capture_binding(binding()).expect("pin");
|
||||
(dir, store)
|
||||
}
|
||||
fn reopen(path: &std::path::Path) -> Store {
|
||||
let result = Store::open(path, identity());
|
||||
assert!(result.is_ok(), "U11 complete reopen: {:?}", result.as_ref().err());
|
||||
result.expect("asserted successful reopen")
|
||||
}
|
||||
#[test]
|
||||
fn created_codec_is_canonical_and_strict() {
|
||||
let c = command();
|
||||
let b = c.encode().expect("encode");
|
||||
let mut golden = b"CRTD\x01".to_vec();
|
||||
golden.extend([1; 16]);
|
||||
golden.extend([2; 16]);
|
||||
golden.extend(3u32.to_be_bytes());
|
||||
golden.extend([0, 255, 3]);
|
||||
golden.push(0);
|
||||
golden.extend([3; 16]);
|
||||
golden.extend([4; 16]);
|
||||
golden.extend([5; 32]);
|
||||
golden.extend(123u64.to_be_bytes());
|
||||
golden.extend(9u64.to_be_bytes());
|
||||
assert_eq!(b, golden);
|
||||
assert_eq!(DecideCreated::decode(&b).expect("decode"), c);
|
||||
let mut variants = Vec::new();
|
||||
let mut v = c.clone();
|
||||
v.operation_id = [6; 16];
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.object.bucket_incarnation = [6; 16];
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.object.key.push(4);
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.expected_head = Some([6; 16]);
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.prepared.version_id = [6; 16];
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.prepared.preparation_id = [6; 16];
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.prepared.content_digest = [6; 32];
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.prepared.content_length += 1;
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.binding_revision += 1;
|
||||
variants.push(v);
|
||||
for v in variants {
|
||||
assert_ne!(v.encode().expect("variant"), b)
|
||||
}
|
||||
for n in 0..b.len() {
|
||||
assert!(DecideCreated::decode(&b[..n]).is_err())
|
||||
}
|
||||
let mut bad = b.clone();
|
||||
bad[4] = 2;
|
||||
assert!(DecideCreated::decode(&bad).is_err());
|
||||
let mut bad = b.clone();
|
||||
bad[44] = 2;
|
||||
assert!(DecideCreated::decode(&bad).is_err());
|
||||
let mut bad = b.clone();
|
||||
bad.push(0);
|
||||
assert!(DecideCreated::decode(&bad).is_err());
|
||||
let mut bad = b;
|
||||
bad[37..41].copy_from_slice(&4097u32.to_be_bytes());
|
||||
assert!(DecideCreated::decode(&bad).is_err());
|
||||
let mut bad = c.clone();
|
||||
bad.operation_id = [0; 16];
|
||||
assert!(bad.encode().is_err());
|
||||
bad = c;
|
||||
bad.object.key = vec![0; MAX_OBJECT_KEY_LENGTH + 1];
|
||||
assert!(bad.encode().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn created_committed_order_and_identity_are_enforced() {
|
||||
let (_dir, mut s) = fixture();
|
||||
let c = command();
|
||||
append(&mut s, 0, &c);
|
||||
append(&mut s, 1, &c);
|
||||
assert!(s.apply_next_created(id(0)).is_err());
|
||||
assert_eq!(s.last_applied().expect("no apply"), None);
|
||||
s.save_committed(id(1)).expect("committed");
|
||||
assert!(matches!(s.apply_next_created(id(1)), Err(StoreError::InvalidApplySequence)));
|
||||
let mut wrong = id(0);
|
||||
wrong.term += 1;
|
||||
assert!(s.apply_next_created(wrong).is_err());
|
||||
wrong = id(0);
|
||||
wrong.leader_node += 1;
|
||||
assert!(s.apply_next_created(wrong).is_err());
|
||||
assert_eq!(s.apply_next_created(id(0)).expect("first"), success(&c, 0));
|
||||
complete(&s);
|
||||
assert_eq!(s.apply_next_created(id(0)).expect("retry"), success(&c, 0));
|
||||
complete(&s);
|
||||
assert!(s.apply_next_created(wrong).is_err());
|
||||
assert_eq!(s.apply_next_created(id(1)).expect("duplicate log"), success(&c, 0));
|
||||
assert_eq!(s.last_applied().expect("last"), Some(id(1)));
|
||||
assert_eq!(
|
||||
s.created_apply_result(1).expect("result"),
|
||||
Some(CreatedApplyResult {
|
||||
log_id: id(1),
|
||||
result: success(&c, 0)
|
||||
})
|
||||
);
|
||||
let tx = s.database.begin_read().expect("read");
|
||||
assert_eq!(tx.open_table(EVENTS).expect("events").len().expect("len"), 1);
|
||||
}
|
||||
#[test]
|
||||
fn created_head_version_event_and_dedup_reopen_together() {
|
||||
let (dir, mut s) = fixture();
|
||||
let c = command();
|
||||
assert_eq!(apply(&mut s, 0, &c), success(&c, 0));
|
||||
drop(s);
|
||||
let mut s = reopen(&dir.path().join("db"));
|
||||
complete(&s);
|
||||
assert_eq!(apply(&mut s, 1, &c), success(&c, 0));
|
||||
let mut changed = c.clone();
|
||||
changed.prepared.content_digest = [6; 32];
|
||||
assert_eq!(
|
||||
apply(&mut s, 2, &changed),
|
||||
CreatedResult::OperationConflict,
|
||||
"U11 full canonical operation conflict"
|
||||
);
|
||||
assert_eq!(
|
||||
s.created_operation(c.operation_id)
|
||||
.expect("original")
|
||||
.expect("operation")
|
||||
.result,
|
||||
success(&c, 0)
|
||||
);
|
||||
let mut stale = c.clone();
|
||||
stale.operation_id = [6; 16];
|
||||
stale.prepared.version_id = [6; 16];
|
||||
assert_eq!(
|
||||
apply(&mut s, 3, &stale),
|
||||
CreatedResult::HeadMismatch {
|
||||
actual_head: Some(c.prepared.version_id)
|
||||
}
|
||||
);
|
||||
let mut reuse = c.clone();
|
||||
reuse.operation_id = [7; 16];
|
||||
reuse.expected_head = Some(c.prepared.version_id);
|
||||
assert_eq!(apply(&mut s, 4, &reuse), CreatedResult::VersionConflict);
|
||||
let mut replacement = stale.clone();
|
||||
replacement.operation_id = [10; 16];
|
||||
replacement.expected_head = Some(c.prepared.version_id);
|
||||
assert_eq!(apply(&mut s, 5, &replacement), success(&replacement, 5));
|
||||
assert_eq!(s.created_head(&c.object).expect("head"), Some(replacement.prepared.version_id));
|
||||
assert_eq!(
|
||||
s.created_version(&c.object, c.prepared.version_id)
|
||||
.expect("old version")
|
||||
.expect("version")
|
||||
.decision_log_id,
|
||||
id(0)
|
||||
);
|
||||
assert!(s.created_event(c.operation_id).expect("old event").is_some());
|
||||
assert!(s.created_event(replacement.operation_id).expect("new event").is_some());
|
||||
assert!(s.created_event(stale.operation_id).expect("rejection event").is_none());
|
||||
assert_eq!(
|
||||
apply(&mut s, 6, &stale),
|
||||
CreatedResult::HeadMismatch {
|
||||
actual_head: Some(c.prepared.version_id)
|
||||
}
|
||||
);
|
||||
drop(s);
|
||||
let s = reopen(&dir.path().join("db"));
|
||||
assert_eq!(s.last_applied().expect("last"), Some(id(6)));
|
||||
let tx = s.database.begin_read().expect("read");
|
||||
assert_eq!(tx.open_table(EVENTS).expect("events").len().expect("len"), 2);
|
||||
}
|
||||
#[test]
|
||||
fn created_binding_is_pinned_and_not_caller_selected() {
|
||||
let (dir, mut s) = fixture();
|
||||
let mut bad = command();
|
||||
bad.binding_revision = 10;
|
||||
assert_eq!(apply(&mut s, 0, &bad), CreatedResult::BindingMismatch);
|
||||
assert!(s.created_head(&bad.object).expect("head").is_none());
|
||||
assert!(s.created_event(bad.operation_id).expect("event").is_none());
|
||||
assert_eq!(
|
||||
s.created_operation(bad.operation_id)
|
||||
.expect("operation")
|
||||
.expect("saved")
|
||||
.result,
|
||||
CreatedResult::BindingMismatch
|
||||
);
|
||||
s.pin_capture_binding(binding()).expect("idempotent pin");
|
||||
let mut changed = binding();
|
||||
changed.target_id = [10; 16];
|
||||
assert!(matches!(s.pin_capture_binding(changed), Err(StoreError::BindingConflict)));
|
||||
drop(s);
|
||||
let s = reopen(&dir.path().join("db"));
|
||||
assert_eq!(s.capture_binding().expect("binding"), Some(binding()));
|
||||
}
|
||||
#[test]
|
||||
fn created_schema_compatibility_and_corruption_are_checked() {
|
||||
let dir = tempfile::tempdir().expect("dir");
|
||||
let path = dir.path().join("db");
|
||||
let s = Store::initialize(&path, identity()).expect("schema1");
|
||||
assert_eq!(s.capture_binding().expect("unmigrated"), None);
|
||||
drop(s);
|
||||
let mut s = reopen(&path);
|
||||
assert_eq!(s.last_applied().expect("no apply"), None);
|
||||
append(&mut s, 0, &command());
|
||||
s.save_committed(id(0)).expect("schema2");
|
||||
drop(s);
|
||||
let mut s = reopen(&path);
|
||||
assert_eq!(s.capture_binding().expect("schema2 unchanged"), None);
|
||||
s.pin_capture_binding(binding()).expect("explicit upgrade");
|
||||
drop(s);
|
||||
let mut s = reopen(&path);
|
||||
assert_eq!(s.capture_binding().expect("schema3"), Some(binding()));
|
||||
s.apply_next_created(id(0)).expect("apply");
|
||||
drop(s);
|
||||
let db = Database::open(&path).expect("raw db");
|
||||
let tx = db.begin_write().expect("write corruption");
|
||||
tx.open_table(EVENTS)
|
||||
.expect("events")
|
||||
.remove(command().operation_id.as_slice())
|
||||
.expect("remove reference");
|
||||
tx.commit().expect("commit corruption");
|
||||
drop(db);
|
||||
let result = Store::open(&path, identity());
|
||||
assert!(
|
||||
matches!(result, Err(StoreError::CorruptRecord)),
|
||||
"U11 missing event rejected before expect"
|
||||
);
|
||||
let (dir, mut s) = fixture();
|
||||
assert_eq!(s.read_committed().expect("empty committed"), None);
|
||||
append(&mut s, 0, &command());
|
||||
s.save_committed(id(0)).expect("committed schema3");
|
||||
assert_eq!(s.capture_binding().expect("no downgrade"), Some(binding()));
|
||||
drop(s);
|
||||
let path = dir.path().join("db");
|
||||
let db = Database::open(&path).expect("raw db");
|
||||
let tx = db.begin_write().expect("write");
|
||||
let mut marker = vec![1];
|
||||
marker.extend(encode_id(id(0)));
|
||||
tx.open_table(META)
|
||||
.expect("meta")
|
||||
.insert("last_applied", marker.as_slice())
|
||||
.expect("corrupt applied");
|
||||
tx.commit().expect("commit");
|
||||
drop(db);
|
||||
assert!(
|
||||
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
|
||||
"U11 last applied missing result rejected"
|
||||
);
|
||||
let (dir, s) = fixture();
|
||||
drop(s);
|
||||
let path = dir.path().join("db");
|
||||
let db = Database::open(&path).expect("raw pinned db");
|
||||
let tx = db.begin_write().expect("missing binding write");
|
||||
tx.open_table(META)
|
||||
.expect("meta")
|
||||
.remove("capture_binding")
|
||||
.expect("remove binding");
|
||||
tx.commit().expect("binding corruption commit");
|
||||
drop(db);
|
||||
assert!(
|
||||
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
|
||||
"U11 missing pinned binding rejected"
|
||||
);
|
||||
|
||||
let (dir, mut s) = fixture();
|
||||
let c = command();
|
||||
assert_eq!(apply(&mut s, 0, &c), success(&c, 0));
|
||||
assert_eq!(apply(&mut s, 1, &c), success(&c, 0));
|
||||
drop(s);
|
||||
let path = dir.path().join("db");
|
||||
let db = Database::open(&path).expect("raw duplicate db");
|
||||
let tx = db.begin_write().expect("retarget decision write");
|
||||
let mut key = created::object_bytes(&c.object).expect("object key");
|
||||
key.extend(c.prepared.version_id);
|
||||
let mut version = Vec::new();
|
||||
created::put_prepared(&mut version, &c.prepared);
|
||||
version.extend(encode_id(id(1)));
|
||||
version.extend(binding().revision.to_be_bytes());
|
||||
tx.open_table(VERSIONS)
|
||||
.expect("versions")
|
||||
.insert(key.as_slice(), version.as_slice())
|
||||
.expect("retarget version");
|
||||
let mut event = created::object_bytes(&c.object).expect("event object");
|
||||
created::put_prepared(&mut event, &c.prepared);
|
||||
event.extend(encode_id(id(1)));
|
||||
event.extend(created::binding_bytes(&binding()).expect("binding bytes"));
|
||||
tx.open_table(EVENTS)
|
||||
.expect("events")
|
||||
.insert(c.operation_id.as_slice(), event.as_slice())
|
||||
.expect("retarget event");
|
||||
let canonical = c.encode().expect("canonical");
|
||||
let mut operation = u64::try_from(canonical.len()).expect("length").to_be_bytes().to_vec();
|
||||
operation.extend(canonical);
|
||||
operation.extend(created::result_bytes(&success(&c, 1)));
|
||||
tx.open_table(OPERATIONS)
|
||||
.expect("operations")
|
||||
.insert(c.operation_id.as_slice(), operation.as_slice())
|
||||
.expect("retarget original response");
|
||||
for index in 0..=1 {
|
||||
let mut result = encode_id(id(index));
|
||||
result.extend(created::result_bytes(&success(&c, 1)));
|
||||
tx.open_table(APPLY_RESULTS)
|
||||
.expect("results")
|
||||
.insert(index, result.as_slice())
|
||||
.expect("retarget duplicate response");
|
||||
}
|
||||
tx.commit().expect("coherent retarget commit");
|
||||
drop(db);
|
||||
assert!(
|
||||
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
|
||||
"U11 original decision cannot retarget duplicate log"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn created_process_child() {
|
||||
if let Some(path) = std::env::var_os("U11_CHILD_DB") {
|
||||
let mut s = Store::initialize(path, identity()).expect("child init");
|
||||
s.pin_capture_binding(binding()).expect("child pin");
|
||||
assert_eq!(apply(&mut s, 0, &command()), success(&command(), 0));
|
||||
complete(&s);
|
||||
println!("U11_ACK");
|
||||
std::io::stdout().flush().expect("flush ACK");
|
||||
loop {
|
||||
std::thread::park()
|
||||
}
|
||||
}
|
||||
let (_dir, mut s) = fixture();
|
||||
assert_eq!(apply(&mut s, 0, &command()), success(&command(), 0));
|
||||
complete(&s);
|
||||
}
|
||||
#[test]
|
||||
fn created_acknowledged_apply_survives_child_kill() {
|
||||
let dir = tempfile::tempdir().expect("dir");
|
||||
let path = dir.path().join("db");
|
||||
let mut child = Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args(["--exact", "application_tests::created_process_child", "--nocapture"])
|
||||
.env("U11_CHILD_DB", &path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("spawn");
|
||||
let stdout = child.stdout.take().expect("stdout");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
let line = line.expect("child line");
|
||||
if line.ends_with("U11_ACK") {
|
||||
let _ = tx.send(());
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let acknowledged = rx.recv_timeout(Duration::from_secs(20));
|
||||
child.kill().expect("kill child");
|
||||
child.wait().expect("wait child");
|
||||
reader.join().expect("reader cleanup");
|
||||
assert!(acknowledged.is_ok(), "U11 bounded child ACK missing");
|
||||
complete(&reopen(&path));
|
||||
}
|
||||
#[test]
|
||||
fn created_power_cut_before_and_after_apply_flush() {
|
||||
let (dir, mut s) = fixture();
|
||||
append(&mut s, 0, &command());
|
||||
s.save_committed(id(0)).expect("committed");
|
||||
drop(s);
|
||||
let path = dir.path().join("db");
|
||||
let backend = CrashBackend::new(&path);
|
||||
let mut s = Store::from_database(backend.database(), identity()).expect("backend");
|
||||
assert_eq!(s.apply_next_created(id(0)).expect("apply ACK"), success(&command(), 0));
|
||||
backend.cut();
|
||||
drop(s);
|
||||
complete(&reopen(&path));
|
||||
for sync in 1..=2 {
|
||||
for after in [false, true] {
|
||||
let (dir, mut s) = fixture();
|
||||
append(&mut s, 0, &command());
|
||||
s.save_committed(id(0)).expect("committed");
|
||||
drop(s);
|
||||
let path = dir.path().join("db");
|
||||
let backend = CrashBackend::new(&path);
|
||||
let mut s = Store::from_database(backend.database(), identity()).expect("backend");
|
||||
let (entered, resume) = backend.pause_sync(sync, after);
|
||||
let writer = std::thread::spawn(move || {
|
||||
let result = s.apply_next_created(id(0));
|
||||
(s, result)
|
||||
});
|
||||
entered.wait();
|
||||
backend.cut();
|
||||
resume.wait();
|
||||
let (s, result) = writer.join().expect("writer");
|
||||
drop(s);
|
||||
let reopened = Store::open(&path, identity());
|
||||
assert!(
|
||||
reopened.is_ok(),
|
||||
"U11 apply cut must reopen whole old/new state sync={sync} after={after}: {:?}",
|
||||
reopened.as_ref().err()
|
||||
);
|
||||
let s = reopened.expect("asserted reopen");
|
||||
if s.last_applied().expect("last").is_some() {
|
||||
complete(&s)
|
||||
} else {
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.created_head(&command().object).expect("old head"), None);
|
||||
assert_eq!(
|
||||
s.created_version(&command().object, command().prepared.version_id)
|
||||
.expect("old version"),
|
||||
None
|
||||
);
|
||||
assert_eq!(s.created_event(command().operation_id).expect("old event"), None);
|
||||
assert_eq!(s.created_operation(command().operation_id).expect("old op"), None);
|
||||
assert_eq!(s.created_apply_result(0).expect("old result"), None)
|
||||
}
|
||||
eprintln!("U11 cut boundary sync={sync} after={after}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,579 +0,0 @@
|
||||
//! Immutable checkpoint state and disk-backed validation of the retained suffix.
|
||||
use crate::{
|
||||
CaptureMembership, CreatedApplyResult, CreatedEvent, CreatedResult, CreatedVersion, LogId, StoreError, application,
|
||||
created::{DecideCreated, Decoder, object_bytes, result_bytes},
|
||||
native_checkpoint::{CheckpointLimits, recovery_scratch},
|
||||
snapshot,
|
||||
};
|
||||
use redb::{ReadTransaction, ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition};
|
||||
|
||||
pub(crate) const META_KEY: &str = "checkpoint_baseline";
|
||||
pub(crate) const PURGED_KEY: &str = "checkpoint_purged";
|
||||
pub(crate) const HEADS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("checkpoint_heads_v1");
|
||||
pub(crate) const VERSIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("checkpoint_versions_v1");
|
||||
pub(crate) const EVENTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("checkpoint_events_v1");
|
||||
pub(crate) const OPERATIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("checkpoint_operations_v1");
|
||||
const FIRST_DECISIONS: TableDefinition<u64, &[u8]> = TableDefinition::new("checkpoint_validation_decisions");
|
||||
const VERSION_OPERATIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("checkpoint_validation_versions");
|
||||
const METADATA_BYTES: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct BaselineMetadata {
|
||||
pub(crate) generation: u64,
|
||||
pub(crate) header: snapshot::Header,
|
||||
pub(crate) limits: CheckpointLimits,
|
||||
}
|
||||
|
||||
struct EmptySink;
|
||||
impl snapshot::StagingSink for EmptySink {
|
||||
fn row(&mut self, _: snapshot::Section, _: &[u8], _: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other("checkpoint metadata contains rows"))
|
||||
}
|
||||
}
|
||||
fn metadata_limits() -> snapshot::Limits {
|
||||
snapshot::Limits {
|
||||
total_bytes: 1024 * 1024,
|
||||
membership_bytes: 512 * 1024,
|
||||
key_bytes: 0,
|
||||
value_bytes: 0,
|
||||
records: 0,
|
||||
}
|
||||
}
|
||||
pub(crate) fn encode_metadata(metadata: &BaselineMetadata) -> Result<Vec<u8>, StoreError> {
|
||||
metadata.limits.validate()?;
|
||||
if metadata.generation == 0 {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let mut bytes = b"CBAS\x01".to_vec();
|
||||
for value in [
|
||||
metadata.generation,
|
||||
metadata.limits.snapshot.total_bytes,
|
||||
metadata.limits.snapshot.membership_bytes,
|
||||
metadata.limits.snapshot.key_bytes,
|
||||
metadata.limits.snapshot.value_bytes,
|
||||
metadata.limits.snapshot.records,
|
||||
metadata.limits.staging_bytes,
|
||||
] {
|
||||
bytes.extend(value.to_be_bytes());
|
||||
}
|
||||
let frame = snapshot::Encoder::new(Vec::new(), &metadata.header, [0; 4], metadata_limits())
|
||||
.and_then(snapshot::Encoder::finish)
|
||||
.map_err(|_| StoreError::CorruptRecord)?;
|
||||
bytes.extend(frame);
|
||||
if bytes.len() > METADATA_BYTES {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
pub(crate) fn read_metadata(
|
||||
meta: &impl ReadableTable<&'static str, &'static [u8]>,
|
||||
) -> Result<Option<BaselineMetadata>, StoreError> {
|
||||
let schema = crate::number(required(meta.get("schema")?)?.value())?;
|
||||
let stored = meta.get(META_KEY)?;
|
||||
if schema != 7 {
|
||||
if stored.is_some() || meta.get(PURGED_KEY)?.is_some() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let value = required(stored)?;
|
||||
let bytes = value.value();
|
||||
if bytes.len() > METADATA_BYTES || bytes.get(..5) != Some(b"CBAS\x01") {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let mut numbers = [0u64; 7];
|
||||
for (index, number) in numbers.iter_mut().enumerate() {
|
||||
let start = 5 + index * 8;
|
||||
*number = crate::number(bytes.get(start..start + 8).ok_or(StoreError::CorruptRecord)?)?;
|
||||
}
|
||||
let limits = CheckpointLimits {
|
||||
snapshot: snapshot::Limits {
|
||||
total_bytes: numbers[1],
|
||||
membership_bytes: numbers[2],
|
||||
key_bytes: numbers[3],
|
||||
value_bytes: numbers[4],
|
||||
records: numbers[5],
|
||||
},
|
||||
staging_bytes: numbers[6],
|
||||
};
|
||||
limits.validate()?;
|
||||
let header = snapshot::decode(bytes.get(61..).ok_or(StoreError::CorruptRecord)?, &mut EmptySink, metadata_limits())
|
||||
.map_err(|_| StoreError::CorruptRecord)?;
|
||||
let metadata = BaselineMetadata {
|
||||
generation: numbers[0],
|
||||
header,
|
||||
limits,
|
||||
};
|
||||
if encode_metadata(&metadata)?.as_slice() != bytes {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(Some(metadata))
|
||||
}
|
||||
pub(crate) fn read_baseline_cut(meta: &impl ReadableTable<&'static str, &'static [u8]>) -> Result<Option<LogId>, StoreError> {
|
||||
Ok(read_metadata(meta)?.and_then(|metadata| metadata.header.applied))
|
||||
}
|
||||
pub(crate) fn encode_purged(id: LogId) -> Vec<u8> {
|
||||
let mut bytes = b"CPUR\x01".to_vec();
|
||||
bytes.extend(crate::encode_id(id));
|
||||
bytes
|
||||
}
|
||||
pub(crate) fn read_purged(meta: &impl ReadableTable<&'static str, &'static [u8]>) -> Result<Option<LogId>, StoreError> {
|
||||
let cut = read_baseline_cut(meta)?;
|
||||
let Some(value) = meta.get(PURGED_KEY)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = value.value();
|
||||
if bytes.len() != 29 || bytes.get(..5) != Some(b"CPUR\x01") {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let id = crate::decode_id(&bytes[5..])?;
|
||||
at_or_before(id, required(cut)?)?;
|
||||
Ok(Some(id))
|
||||
}
|
||||
fn at_or_before(id: LogId, cut: LogId) -> Result<(), StoreError> {
|
||||
if id.index > cut.index || (id.index == cut.index && id != cut) {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn required<T>(value: Option<T>) -> Result<T, StoreError> {
|
||||
value.ok_or(StoreError::CorruptRecord)
|
||||
}
|
||||
struct Budget {
|
||||
limits: snapshot::Limits,
|
||||
bytes: u64,
|
||||
records: u64,
|
||||
}
|
||||
impl Budget {
|
||||
fn new(limits: snapshot::Limits) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
bytes: 0,
|
||||
records: 0,
|
||||
}
|
||||
}
|
||||
fn row(&mut self, key: &[u8], value: &[u8]) -> Result<(), StoreError> {
|
||||
let key = u64::try_from(key.len()).map_err(|_| StoreError::CorruptRecord)?;
|
||||
let value = u64::try_from(value.len()).map_err(|_| StoreError::CorruptRecord)?;
|
||||
if key > 1024 * 1024 || value > 1024 * 1024 {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
self.bytes = self
|
||||
.bytes
|
||||
.checked_add(key)
|
||||
.and_then(|n| n.checked_add(value))
|
||||
.ok_or(StoreError::ResourceLimit)?;
|
||||
self.records = self.records.checked_add(1).ok_or(StoreError::ResourceLimit)?;
|
||||
if key > self.limits.key_bytes
|
||||
|| value > self.limits.value_bytes
|
||||
|| self.bytes > self.limits.total_bytes
|
||||
|| self.records > self.limits.records
|
||||
{
|
||||
return Err(StoreError::ResourceLimit);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resource_error(error: StoreError) -> StoreError {
|
||||
use std::error::Error;
|
||||
let mut cause: Option<&(dyn Error + 'static)> = Some(&error);
|
||||
while let Some(current) = cause {
|
||||
if current
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|io| io.kind() == std::io::ErrorKind::StorageFull)
|
||||
{
|
||||
return StoreError::ResourceLimit;
|
||||
}
|
||||
cause = current.source();
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
/// Validate relationships, preserving negative history rather than re-evaluating it.
|
||||
/// The caller separately establishes local capture or authenticated installation authority.
|
||||
pub(crate) fn validate_baseline_with_budget(
|
||||
tx: &ReadTransaction,
|
||||
metadata: &BaselineMetadata,
|
||||
index_max: u64,
|
||||
) -> Result<(), StoreError> {
|
||||
validate_baseline_inner(tx, metadata, index_max).map_err(resource_error)
|
||||
}
|
||||
|
||||
fn validate_baseline_inner(tx: &ReadTransaction, metadata: &BaselineMetadata, index_max: u64) -> Result<(), StoreError> {
|
||||
encode_metadata(metadata)?;
|
||||
let Some(cut) = metadata.header.applied else {
|
||||
if metadata.header.membership != CaptureMembership::new(None, vec![], std::collections::BTreeMap::new())? {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
for definition in [HEADS, VERSIONS, EVENTS, OPERATIONS] {
|
||||
if tx.open_table(definition)?.len()? != 0 {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let heads = tx.open_table(HEADS)?;
|
||||
let versions = tx.open_table(VERSIONS)?;
|
||||
let events = tx.open_table(EVENTS)?;
|
||||
let operations = tx.open_table(OPERATIONS)?;
|
||||
let (_directory, index) = recovery_scratch(index_max)?;
|
||||
let mut index_write = index.begin_write()?;
|
||||
index_write.open_table(FIRST_DECISIONS)?;
|
||||
index_write.open_table(VERSION_OPERATIONS)?;
|
||||
let mut budget = Budget::new(metadata.limits.snapshot);
|
||||
let mut pending = 0;
|
||||
for row in operations.iter()? {
|
||||
let (key, value) = row?;
|
||||
budget.row(key.value(), value.value())?;
|
||||
let provenance = application::decode_provenance(value.value())?;
|
||||
let command = DecideCreated::decode(&provenance.operation.canonical_command)?;
|
||||
let canonical = command.encode()?;
|
||||
if key.value() != command.operation_id || canonical != provenance.operation.canonical_command {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
at_or_before(provenance.first_decision_log_id, cut)?;
|
||||
let mut original = u64::try_from(canonical.len())
|
||||
.map_err(|_| StoreError::CorruptRecord)?
|
||||
.to_be_bytes()
|
||||
.to_vec();
|
||||
original.extend(&canonical);
|
||||
original.extend(result_bytes(&provenance.operation.result));
|
||||
if application::encode_provenance(provenance.first_decision_log_id, &original)?.as_slice() != value.value() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if index_write
|
||||
.open_table(FIRST_DECISIONS)?
|
||||
.insert(provenance.first_decision_log_id.index, key.value())?
|
||||
.is_some()
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if provenance.operation.result == CreatedResult::OperationConflict {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if let CreatedResult::Created {
|
||||
version_id,
|
||||
event_id,
|
||||
decision_log_id,
|
||||
} = provenance.operation.result
|
||||
{
|
||||
if version_id != command.prepared.version_id
|
||||
|| event_id != command.operation_id
|
||||
|| decision_log_id != provenance.first_decision_log_id
|
||||
|| command.binding_revision != metadata.header.binding.revision
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let event = application::decode_event(required(events.get(key.value())?)?.value())?;
|
||||
if event
|
||||
!= (CreatedEvent {
|
||||
object: command.object.clone(),
|
||||
prepared: command.prepared.clone(),
|
||||
decision_log_id,
|
||||
binding: metadata.header.binding.clone(),
|
||||
})
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let version_key = application::version_key(&command.object, version_id)?;
|
||||
let version = application::decode_version(required(versions.get(version_key.as_slice())?)?.value())?;
|
||||
if version
|
||||
!= (CreatedVersion {
|
||||
prepared: command.prepared.clone(),
|
||||
decision_log_id,
|
||||
binding_revision: metadata.header.binding.revision,
|
||||
})
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if index_write
|
||||
.open_table(VERSION_OPERATIONS)?
|
||||
.insert(version_key.as_slice(), key.value())?
|
||||
.is_some()
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
} else if events.get(key.value())?.is_some() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
pending += 1;
|
||||
if pending == 128 {
|
||||
index_write.commit().map_err(StoreError::StagingCommit)?;
|
||||
index_write = index.begin_write()?;
|
||||
pending = 0;
|
||||
}
|
||||
}
|
||||
index_write.commit().map_err(StoreError::StagingCommit)?;
|
||||
let index_read = index.begin_read()?;
|
||||
let version_operations = index_read.open_table(VERSION_OPERATIONS)?;
|
||||
if version_operations.len()? != versions.len()? {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
for row in versions.iter()? {
|
||||
let (key, value) = row?;
|
||||
budget.row(key.value(), value.value())?;
|
||||
required(version_operations.get(key.value())?)?;
|
||||
let mut decoder = Decoder::new(key.value());
|
||||
let object = decoder.object()?;
|
||||
let id = decoder.uuid()?;
|
||||
decoder.finish()?;
|
||||
let version = application::decode_version(value.value())?;
|
||||
let head_bytes = required(heads.get(object_bytes(&object)?.as_slice())?)?;
|
||||
let mut decoder = Decoder::new(head_bytes.value());
|
||||
let head = decoder.uuid()?;
|
||||
decoder.finish()?;
|
||||
let current =
|
||||
application::decode_version(required(versions.get(application::version_key(&object, head)?.as_slice())?)?.value())?;
|
||||
if id != version.prepared.version_id
|
||||
|| current.decision_log_id.index < version.decision_log_id.index
|
||||
|| (current.decision_log_id.index == version.decision_log_id.index
|
||||
&& current.decision_log_id != version.decision_log_id)
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
for row in events.iter()? {
|
||||
let (key, value) = row?;
|
||||
budget.row(key.value(), value.value())?;
|
||||
let event = application::decode_event(value.value())?;
|
||||
let operation = application::decode_provenance(required(operations.get(key.value())?)?.value())?;
|
||||
if operation.operation.result
|
||||
!= (CreatedResult::Created {
|
||||
version_id: event.prepared.version_id,
|
||||
event_id: key.value().try_into().map_err(|_| StoreError::CorruptRecord)?,
|
||||
decision_log_id: event.decision_log_id,
|
||||
})
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
for row in heads.iter()? {
|
||||
let (key, value) = row?;
|
||||
budget.row(key.value(), value.value())?;
|
||||
let mut decoder = Decoder::new(key.value());
|
||||
let object = decoder.object()?;
|
||||
decoder.finish()?;
|
||||
let mut decoder = Decoder::new(value.value());
|
||||
let id = decoder.uuid()?;
|
||||
decoder.finish()?;
|
||||
required(versions.get(application::version_key(&object, id)?.as_slice())?)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_recovery(tx: &ReadTransaction) -> Result<(), StoreError> {
|
||||
validate_recovery_with_limits(tx, None)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_recovery_with_limits(
|
||||
tx: &ReadTransaction,
|
||||
override_limits: Option<CheckpointLimits>,
|
||||
) -> Result<(), StoreError> {
|
||||
validate_recovery_inner(tx, override_limits).map_err(resource_error)
|
||||
}
|
||||
|
||||
fn validate_recovery_inner(tx: &ReadTransaction, override_limits: Option<CheckpointLimits>) -> Result<(), StoreError> {
|
||||
let meta = tx.open_table(crate::META)?;
|
||||
let metadata = required(read_metadata(&meta)?)?;
|
||||
crate::raft_state_machine::selected_meta(&meta, &metadata)?;
|
||||
let cut = metadata.header.applied;
|
||||
let recovery_limits = override_limits.unwrap_or(metadata.limits);
|
||||
recovery_limits.validate()?;
|
||||
let identity = crate::decode_identity(required(meta.get("identity")?)?.value())?;
|
||||
let binding = application::read_binding(&meta)?;
|
||||
if identity.cluster_id != metadata.header.cluster_id || binding != metadata.header.binding {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let purged = read_purged(&meta)?;
|
||||
validate_baseline_with_budget(tx, &metadata, recovery_limits.staging_bytes)?;
|
||||
let last = application::read_applied(&meta)?;
|
||||
optional_at_or_before(cut, last)?;
|
||||
let logs = tx.open_table(crate::LOGS)?;
|
||||
let committed = crate::committed::validate_committed(&meta, &logs)?;
|
||||
optional_at_or_before(last, committed)?;
|
||||
let declared_tail = meta.get("tail")?.map(|v| crate::decode_id(v.value())).transpose()?;
|
||||
optional_at_or_before(committed, declared_tail)?;
|
||||
let (_directory, scratch) = recovery_scratch(recovery_limits.staging_bytes)?;
|
||||
for (source, target) in [
|
||||
(HEADS, application::HEADS),
|
||||
(VERSIONS, application::VERSIONS),
|
||||
(EVENTS, application::EVENTS),
|
||||
(OPERATIONS, application::OPERATIONS),
|
||||
] {
|
||||
let table = tx.open_table(source)?;
|
||||
let mut write = scratch.begin_write()?;
|
||||
write.open_table(target)?;
|
||||
let mut pending = 0;
|
||||
for row in table.iter()? {
|
||||
let (key, value) = row?;
|
||||
write.open_table(target)?.insert(key.value(), value.value())?;
|
||||
pending += 1;
|
||||
if pending == 128 {
|
||||
write.commit().map_err(StoreError::StagingCommit)?;
|
||||
write = scratch.begin_write()?;
|
||||
pending = 0;
|
||||
}
|
||||
}
|
||||
write.commit().map_err(StoreError::StagingCommit)?;
|
||||
}
|
||||
let mut membership: CaptureMembership = metadata.header.membership.clone();
|
||||
if let Some(cut) = cut
|
||||
&& let Some(retained_cut) = logs.get(cut.index)?
|
||||
&& crate::decode_log(retained_cut.value())?.id != cut
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let results = tx.open_table(application::APPLY_RESULTS)?;
|
||||
// Covered entries may remain until purge. Check their exact typed responses
|
||||
// without rebuilding the historical state or allocating a prefix-sized map.
|
||||
let operations = tx.open_table(OPERATIONS)?;
|
||||
let mut actual_tail = purged;
|
||||
let mut prefix_next = purged.map_or(Some(0), |id| id.index.checked_add(1));
|
||||
if let Some(cut) = cut {
|
||||
for row in logs.range(..=cut.index)? {
|
||||
let (key, bytes) = row?;
|
||||
if purged.is_some_and(|purged| key.value() <= purged.index) {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if bytes.value().len() > 1024 * 1024 {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let entry = crate::decode_log(bytes.value())?;
|
||||
if entry.id.index != key.value() || prefix_next != Some(key.value()) {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
actual_tail = Some(entry.id);
|
||||
prefix_next = key.value().checked_add(1);
|
||||
let native = application::decode_native_entry(entry)?;
|
||||
let bytes = required(results.get(key.value())?)?;
|
||||
if bytes.value().len() > 1024 * 1024 {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let response = application::decode_response(bytes.value())?;
|
||||
application::validate_response_kind(&native, &response)?;
|
||||
if let openraft::EntryPayload::Normal(command) = native.payload {
|
||||
let operation = application::decode_provenance(
|
||||
required(operations.get(command.created().operation_id.as_slice())?)?.value(),
|
||||
)?;
|
||||
let canonical = command.created().encode()?;
|
||||
let expected = if operation.operation.canonical_command == canonical {
|
||||
operation.operation.result
|
||||
} else {
|
||||
CreatedResult::OperationConflict
|
||||
};
|
||||
if response.created_result() != Some(&expected) {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if operation.first_decision_log_id.index == key.value()
|
||||
&& operation.first_decision_log_id != LogId::from(native.log_id)
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in results.range(..=cut.index)? {
|
||||
let (key, _) = row?;
|
||||
if purged.is_some_and(|purged| key.value() <= purged.index) || logs.get(key.value())?.is_none() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
if purged.is_none_or(|purged| purged.index < cut.index) && logs.get(cut.index)?.is_none() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
if actual_tail != cut {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
|
||||
let mut observed = cut;
|
||||
let mut next = cut.map_or(Some(0), |cut| cut.index.checked_add(1));
|
||||
let mut budget = Budget::new(recovery_limits.snapshot);
|
||||
if let Some(start) = next {
|
||||
for row in logs.range(start..)? {
|
||||
let (key, bytes) = row?;
|
||||
budget.row(&key.value().to_be_bytes(), bytes.value())?;
|
||||
let entry = crate::decode_log(bytes.value())?;
|
||||
if next != Some(key.value()) || entry.id.index != key.value() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
next = key.value().checked_add(1);
|
||||
actual_tail = Some(entry.id);
|
||||
let native = application::decode_native_entry(entry.clone())?;
|
||||
if last.is_none_or(|last| key.value() > last.index) {
|
||||
continue;
|
||||
}
|
||||
let response_bytes = required(results.get(key.value())?)?;
|
||||
if response_bytes.value().len() > 1024 * 1024 {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if u64::try_from(response_bytes.value().len()).map_err(|_| StoreError::CorruptRecord)?
|
||||
> recovery_limits.snapshot.value_bytes
|
||||
{
|
||||
return Err(StoreError::ResourceLimit);
|
||||
}
|
||||
let response = application::decode_response(response_bytes.value())?;
|
||||
application::validate_response_kind(&native, &response)?;
|
||||
let write = scratch.begin_write()?;
|
||||
let expected = match native.payload {
|
||||
openraft::EntryPayload::Blank => crate::CaptureResponse::blank(entry.id)?,
|
||||
openraft::EntryPayload::Membership(value) => {
|
||||
membership = CaptureMembership::try_from(&openraft::StoredMembership::new(Some(entry.id.into()), value))?;
|
||||
crate::CaptureResponse::membership(entry.id)?
|
||||
}
|
||||
openraft::EntryPayload::Normal(command) => {
|
||||
let canonical = command.created().encode()?;
|
||||
let result = application::apply_created(&write, entry.id, command.created(), &canonical, &binding, 6)?;
|
||||
crate::CaptureResponse::created(CreatedApplyResult {
|
||||
log_id: entry.id,
|
||||
result,
|
||||
})?
|
||||
}
|
||||
};
|
||||
if response != expected {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
write.commit().map_err(StoreError::StagingCommit)?;
|
||||
observed = Some(entry.id);
|
||||
}
|
||||
for row in results.range(start..)? {
|
||||
let (key, _) = row?;
|
||||
if last.is_none_or(|last| key.value() > last.index) || logs.get(key.value())?.is_none() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
if actual_tail != declared_tail || observed != last || application::native_application_state(tx)?.1 != membership {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let recovered = scratch.begin_read()?;
|
||||
let mut live_budget = Budget::new(recovery_limits.snapshot);
|
||||
for definition in [
|
||||
application::HEADS,
|
||||
application::VERSIONS,
|
||||
application::EVENTS,
|
||||
application::OPERATIONS,
|
||||
] {
|
||||
let expected = recovered.open_table(definition)?;
|
||||
let actual = tx.open_table(definition)?;
|
||||
if expected.len()? != actual.len()? {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
for row in actual.iter()? {
|
||||
let (key, value) = row?;
|
||||
live_budget.row(key.value(), value.value())?;
|
||||
if required(expected.get(key.value())?)?.value() != value.value() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn optional_at_or_before(id: Option<LogId>, cut: Option<LogId>) -> Result<(), StoreError> {
|
||||
match (id, cut) {
|
||||
(None, _) => Ok(()),
|
||||
(Some(id), Some(cut)) => at_or_before(id, cut),
|
||||
_ => Err(StoreError::CorruptRecord),
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
use crate::{LOGS, LogId, META, Store, StoreError, decode_id, decode_log, encode_id, number};
|
||||
use redb::{ReadableDatabase, ReadableTable};
|
||||
|
||||
pub(crate) fn validate_committed(
|
||||
meta: &impl ReadableTable<&'static str, &'static [u8]>,
|
||||
logs: &impl ReadableTable<u64, &'static [u8]>,
|
||||
) -> Result<Option<LogId>, StoreError> {
|
||||
let schema = number(meta.get("schema")?.ok_or(StoreError::CorruptRecord)?.value())?;
|
||||
let marker = meta.get("committed")?;
|
||||
match (schema, marker) {
|
||||
(1 | 3 | 4 | 5 | 6 | 7, None) => {
|
||||
if matches!(schema, 3..=7) && meta.get("last_applied")?.is_none_or(|v| v.value() != [0]) {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
(2 | 3 | 4 | 5 | 6 | 7, Some(marker)) => {
|
||||
let id = decode_id(marker.value())?;
|
||||
match logs.get(id.index)? {
|
||||
Some(record) if decode_log(record.value())?.id == id => {}
|
||||
None if schema == 7 && crate::checkpoint_baseline::read_baseline_cut(meta)? == Some(id) => {}
|
||||
_ => return Err(StoreError::CorruptRecord),
|
||||
}
|
||||
Ok(Some(id))
|
||||
}
|
||||
(1 | 2, _) => Err(StoreError::CorruptRecord),
|
||||
_ => Err(StoreError::UnsupportedSchema),
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Read the exact committed boundary, validating it against the stored log.
|
||||
pub fn read_committed(&self) -> Result<Option<LogId>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
validate_committed(&tx.open_table(META)?, &tx.open_table(LOGS)?)
|
||||
}
|
||||
|
||||
/// Persist a monotonic committed boundary supplied by the authority owner.
|
||||
pub fn save_committed(&mut self, id: LogId) -> Result<(), StoreError> {
|
||||
let tx = self.write_transaction()?;
|
||||
{
|
||||
let mut meta = tx.open_table(META)?;
|
||||
let logs = tx.open_table(LOGS)?;
|
||||
let previous = validate_committed(&meta, &logs)?;
|
||||
if previous == Some(id) {
|
||||
return Ok(());
|
||||
}
|
||||
let record = logs.get(id.index)?.ok_or(StoreError::InvalidCommittedLog)?;
|
||||
if decode_log(record.value())?.id != id {
|
||||
return Err(StoreError::InvalidCommittedLog);
|
||||
}
|
||||
if let Some(previous) = previous {
|
||||
if id.index < previous.index {
|
||||
return Err(StoreError::CommittedRegression);
|
||||
}
|
||||
}
|
||||
meta.insert("committed", encode_id(id).as_slice())?;
|
||||
if previous.is_none() && !matches!(number(meta.get("schema")?.ok_or(StoreError::CorruptRecord)?.value())?, 3..=7) {
|
||||
meta.insert("schema", 2u64.to_be_bytes().as_slice())?;
|
||||
}
|
||||
}
|
||||
self.commit(tx)
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
use crate::{LogId, StoreError, decode_id, encode_id};
|
||||
|
||||
pub const MAX_OBJECT_KEY_LENGTH: usize = 4096;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ObjectIdentity {
|
||||
pub bucket_incarnation: [u8; 16],
|
||||
pub key: Vec<u8>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PreparedIdentity {
|
||||
pub version_id: [u8; 16],
|
||||
pub preparation_id: [u8; 16],
|
||||
pub content_digest: [u8; 32],
|
||||
pub content_length: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecideCreated {
|
||||
pub operation_id: [u8; 16],
|
||||
pub object: ObjectIdentity,
|
||||
pub expected_head: Option<[u8; 16]>,
|
||||
pub prepared: PreparedIdentity,
|
||||
pub binding_revision: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CaptureBinding {
|
||||
pub revision: u64,
|
||||
pub binding_id: [u8; 16],
|
||||
pub target_id: [u8; 16],
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CreatedResult {
|
||||
Created {
|
||||
version_id: [u8; 16],
|
||||
event_id: [u8; 16],
|
||||
decision_log_id: LogId,
|
||||
},
|
||||
HeadMismatch {
|
||||
actual_head: Option<[u8; 16]>,
|
||||
},
|
||||
OperationConflict,
|
||||
BindingMismatch,
|
||||
VersionConflict,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreatedVersion {
|
||||
pub prepared: PreparedIdentity,
|
||||
pub decision_log_id: LogId,
|
||||
pub binding_revision: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreatedEvent {
|
||||
pub object: ObjectIdentity,
|
||||
pub prepared: PreparedIdentity,
|
||||
pub decision_log_id: LogId,
|
||||
pub binding: CaptureBinding,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreatedOperation {
|
||||
pub canonical_command: Vec<u8>,
|
||||
pub result: CreatedResult,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreatedOperationProvenance {
|
||||
pub operation: CreatedOperation,
|
||||
pub first_decision_log_id: LogId,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreatedApplyResult {
|
||||
pub log_id: LogId,
|
||||
pub result: CreatedResult,
|
||||
}
|
||||
|
||||
pub(crate) struct Decoder<'a> {
|
||||
bytes: &'a [u8],
|
||||
}
|
||||
impl<'a> Decoder<'a> {
|
||||
pub(crate) fn new(bytes: &'a [u8]) -> Self {
|
||||
Self { bytes }
|
||||
}
|
||||
pub(crate) fn take<const N: usize>(&mut self) -> Result<[u8; N], StoreError> {
|
||||
let a = self
|
||||
.bytes
|
||||
.get(..N)
|
||||
.ok_or(StoreError::CorruptRecord)?
|
||||
.try_into()
|
||||
.map_err(|_| StoreError::CorruptRecord)?;
|
||||
self.bytes = &self.bytes[N..];
|
||||
Ok(a)
|
||||
}
|
||||
pub(crate) fn u64(&mut self) -> Result<u64, StoreError> {
|
||||
Ok(u64::from_be_bytes(self.take()?))
|
||||
}
|
||||
pub(crate) fn uuid(&mut self) -> Result<[u8; 16], StoreError> {
|
||||
let id = self.take()?;
|
||||
if id == [0; 16] {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
pub(crate) fn option(&mut self) -> Result<Option<[u8; 16]>, StoreError> {
|
||||
match self.take::<1>()?[0] {
|
||||
0 => Ok(None),
|
||||
1 => Ok(Some(self.uuid()?)),
|
||||
_ => Err(StoreError::CorruptRecord),
|
||||
}
|
||||
}
|
||||
pub(crate) fn object(&mut self) -> Result<ObjectIdentity, StoreError> {
|
||||
let bucket_incarnation = self.uuid()?;
|
||||
let len = usize::try_from(u32::from_be_bytes(self.take()?)).map_err(|_| StoreError::CorruptRecord)?;
|
||||
if len > MAX_OBJECT_KEY_LENGTH {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let key = self.bytes.get(..len).ok_or(StoreError::CorruptRecord)?.to_vec();
|
||||
self.bytes = &self.bytes[len..];
|
||||
Ok(ObjectIdentity { bucket_incarnation, key })
|
||||
}
|
||||
pub(crate) fn prepared(&mut self) -> Result<PreparedIdentity, StoreError> {
|
||||
Ok(PreparedIdentity {
|
||||
version_id: self.uuid()?,
|
||||
preparation_id: self.uuid()?,
|
||||
content_digest: self.take()?,
|
||||
content_length: self.u64()?,
|
||||
})
|
||||
}
|
||||
pub(crate) fn id(&mut self) -> Result<LogId, StoreError> {
|
||||
decode_id(&self.take::<24>()?)
|
||||
}
|
||||
pub(crate) fn binding(&mut self) -> Result<CaptureBinding, StoreError> {
|
||||
Ok(CaptureBinding {
|
||||
revision: self.u64()?,
|
||||
binding_id: self.uuid()?,
|
||||
target_id: self.uuid()?,
|
||||
})
|
||||
}
|
||||
pub(crate) fn finish(self) -> Result<(), StoreError> {
|
||||
if self.bytes.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StoreError::CorruptRecord)
|
||||
}
|
||||
}
|
||||
pub(crate) fn result(&mut self) -> Result<CreatedResult, StoreError> {
|
||||
match self.take::<1>()?[0] {
|
||||
0 => Ok(CreatedResult::Created {
|
||||
version_id: self.uuid()?,
|
||||
event_id: self.uuid()?,
|
||||
decision_log_id: self.id()?,
|
||||
}),
|
||||
1 => Ok(CreatedResult::HeadMismatch {
|
||||
actual_head: self.option()?,
|
||||
}),
|
||||
2 => Ok(CreatedResult::OperationConflict),
|
||||
3 => Ok(CreatedResult::BindingMismatch),
|
||||
4 => Ok(CreatedResult::VersionConflict),
|
||||
_ => Err(StoreError::CorruptRecord),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn put_option(b: &mut Vec<u8>, id: Option<[u8; 16]>) {
|
||||
b.push(u8::from(id.is_some()));
|
||||
if let Some(id) = id {
|
||||
b.extend(id)
|
||||
}
|
||||
}
|
||||
pub(crate) fn object_bytes(o: &ObjectIdentity) -> Result<Vec<u8>, StoreError> {
|
||||
if o.bucket_incarnation == [0; 16] || o.key.len() > MAX_OBJECT_KEY_LENGTH {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let capacity = 20usize.checked_add(o.key.len()).ok_or(StoreError::CorruptRecord)?;
|
||||
let mut b = Vec::with_capacity(capacity);
|
||||
b.extend(o.bucket_incarnation);
|
||||
b.extend(
|
||||
u32::try_from(o.key.len())
|
||||
.map_err(|_| StoreError::CorruptRecord)?
|
||||
.to_be_bytes(),
|
||||
);
|
||||
b.extend(&o.key);
|
||||
Ok(b)
|
||||
}
|
||||
pub(crate) fn put_prepared(b: &mut Vec<u8>, p: &PreparedIdentity) {
|
||||
b.extend(p.version_id);
|
||||
b.extend(p.preparation_id);
|
||||
b.extend(p.content_digest);
|
||||
b.extend(p.content_length.to_be_bytes())
|
||||
}
|
||||
pub(crate) fn binding_bytes(v: &CaptureBinding) -> Result<Vec<u8>, StoreError> {
|
||||
let mut b = v.revision.to_be_bytes().to_vec();
|
||||
b.extend(v.binding_id);
|
||||
b.extend(v.target_id);
|
||||
let mut d = Decoder::new(&b);
|
||||
d.binding()?;
|
||||
d.finish()?;
|
||||
Ok(b)
|
||||
}
|
||||
pub(crate) fn result_bytes(r: &CreatedResult) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
match r {
|
||||
CreatedResult::Created {
|
||||
version_id,
|
||||
event_id,
|
||||
decision_log_id,
|
||||
} => {
|
||||
b.push(0);
|
||||
b.extend(version_id);
|
||||
b.extend(event_id);
|
||||
b.extend(encode_id(*decision_log_id))
|
||||
}
|
||||
CreatedResult::HeadMismatch { actual_head } => {
|
||||
b.push(1);
|
||||
put_option(&mut b, *actual_head)
|
||||
}
|
||||
CreatedResult::OperationConflict => b.push(2),
|
||||
CreatedResult::BindingMismatch => b.push(3),
|
||||
CreatedResult::VersionConflict => b.push(4),
|
||||
}
|
||||
b
|
||||
}
|
||||
impl DecideCreated {
|
||||
pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
|
||||
let object = object_bytes(&self.object)?;
|
||||
let capacity = object.len().checked_add(134).ok_or(StoreError::CorruptRecord)?;
|
||||
let mut b = Vec::with_capacity(capacity);
|
||||
b.extend(b"CRTD\x01");
|
||||
b.extend(self.operation_id);
|
||||
b.extend(object);
|
||||
put_option(&mut b, self.expected_head);
|
||||
put_prepared(&mut b, &self.prepared);
|
||||
b.extend(self.binding_revision.to_be_bytes());
|
||||
Self::decode(&b)?;
|
||||
Ok(b)
|
||||
}
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
|
||||
let mut d = Decoder::new(bytes);
|
||||
if d.take::<5>()? != *b"CRTD\x01" {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let c = Self {
|
||||
operation_id: d.uuid()?,
|
||||
object: d.object()?,
|
||||
expected_head: d.option()?,
|
||||
prepared: d.prepared()?,
|
||||
binding_revision: d.u64()?,
|
||||
};
|
||||
d.finish()?;
|
||||
Ok(c)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,588 +0,0 @@
|
||||
//! Local checkpoints are captured from this writer's owned transaction only.
|
||||
use crate::{
|
||||
LogId, NativeLogWriter, NativeSnapshotError, NativeSnapshotView, StoreError,
|
||||
checkpoint_baseline::{self, BaselineMetadata},
|
||||
snapshot,
|
||||
};
|
||||
use redb::{ReadableDatabase, ReadableTable, StorageBackend};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Seek, Write},
|
||||
ops::Bound,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CheckpointLimits {
|
||||
pub snapshot: snapshot::Limits,
|
||||
pub staging_bytes: u64,
|
||||
}
|
||||
|
||||
impl CheckpointLimits {
|
||||
pub(crate) fn validate(&self) -> Result<(), StoreError> {
|
||||
let rows = self.snapshot;
|
||||
if rows.total_bytes == 0
|
||||
|| rows.membership_bytes == 0
|
||||
|| rows.membership_bytes > 512 * 1024
|
||||
|| rows.key_bytes == 0
|
||||
|| rows.key_bytes > 1024 * 1024
|
||||
|| rows.value_bytes == 0
|
||||
|| rows.value_bytes > 1024 * 1024
|
||||
|| rows.records == 0
|
||||
|| self.staging_bytes < 4 * 1024 * 1024
|
||||
{
|
||||
return Err(StoreError::ResourceLimit);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CheckpointLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
snapshot: snapshot::Limits {
|
||||
total_bytes: 256 * 1024 * 1024,
|
||||
membership_bytes: 512 * 1024,
|
||||
key_bytes: 8192,
|
||||
value_bytes: 8192,
|
||||
records: 1_000_000,
|
||||
},
|
||||
staging_bytes: 1024 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CheckpointReceipt {
|
||||
generation: u64,
|
||||
cut: LogId,
|
||||
}
|
||||
|
||||
impl CheckpointReceipt {
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
pub fn cut(&self) -> LogId {
|
||||
self.cut
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CheckpointError {
|
||||
#[error(transparent)]
|
||||
Store(#[from] StoreError),
|
||||
#[error(transparent)]
|
||||
Capture(#[from] NativeSnapshotError),
|
||||
#[error(transparent)]
|
||||
Snapshot(#[from] snapshot::SnapshotError),
|
||||
#[error("checkpoint filesystem failure")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("checkpoint worker failed")]
|
||||
Worker(#[source] tokio::task::JoinError),
|
||||
#[error("checkpoint store lock poisoned")]
|
||||
Poisoned,
|
||||
#[error("checkpoint conflict: {0}")]
|
||||
Conflict(&'static str),
|
||||
#[error("checkpoint cancelled before publication")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Enforces the file-length budget before redb can allocate or write a page.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BoundedBackend {
|
||||
inner: redb::backends::FileBackend,
|
||||
maximum: u64,
|
||||
}
|
||||
|
||||
impl BoundedBackend {
|
||||
fn new(file: File, maximum: u64) -> Result<Self, redb::DatabaseError> {
|
||||
Ok(Self {
|
||||
inner: redb::backends::FileBackend::new(file)?,
|
||||
maximum,
|
||||
})
|
||||
}
|
||||
|
||||
fn check(&self, end: u64) -> io::Result<()> {
|
||||
if end > self.maximum {
|
||||
return Err(io::Error::new(io::ErrorKind::StorageFull, "checkpoint staging budget"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl StorageBackend for BoundedBackend {
|
||||
fn len(&self) -> io::Result<u64> {
|
||||
self.inner.len()
|
||||
}
|
||||
fn read(&self, offset: u64, out: &mut [u8]) -> io::Result<()> {
|
||||
self.inner.read(offset, out)
|
||||
}
|
||||
fn set_len(&self, len: u64) -> io::Result<()> {
|
||||
self.check(len)?;
|
||||
self.inner.set_len(len)
|
||||
}
|
||||
fn sync_data(&self) -> io::Result<()> {
|
||||
self.inner.sync_data()
|
||||
}
|
||||
fn write(&self, offset: u64, data: &[u8]) -> io::Result<()> {
|
||||
let length = u64::try_from(data.len()).map_err(io::Error::other)?;
|
||||
self.check(
|
||||
offset
|
||||
.checked_add(length)
|
||||
.ok_or_else(|| io::Error::other("checkpoint write overflow"))?,
|
||||
)?;
|
||||
self.inner.write(offset, data)
|
||||
}
|
||||
fn close(&self) -> io::Result<()> {
|
||||
self.inner.close()
|
||||
}
|
||||
fn try_lock_range(&self, start: Bound<u64>, end: Bound<u64>) -> Result<bool, redb::BackendError> {
|
||||
self.inner.try_lock_range(start, end)
|
||||
}
|
||||
fn try_lock_shared_range(&self, start: Bound<u64>, end: Bound<u64>) -> Result<bool, redb::BackendError> {
|
||||
self.inner.try_lock_shared_range(start, end)
|
||||
}
|
||||
fn lock_range(&self, start: Bound<u64>, end: Bound<u64>) -> Result<(), redb::BackendError> {
|
||||
self.inner.lock_range(start, end)
|
||||
}
|
||||
fn lock_shared_range(&self, start: Bound<u64>, end: Bound<u64>) -> Result<(), redb::BackendError> {
|
||||
self.inner.lock_shared_range(start, end)
|
||||
}
|
||||
fn unlock_range(&self, start: Bound<u64>, end: Bound<u64>) -> Result<(), redb::BackendError> {
|
||||
self.inner.unlock_range(start, end)
|
||||
}
|
||||
fn query_lock_range(&self, start: Bound<u64>, end: Bound<u64>) -> Result<bool, redb::BackendError> {
|
||||
self.inner.query_lock_range(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn recovery_scratch(maximum: u64) -> Result<(tempfile::TempDir, redb::Database), StoreError> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let database = stage_database(directory.path().join("stage.redb"), maximum)?;
|
||||
Ok((directory, database))
|
||||
}
|
||||
|
||||
pub(crate) fn stage_database(path: std::path::PathBuf, maximum: u64) -> Result<redb::Database, StoreError> {
|
||||
let file = File::options().read(true).write(true).create_new(true).open(path)?;
|
||||
let backend = BoundedBackend::new(file, maximum)?;
|
||||
Ok(redb::Database::builder()
|
||||
.set_cache_size(8 * 1024 * 1024)
|
||||
.create_with_backend(backend)?)
|
||||
}
|
||||
|
||||
struct PayloadFile {
|
||||
file: File,
|
||||
maximum: u64,
|
||||
written: u64,
|
||||
}
|
||||
|
||||
impl Write for PayloadFile {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
let length = u64::try_from(bytes.len()).map_err(io::Error::other)?;
|
||||
let end = self
|
||||
.written
|
||||
.checked_add(length)
|
||||
.ok_or_else(|| io::Error::other("checkpoint payload overflow"))?;
|
||||
if end > self.maximum {
|
||||
return Err(io::Error::new(io::ErrorKind::StorageFull, "checkpoint payload budget"));
|
||||
}
|
||||
let count = self.file.write(bytes)?;
|
||||
self.written = self
|
||||
.written
|
||||
.checked_add(u64::try_from(count).map_err(io::Error::other)?)
|
||||
.ok_or_else(|| io::Error::other("checkpoint payload overflow"))?;
|
||||
Ok(count)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct StageSink<'a>(pub(crate) &'a redb::WriteTransaction);
|
||||
|
||||
impl snapshot::StagingSink for StageSink<'_> {
|
||||
fn row(&mut self, section: snapshot::Section, key: &[u8], value: &[u8]) -> io::Result<()> {
|
||||
let definition = match section {
|
||||
snapshot::Section::Heads => checkpoint_baseline::HEADS,
|
||||
snapshot::Section::Versions => checkpoint_baseline::VERSIONS,
|
||||
snapshot::Section::Events => checkpoint_baseline::EVENTS,
|
||||
snapshot::Section::Operations => checkpoint_baseline::OPERATIONS,
|
||||
};
|
||||
let mut table = self.0.open_table(definition).map_err(io::Error::other)?;
|
||||
if table.insert(key, value).map_err(io::Error::other)?.is_some() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "duplicate checkpoint key"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// This type has no external constructors, deserialization, or path-based import.
|
||||
// Its source remains the owned local read transaction, not the decoded artifact.
|
||||
struct LocalStage {
|
||||
source: NativeSnapshotView,
|
||||
database: redb::Database,
|
||||
_directory: tempfile::TempDir,
|
||||
metadata: BaselineMetadata,
|
||||
}
|
||||
|
||||
fn capture_stage(source: NativeSnapshotView, limits: CheckpointLimits) -> Result<LocalStage, CheckpointError> {
|
||||
limits.validate()?;
|
||||
source
|
||||
.header()
|
||||
.applied
|
||||
.ok_or(CheckpointError::Conflict("empty applied cut"))?;
|
||||
let directory = tempfile::tempdir()?;
|
||||
let payload = File::options()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(directory.path().join("capture.csnp"))?;
|
||||
let artifact = source.stream_local_capability(
|
||||
PayloadFile {
|
||||
file: payload,
|
||||
maximum: limits.staging_bytes.min(limits.snapshot.total_bytes),
|
||||
written: 0,
|
||||
},
|
||||
limits.snapshot,
|
||||
)?;
|
||||
let remaining = limits
|
||||
.staging_bytes
|
||||
.checked_sub(artifact.payload.written)
|
||||
.ok_or(CheckpointError::Conflict("staging budget"))?;
|
||||
let mut payload = artifact.payload.file;
|
||||
payload.sync_all()?;
|
||||
payload.rewind()?;
|
||||
let database = stage_database(directory.path().join("stage.redb"), remaining)?;
|
||||
let transaction = database.begin_write().map_err(StoreError::from)?;
|
||||
for definition in [
|
||||
checkpoint_baseline::HEADS,
|
||||
checkpoint_baseline::VERSIONS,
|
||||
checkpoint_baseline::EVENTS,
|
||||
checkpoint_baseline::OPERATIONS,
|
||||
] {
|
||||
transaction.open_table(definition).map_err(StoreError::from)?;
|
||||
}
|
||||
let header = snapshot::decode_capability_artifact(
|
||||
snapshot::CapabilityArtifact {
|
||||
manifest: artifact.manifest,
|
||||
payload,
|
||||
},
|
||||
&mut StageSink(&transaction),
|
||||
limits.snapshot,
|
||||
)?;
|
||||
std::fs::remove_file(directory.path().join("capture.csnp"))?;
|
||||
if header != *source.header() {
|
||||
return Err(CheckpointError::Conflict("capture header"));
|
||||
}
|
||||
let metadata = BaselineMetadata {
|
||||
generation: 1,
|
||||
header,
|
||||
limits,
|
||||
};
|
||||
let encoded = checkpoint_baseline::encode_metadata(&metadata)?;
|
||||
{
|
||||
let mut meta = transaction.open_table(crate::META).map_err(StoreError::from)?;
|
||||
meta.insert("schema", 7u64.to_be_bytes().as_slice())
|
||||
.map_err(StoreError::from)?;
|
||||
meta.insert("checkpoint_baseline", encoded.as_slice())
|
||||
.map_err(StoreError::from)?;
|
||||
}
|
||||
transaction.commit().map_err(StoreError::StagingCommit)?;
|
||||
let stage = LocalStage {
|
||||
source,
|
||||
database,
|
||||
_directory: directory,
|
||||
metadata,
|
||||
};
|
||||
Ok(stage)
|
||||
}
|
||||
|
||||
fn verify_local_stage(stage: &LocalStage) -> Result<(), CheckpointError> {
|
||||
let transaction = stage.database.begin_read().map_err(StoreError::from)?;
|
||||
for (source_definition, stage_definition) in [
|
||||
(crate::application::HEADS, checkpoint_baseline::HEADS),
|
||||
(crate::application::VERSIONS, checkpoint_baseline::VERSIONS),
|
||||
(crate::application::EVENTS, checkpoint_baseline::EVENTS),
|
||||
(crate::application::OPERATIONS, checkpoint_baseline::OPERATIONS),
|
||||
] {
|
||||
let source = stage
|
||||
.source
|
||||
.transaction()
|
||||
.open_table(source_definition)
|
||||
.map_err(StoreError::from)?;
|
||||
let staged = transaction.open_table(stage_definition).map_err(StoreError::from)?;
|
||||
let mut source_rows = source.iter().map_err(StoreError::from)?;
|
||||
let mut staged_rows = staged.iter().map_err(StoreError::from)?;
|
||||
loop {
|
||||
match (source_rows.next(), staged_rows.next()) {
|
||||
(None, None) => break,
|
||||
(Some(source), Some(staged)) => {
|
||||
let (source_key, source_value) = source.map_err(StoreError::from)?;
|
||||
let (stage_key, stage_value) = staged.map_err(StoreError::from)?;
|
||||
if source_key.value() != stage_key.value() || source_value.value() != stage_value.value() {
|
||||
return Err(CheckpointError::Conflict("staging differs from owned local capture"));
|
||||
}
|
||||
}
|
||||
_ => return Err(CheckpointError::Conflict("staging row count")),
|
||||
}
|
||||
}
|
||||
}
|
||||
let staged_meta = transaction.open_table(crate::META).map_err(StoreError::from)?;
|
||||
let staged_metadata =
|
||||
checkpoint_baseline::read_metadata(&staged_meta)?.ok_or(CheckpointError::Conflict("missing staging metadata"))?;
|
||||
if checkpoint_baseline::encode_metadata(&staged_metadata)? != checkpoint_baseline::encode_metadata(&stage.metadata)? {
|
||||
return Err(CheckpointError::Conflict("staging metadata differs from local capture"));
|
||||
}
|
||||
let stage_bytes = std::fs::metadata(stage._directory.path().join("stage.redb"))?.len();
|
||||
let index_budget = stage
|
||||
.metadata
|
||||
.limits
|
||||
.staging_bytes
|
||||
.checked_sub(stage_bytes)
|
||||
.ok_or(CheckpointError::Conflict("staging disk budget"))?;
|
||||
checkpoint_baseline::validate_baseline_with_budget(&transaction, &stage.metadata, index_budget)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resource_error(error: CheckpointError) -> CheckpointError {
|
||||
use std::error::Error;
|
||||
if matches!(&error, CheckpointError::Store(StoreError::CommitIndeterminate(_))) {
|
||||
return error;
|
||||
}
|
||||
let mut source: Option<&(dyn Error + 'static)> = Some(&error);
|
||||
while let Some(current) = source {
|
||||
if current
|
||||
.downcast_ref::<io::Error>()
|
||||
.is_some_and(|error| error.kind() == io::ErrorKind::StorageFull)
|
||||
{
|
||||
return CheckpointError::Store(StoreError::ResourceLimit);
|
||||
}
|
||||
source = current.source();
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
struct CancelOnDrop(Arc<AtomicBool>);
|
||||
impl Drop for CancelOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
type CaptureHook = Box<dyn FnOnce() + Send>;
|
||||
#[cfg(test)]
|
||||
type StageHook = Box<dyn FnOnce(&redb::Database) + Send>;
|
||||
#[cfg(test)]
|
||||
static CAPTURE_HOOKS: std::sync::Mutex<std::collections::BTreeMap<usize, CaptureHook>> =
|
||||
std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
#[cfg(test)]
|
||||
static STAGE_HOOKS: std::sync::Mutex<std::collections::BTreeMap<usize, StageHook>> =
|
||||
std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct CheckpointHook {
|
||||
owner: usize,
|
||||
stage: bool,
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl Drop for CheckpointHook {
|
||||
fn drop(&mut self) {
|
||||
if self.stage {
|
||||
STAGE_HOOKS.lock().expect("stage hooks").remove(&self.owner);
|
||||
} else {
|
||||
CAPTURE_HOOKS.lock().expect("capture hooks").remove(&self.owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn after_local_capture(writer: &NativeLogWriter, hook: impl FnOnce() + Send + 'static) -> CheckpointHook {
|
||||
let owner = Arc::as_ptr(&writer.store).addr();
|
||||
assert!(
|
||||
CAPTURE_HOOKS
|
||||
.lock()
|
||||
.expect("capture hooks")
|
||||
.insert(owner, Box::new(hook))
|
||||
.is_none()
|
||||
);
|
||||
CheckpointHook { owner, stage: false }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn after_local_stage(writer: &NativeLogWriter, hook: impl FnOnce(&redb::Database) + Send + 'static) -> CheckpointHook {
|
||||
let owner = Arc::as_ptr(&writer.store).addr();
|
||||
assert!(
|
||||
STAGE_HOOKS
|
||||
.lock()
|
||||
.expect("stage hooks")
|
||||
.insert(owner, Box::new(hook))
|
||||
.is_none()
|
||||
);
|
||||
CheckpointHook { owner, stage: true }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn run_capture_hook(owner: &Arc<std::sync::Mutex<crate::Store>>) {
|
||||
let hook = CAPTURE_HOOKS
|
||||
.lock()
|
||||
.expect("capture hooks")
|
||||
.remove(&Arc::as_ptr(owner).addr());
|
||||
if let Some(hook) = hook {
|
||||
hook();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn run_stage_hook(owner: &Arc<std::sync::Mutex<crate::Store>>, database: &redb::Database) {
|
||||
let hook = STAGE_HOOKS.lock().expect("stage hooks").remove(&Arc::as_ptr(owner).addr());
|
||||
if let Some(hook) = hook {
|
||||
hook(database);
|
||||
}
|
||||
}
|
||||
|
||||
impl NativeLogWriter {
|
||||
pub async fn checkpoint_local(&self, limits: CheckpointLimits) -> Result<CheckpointReceipt, CheckpointError> {
|
||||
self.checkpoint_local_inner(limits).await.map_err(resource_error)
|
||||
}
|
||||
|
||||
async fn checkpoint_local_inner(&self, limits: CheckpointLimits) -> Result<CheckpointReceipt, CheckpointError> {
|
||||
limits.validate()?;
|
||||
let admission = self.checkpoint_admission.clone().lock_owned().await;
|
||||
let owner = self.store.clone();
|
||||
let stage = tokio::task::spawn_blocking(move || {
|
||||
let source = NativeSnapshotView::acquire(owner.clone())?;
|
||||
#[cfg(test)]
|
||||
run_capture_hook(&owner);
|
||||
let stage = capture_stage(source, limits)?;
|
||||
#[cfg(test)]
|
||||
run_stage_hook(&owner, &stage.database);
|
||||
verify_local_stage(&stage)?;
|
||||
Ok::<_, CheckpointError>((stage, admission))
|
||||
})
|
||||
.await
|
||||
.map_err(CheckpointError::Worker)??;
|
||||
let ordering = self.ordering.clone().lock_owned().await;
|
||||
let owner = self.store.clone();
|
||||
let cancellation = CancelOnDrop(Arc::new(AtomicBool::new(false)));
|
||||
let cancelled = cancellation.0.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// The blocking owner retains both guards until publication finishes,
|
||||
// even if the async caller is dropped while redb is committing.
|
||||
let _ordering = ordering;
|
||||
let (stage, _admission) = stage;
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
return Err(CheckpointError::Cancelled);
|
||||
}
|
||||
if !Arc::ptr_eq(&owner, stage.source.owner()) {
|
||||
return Err(CheckpointError::Conflict("local capture owner"));
|
||||
}
|
||||
let mut store = owner.lock().map_err(|_| CheckpointError::Poisoned)?;
|
||||
let transaction = store.write_transaction()?;
|
||||
let mut meta = transaction.open_table(crate::META).map_err(StoreError::from)?;
|
||||
let schema = crate::number(
|
||||
meta.get("schema")
|
||||
.map_err(StoreError::from)?
|
||||
.ok_or(StoreError::CorruptRecord)?
|
||||
.value(),
|
||||
)?;
|
||||
if !matches!(schema, 6 | 7) {
|
||||
return Err(StoreError::UnsupportedSchema.into());
|
||||
}
|
||||
let previous = checkpoint_baseline::read_metadata(&meta)?;
|
||||
if crate::application::read_binding(&meta)? != stage.metadata.header.binding {
|
||||
return Err(CheckpointError::Conflict("live capture binding changed"));
|
||||
}
|
||||
let identity = crate::decode_identity(
|
||||
meta.get("identity")
|
||||
.map_err(StoreError::from)?
|
||||
.ok_or(StoreError::CorruptRecord)?
|
||||
.value(),
|
||||
)?;
|
||||
if identity.cluster_id != stage.metadata.header.cluster_id {
|
||||
return Err(CheckpointError::Conflict("live capture cluster changed"));
|
||||
}
|
||||
let cut = stage
|
||||
.metadata
|
||||
.header
|
||||
.applied
|
||||
.ok_or(CheckpointError::Conflict("empty applied cut"))?;
|
||||
let applied = crate::application::read_applied(&meta)?.ok_or(StoreError::CorruptRecord)?;
|
||||
let logs = transaction.open_table(crate::LOGS).map_err(StoreError::from)?;
|
||||
let committed = crate::committed::validate_committed(&meta, &logs)?.ok_or(StoreError::CorruptRecord)?;
|
||||
if cut.index > applied.index
|
||||
|| (cut.index == applied.index && cut != applied)
|
||||
|| applied.index > committed.index
|
||||
|| (applied.index == committed.index && applied != committed)
|
||||
{
|
||||
return Err(CheckpointError::Conflict("live applied and committed bounds"));
|
||||
}
|
||||
drop(logs);
|
||||
if let Some(old) = previous.as_ref().and_then(|previous| previous.header.applied)
|
||||
&& (old.index > cut.index || (old.index == cut.index && old != cut))
|
||||
{
|
||||
return Err(CheckpointError::Conflict("published checkpoint cut"));
|
||||
}
|
||||
let mut metadata = stage.metadata;
|
||||
metadata.generation = previous.map_or(Ok(1), |old| {
|
||||
old.generation
|
||||
.checked_add(1)
|
||||
.ok_or(CheckpointError::Conflict("checkpoint generation exhausted"))
|
||||
})?;
|
||||
let stage_transaction = stage.database.begin_read().map_err(StoreError::from)?;
|
||||
for (source_definition, definition) in [
|
||||
(crate::application::HEADS, checkpoint_baseline::HEADS),
|
||||
(crate::application::VERSIONS, checkpoint_baseline::VERSIONS),
|
||||
(crate::application::EVENTS, checkpoint_baseline::EVENTS),
|
||||
(crate::application::OPERATIONS, checkpoint_baseline::OPERATIONS),
|
||||
] {
|
||||
let captured = stage
|
||||
.source
|
||||
.transaction()
|
||||
.open_table(source_definition)
|
||||
.map_err(StoreError::from)?;
|
||||
let staged = stage_transaction.open_table(definition).map_err(StoreError::from)?;
|
||||
let mut source_rows = captured.iter().map_err(StoreError::from)?;
|
||||
let mut stage_rows = staged.iter().map_err(StoreError::from)?;
|
||||
let mut target = transaction.open_table(definition).map_err(StoreError::from)?;
|
||||
target.retain(|_, _| false).map_err(StoreError::from)?;
|
||||
// Recheck disk bytes while copying; the earlier relationship
|
||||
// validation remains outside the writer ordering interval.
|
||||
loop {
|
||||
match (source_rows.next(), stage_rows.next()) {
|
||||
(None, None) => break,
|
||||
(Some(source), Some(staged)) => {
|
||||
let (source_key, source_value) = source.map_err(StoreError::from)?;
|
||||
let (key, value) = staged.map_err(StoreError::from)?;
|
||||
if source_key.value() != key.value() || source_value.value() != value.value() {
|
||||
return Err(CheckpointError::Conflict("publication differs from local capture"));
|
||||
}
|
||||
target.insert(key.value(), value.value()).map_err(StoreError::from)?;
|
||||
}
|
||||
_ => return Err(CheckpointError::Conflict("publication row count")),
|
||||
}
|
||||
}
|
||||
}
|
||||
let encoded = checkpoint_baseline::encode_metadata(&metadata)?;
|
||||
meta.insert("checkpoint_baseline", encoded.as_slice())
|
||||
.map_err(StoreError::from)?;
|
||||
meta.insert("schema", 7u64.to_be_bytes().as_slice())
|
||||
.map_err(StoreError::from)?;
|
||||
crate::raft_state_machine::publish_local_meta(&mut meta, &metadata)?;
|
||||
drop(meta);
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
return Err(CheckpointError::Cancelled);
|
||||
}
|
||||
store.commit(transaction)?;
|
||||
Ok(CheckpointReceipt {
|
||||
generation: metadata.generation,
|
||||
cut,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(CheckpointError::Worker)?
|
||||
}
|
||||
}
|
||||
@@ -1,607 +0,0 @@
|
||||
use super::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn identity() -> NodeIdentity {
|
||||
NodeIdentity {
|
||||
cluster_id: [7; 16],
|
||||
node_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn binding() -> CaptureBinding {
|
||||
CaptureBinding {
|
||||
revision: 9,
|
||||
binding_id: [8; 16],
|
||||
target_id: [9; 16],
|
||||
}
|
||||
}
|
||||
|
||||
fn id(index: u64) -> LogId {
|
||||
LogId {
|
||||
term: 8,
|
||||
leader_node: 0,
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
fn command(operation: u8) -> DecideCreated {
|
||||
DecideCreated {
|
||||
operation_id: [operation; 16],
|
||||
object: ObjectIdentity {
|
||||
bucket_incarnation: [2; 16],
|
||||
key: b"checkpoint-object".to_vec(),
|
||||
},
|
||||
expected_head: None,
|
||||
prepared: PreparedIdentity {
|
||||
version_id: [3; 16],
|
||||
preparation_id: [4; 16],
|
||||
content_digest: [5; 32],
|
||||
content_length: 4097,
|
||||
},
|
||||
binding_revision: 9,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(store: &mut Store, index: u64, command: DecideCreated) -> CaptureResponse {
|
||||
let entry = CaptureRaftEntry {
|
||||
log_id: id(index).into(),
|
||||
payload: openraft::EntryPayload::Normal(CaptureCommand::try_created(command).expect("canonical command")),
|
||||
};
|
||||
store.append_native(&[entry]).expect("actual durable append");
|
||||
store.save_committed(id(index)).expect("actual durable commit");
|
||||
store.apply_next_native(id(index)).expect("actual native apply")
|
||||
}
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, Store, DecideCreated, CreatedOperationProvenance) {
|
||||
let dir = tempfile::tempdir().expect("actual native database directory");
|
||||
let mut store = Store::initialize(dir.path().join("db"), identity()).expect("initialize actual redb");
|
||||
store.pin_capture_binding(binding()).expect("bind actual store");
|
||||
store.enable_native_empty().expect("native schema4");
|
||||
store.promote_created_provenance().expect("native schema6 provenance");
|
||||
let membership = CaptureMembership::new(
|
||||
Some(id(0)),
|
||||
vec![BTreeSet::from([0])],
|
||||
BTreeMap::from([(0, CaptureNode::new("http://127.0.0.1:7000".into()).expect("native member"))]),
|
||||
)
|
||||
.expect("full native membership");
|
||||
let stored = openraft::StoredMembership::try_from(&membership).expect("OpenRaft membership");
|
||||
store
|
||||
.append_native(&[CaptureRaftEntry {
|
||||
log_id: id(0).into(),
|
||||
payload: openraft::EntryPayload::Membership(stored.membership().clone()),
|
||||
}])
|
||||
.expect("append actual membership");
|
||||
store.save_committed(id(0)).expect("commit membership");
|
||||
store.apply_next_native(id(0)).expect("apply membership");
|
||||
let negative = command(11);
|
||||
let mut original = negative.clone();
|
||||
original.expected_head = Some([12; 16]);
|
||||
apply(&mut store, 1, original.clone());
|
||||
let first = store
|
||||
.created_operation_provenance(original.operation_id)
|
||||
.expect("read first negative")
|
||||
.expect("canonical negative operation");
|
||||
assert_eq!(first.first_decision_log_id, id(1), "CHECKPOINT_FIRST_NEGATIVE_BOUND");
|
||||
assert!(
|
||||
matches!(first.operation.result, CreatedResult::HeadMismatch { .. }),
|
||||
"CHECKPOINT_FIRST_NEGATIVE_BOUND"
|
||||
);
|
||||
apply(&mut store, 2, command(1));
|
||||
(dir, store, original, first)
|
||||
}
|
||||
|
||||
fn runtime() -> tokio::runtime::Runtime {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("checkpoint runtime")
|
||||
}
|
||||
|
||||
fn schema(store: &Store) -> u64 {
|
||||
let tx = store.database.begin_read().expect("read actual schema");
|
||||
let table = tx.open_table(META).expect("schema table");
|
||||
number(table.get("schema").expect("schema read").expect("schema exists").value()).expect("schema number")
|
||||
}
|
||||
|
||||
fn assert_negative(store: &Store, original: &DecideCreated, first: &CreatedOperationProvenance) {
|
||||
assert_eq!(
|
||||
store
|
||||
.created_operation_provenance(original.operation_id)
|
||||
.expect("canonical read"),
|
||||
Some(first.clone()),
|
||||
"CHECKPOINT_FIRST_NEGATIVE_BOUND: preserve original bytes/result/full first ID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_negative_retry_and_purge_reopen() {
|
||||
let (dir, store, original, first) = fixture();
|
||||
assert_eq!(schema(&store), 6);
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let receipt = runtime()
|
||||
.block_on(writer.checkpoint_local(CheckpointLimits::default()))
|
||||
.expect("publish local checkpoint");
|
||||
assert_eq!(receipt.cut(), id(2), "CHECKPOINT_BASELINE_BOUND");
|
||||
assert_eq!(receipt.generation(), 1);
|
||||
{
|
||||
let mut store = writer.store.lock().expect("actual writer store");
|
||||
assert_eq!(schema(&store), 7);
|
||||
assert_negative(&store, &original, &first);
|
||||
apply(&mut store, 3, original.clone());
|
||||
assert_negative(&store, &original, &first);
|
||||
let mut conflict = original.clone();
|
||||
conflict.binding_revision += 1;
|
||||
apply(&mut store, 4, conflict);
|
||||
assert_eq!(
|
||||
store
|
||||
.created_apply_result(4)
|
||||
.expect("conflicting response")
|
||||
.expect("response")
|
||||
.result,
|
||||
CreatedResult::OperationConflict,
|
||||
"CHECKPOINT_RETRY_BOUND"
|
||||
);
|
||||
assert_negative(&store, &original, &first);
|
||||
}
|
||||
runtime()
|
||||
.block_on(writer.purge_checkpoint(&receipt))
|
||||
.expect("purge exact durable baseline");
|
||||
drop(writer);
|
||||
let reopened = Store::open(dir.path().join("db"), identity()).expect("reopen purged baseline plus suffix");
|
||||
assert_negative(&reopened, &original, &first);
|
||||
assert_eq!(reopened.last_applied().expect("suffix applied"), Some(id(4)), "CHECKPOINT_SUFFIX_BOUND");
|
||||
assert!(reopened.read_entry(2).expect("purged cut").is_none());
|
||||
assert!(reopened.read_entry(3).expect("retained suffix").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_capture_keeps_concurrent_suffix() {
|
||||
let (dir, store, original, first) = fixture();
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let _hook = native_checkpoint::after_local_capture(&writer, move || {
|
||||
entered_tx.send(()).expect("capture reached");
|
||||
release_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(30))
|
||||
.expect("release owned capture");
|
||||
});
|
||||
let worker = writer.clone();
|
||||
let thread = std::thread::spawn(move || runtime().block_on(worker.checkpoint_local(CheckpointLimits::default())));
|
||||
entered_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(30))
|
||||
.expect("actual capture boundary");
|
||||
{
|
||||
let mut store = writer.store.lock().expect("capture releases live write owner");
|
||||
apply(&mut store, 3, original.clone());
|
||||
}
|
||||
release_tx.send(()).expect("release capture");
|
||||
let receipt = thread.join().expect("checkpoint worker").expect("concurrent checkpoint");
|
||||
assert_eq!(receipt.cut(), id(2), "CHECKPOINT_SAME_CUT_BOUND");
|
||||
runtime()
|
||||
.block_on(writer.purge_checkpoint(&receipt))
|
||||
.expect("purge captured prefix");
|
||||
drop(writer);
|
||||
let reopened = Store::open(dir.path().join("db"), identity()).expect("reopen concurrent suffix");
|
||||
assert_eq!(reopened.last_applied().expect("actual suffix"), Some(id(3)), "CHECKPOINT_SUFFIX_BOUND");
|
||||
assert_negative(&reopened, &original, &first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_staging_corruption_preserves_live_generation() {
|
||||
let (dir, store, original, first) = fixture();
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let before = runtime()
|
||||
.block_on(writer.checkpoint_local(CheckpointLimits::default()))
|
||||
.expect("initial baseline");
|
||||
let _hook = native_checkpoint::after_local_stage(&writer, |database| {
|
||||
let tx = database.begin_write().expect("actual isolated stage mutation");
|
||||
tx.open_table(checkpoint_baseline::OPERATIONS)
|
||||
.expect("stage operations")
|
||||
.retain(|_, _| false)
|
||||
.expect("damage cross-record relationships");
|
||||
tx.commit().expect("persist actual damaged stage");
|
||||
});
|
||||
assert!(
|
||||
runtime()
|
||||
.block_on(writer.checkpoint_local(CheckpointLimits::default()))
|
||||
.is_err(),
|
||||
"CHECKPOINT_FAILURE_ATOMIC"
|
||||
);
|
||||
let store = writer.store.lock().expect("unchanged live store");
|
||||
let tx = store.database.begin_read().expect("published generation");
|
||||
let meta = tx.open_table(META).expect("published meta");
|
||||
assert_eq!(
|
||||
checkpoint_baseline::read_metadata(&meta)
|
||||
.expect("valid old metadata")
|
||||
.expect("old baseline")
|
||||
.generation,
|
||||
before.generation(),
|
||||
"CHECKPOINT_FAILURE_ATOMIC"
|
||||
);
|
||||
assert_negative(&store, &original, &first);
|
||||
drop(meta);
|
||||
drop(tx);
|
||||
drop(store);
|
||||
drop(writer);
|
||||
let reopened = Store::open(dir.path().join("db"), identity()).expect("reopen after failed staging validation");
|
||||
assert_negative(&reopened, &original, &first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_budget_failure_preserves_schema_and_history() {
|
||||
for staging in [false, true] {
|
||||
let (dir, mut store, original, first) = fixture();
|
||||
if staging {
|
||||
for index in 0u64..256 {
|
||||
let mut large = command(31);
|
||||
large.operation_id[..8].copy_from_slice(&index.to_be_bytes());
|
||||
large.prepared.version_id[..8].copy_from_slice(&index.to_be_bytes());
|
||||
large.object.key = vec![b'x'; MAX_OBJECT_KEY_LENGTH];
|
||||
large.object.key[..8].copy_from_slice(&index.to_be_bytes());
|
||||
apply(&mut store, index + 3, large);
|
||||
}
|
||||
}
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let mut limits = CheckpointLimits::default();
|
||||
if staging {
|
||||
limits.staging_bytes = 4 * 1024 * 1024;
|
||||
} else {
|
||||
limits.snapshot.total_bytes = 64;
|
||||
}
|
||||
let result = runtime().block_on(writer.checkpoint_local(limits));
|
||||
if staging {
|
||||
assert!(
|
||||
matches!(&result, Err(CheckpointError::Store(StoreError::ResourceLimit))),
|
||||
"CHECKPOINT_RESOURCE_BOUND: actual 4MiB physical staging limit: {result:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"CHECKPOINT_RESOURCE_BOUND: actual bounded output must refuse: {result:?}"
|
||||
);
|
||||
}
|
||||
{
|
||||
let store = writer.store.lock().expect("live after exhausted budget");
|
||||
assert_eq!(schema(&store), 6, "CHECKPOINT_FAILURE_ATOMIC");
|
||||
assert_negative(&store, &original, &first);
|
||||
assert!(store.read_entry(0).expect("prefix intact").is_some());
|
||||
}
|
||||
drop(writer);
|
||||
assert_negative(
|
||||
&Store::open(dir.path().join("db"), identity()).expect("reopen budget failure"),
|
||||
&original,
|
||||
&first,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_cancelled_capture_cannot_publish_or_purge() {
|
||||
let (dir, store, original, first) = fixture();
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let _hook = native_checkpoint::after_local_capture(&writer, move || {
|
||||
entered_tx.send(()).expect("capture cancellation boundary");
|
||||
release_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(30))
|
||||
.expect("release pending actual capture");
|
||||
});
|
||||
let rt = runtime();
|
||||
let worker = writer.clone();
|
||||
rt.block_on(async {
|
||||
let task = tokio::spawn(async move { worker.checkpoint_local(CheckpointLimits::default()).await });
|
||||
tokio::task::spawn_blocking(move || entered_rx.recv_timeout(std::time::Duration::from_secs(30)))
|
||||
.await
|
||||
.expect("capture observer")
|
||||
.expect("actual capture owned transaction");
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("cancelled waiter").is_cancelled(), "CHECKPOINT_CANCEL_BOUND");
|
||||
release_tx.send(()).expect("release actual worker after waiter cancellation");
|
||||
// A fresh checkpoint waits for the same production admission; completion
|
||||
// establishes that the cancelled capture worker released its real resources.
|
||||
let receipt = writer
|
||||
.checkpoint_local(CheckpointLimits::default())
|
||||
.await
|
||||
.expect("capture after cancellation");
|
||||
assert_eq!(receipt.generation(), 1, "CHECKPOINT_CANCEL_BOUND: abandoned capture never published");
|
||||
});
|
||||
drop(rt);
|
||||
drop(writer);
|
||||
let reopened = Store::open(dir.path().join("db"), identity()).expect("reopen after cancellation and successful next capture");
|
||||
assert_negative(&reopened, &original, &first);
|
||||
assert!(
|
||||
reopened.read_entry(0).expect("cancellation cannot purge").is_some(),
|
||||
"CHECKPOINT_CANCEL_BOUND"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_stale_receipt_cannot_remove_history() {
|
||||
let (_dir, store, original, first) = fixture();
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let rt = runtime();
|
||||
let old = rt
|
||||
.block_on(writer.checkpoint_local(CheckpointLimits::default()))
|
||||
.expect("first generation");
|
||||
let new = rt
|
||||
.block_on(writer.checkpoint_local(CheckpointLimits::default()))
|
||||
.expect("next generation");
|
||||
assert!(new.generation() > old.generation());
|
||||
assert!(
|
||||
matches!(
|
||||
rt.block_on(writer.purge_checkpoint(&old)),
|
||||
Err(NativeWriteError::Store(StoreError::StaleCheckpoint))
|
||||
),
|
||||
"CHECKPOINT_PURGE_BOUND"
|
||||
);
|
||||
let store = writer.store.lock().expect("history after stale purge");
|
||||
assert_negative(&store, &original, &first);
|
||||
assert!(store.read_entry(1).expect("first decision retained").is_some());
|
||||
assert!(store.created_apply_result(1).expect("typed result retained").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_reopen_rejects_conflicting_cut_and_suffix_gap() {
|
||||
for corruption in 0..4 {
|
||||
let gap = corruption == 1;
|
||||
let (dir, store, original, _) = fixture();
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let rt = runtime();
|
||||
let receipt = rt
|
||||
.block_on(writer.checkpoint_local(CheckpointLimits::default()))
|
||||
.expect("valid checkpoint");
|
||||
if gap {
|
||||
{
|
||||
let mut store = writer.store.lock().expect("suffix owner");
|
||||
apply(&mut store, 3, original.clone());
|
||||
apply(&mut store, 4, original.clone());
|
||||
}
|
||||
rt.block_on(writer.purge_checkpoint(&receipt))
|
||||
.expect("purge before gap corruption");
|
||||
{
|
||||
let store = writer.store.lock().expect("actual purged metadata owner");
|
||||
let tx = store.database.begin_write().expect("actual CPUR mutation");
|
||||
let mut meta = tx.open_table(META).expect("actual CPUR table");
|
||||
let mut conflict = receipt.cut();
|
||||
conflict.term += 1;
|
||||
conflict.leader_node += 1;
|
||||
let encoded = checkpoint_baseline::encode_purged(conflict);
|
||||
meta.insert(checkpoint_baseline::PURGED_KEY, encoded.as_slice())
|
||||
.expect("persist conflicting full purge ID");
|
||||
drop(meta);
|
||||
tx.commit().expect("durable conflicting CPUR");
|
||||
let tx = store.database.begin_read().expect("actual conflicting CPUR read");
|
||||
let meta = tx.open_table(META).expect("actual persisted CPUR");
|
||||
assert!(
|
||||
matches!(checkpoint_baseline::read_purged(&meta), Err(StoreError::CorruptRecord)),
|
||||
"CHECKPOINT_FULL_CUT_BOUND: same index with different term/leader is corruption"
|
||||
);
|
||||
drop(meta);
|
||||
drop(tx);
|
||||
let tx = store
|
||||
.database
|
||||
.begin_write()
|
||||
.expect("restore valid purge boundary for gap scenario");
|
||||
let encoded = checkpoint_baseline::encode_purged(receipt.cut());
|
||||
tx.open_table(META)
|
||||
.expect("actual purge meta")
|
||||
.insert(checkpoint_baseline::PURGED_KEY, encoded.as_slice())
|
||||
.expect("restore exact cut");
|
||||
tx.commit().expect("persist exact purge boundary");
|
||||
}
|
||||
}
|
||||
if corruption >= 2 {
|
||||
let mut store = writer.store.lock().expect("actual suffix before tail corruption");
|
||||
apply(&mut store, 3, original.clone());
|
||||
}
|
||||
{
|
||||
let store = writer.store.lock().expect("actual corruption owner");
|
||||
let tx = store.database.begin_write().expect("actual redb mutation");
|
||||
let mut logs = tx.open_table(LOGS).expect("actual log table");
|
||||
if gap {
|
||||
logs.remove(3).expect("remove required suffix entry");
|
||||
} else if corruption >= 2 {
|
||||
let mut tail = id(3);
|
||||
if corruption == 2 {
|
||||
tail.index += 1;
|
||||
} else {
|
||||
tail.term += 1;
|
||||
tail.leader_node += 1;
|
||||
}
|
||||
let encoded = encode_id(tail);
|
||||
tx.open_table(META)
|
||||
.expect("actual metadata")
|
||||
.insert("tail", encoded.as_slice())
|
||||
.expect("corrupt durable tail");
|
||||
} else {
|
||||
let mut entry = store.read_entry(2).expect("retained cut").expect("cut entry");
|
||||
entry.id.term += 1;
|
||||
entry.id.leader_node += 1;
|
||||
let encoded = encode_log(&entry).expect("valid conflicting full ID encoding");
|
||||
logs.insert(2, encoded.as_slice()).expect("replace exact cut identity");
|
||||
}
|
||||
drop(logs);
|
||||
tx.commit().expect("persist actual inconsistent recovery input");
|
||||
}
|
||||
assert!(
|
||||
rt.block_on(writer.purge_checkpoint(&receipt)).is_err(),
|
||||
"CHECKPOINT_PURGE_BOUND: invalid recovery cannot authorize cleanup"
|
||||
);
|
||||
if !gap {
|
||||
let store = writer.store.lock().expect("refused purge retains original evidence");
|
||||
assert!(store.read_entry(1).expect("original log retained").is_some(), "CHECKPOINT_PURGE_BOUND");
|
||||
assert!(
|
||||
store.created_apply_result(1).expect("original response retained").is_some(),
|
||||
"CHECKPOINT_PURGE_BOUND"
|
||||
);
|
||||
}
|
||||
drop(writer);
|
||||
drop(rt);
|
||||
assert!(Store::open(dir.path().join("db"), identity()).is_err(), "CHECKPOINT_RECOVERY_BOUND");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_publication_fault_selects_complete_generation() {
|
||||
for (sync, after) in [(1, false), (1, true), (2, false), (2, true)] {
|
||||
let (dir, store, original, first) = fixture();
|
||||
let vote = VoteRecord {
|
||||
term: 9,
|
||||
voted_node: 0,
|
||||
committed: true,
|
||||
};
|
||||
let mut store = store;
|
||||
store.save_vote(vote).expect("local hard state before checkpoint");
|
||||
drop(store);
|
||||
let path = dir.path().join("db");
|
||||
let backend = tests::CrashBackend::new(&path);
|
||||
let store = Store::from_database(backend.database(), identity()).expect("existing simulated durability backend");
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let (entered, release) = backend.pause_sync(sync, after);
|
||||
let worker = writer.clone();
|
||||
let task = std::thread::spawn(move || runtime().block_on(worker.checkpoint_local(CheckpointLimits::default())));
|
||||
entered.wait();
|
||||
backend.cut();
|
||||
release.wait();
|
||||
let _unknown = task.join().expect("interrupted publication worker");
|
||||
drop(writer);
|
||||
let reopened = Store::open(&path, identity()).expect("recover complete generation after simulated sync cut");
|
||||
assert!(matches!(schema(&reopened), 6 | 7), "CHECKPOINT_CRASH_ATOMIC");
|
||||
assert_negative(&reopened, &original, &first);
|
||||
assert_eq!(
|
||||
reopened.read_vote().expect("preserved local vote"),
|
||||
Some(vote),
|
||||
"CHECKPOINT_LOCAL_STATE_BOUND"
|
||||
);
|
||||
assert_eq!(reopened.last_applied().expect("complete applied position"), Some(id(2)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_recovery_budget_can_be_raised_without_changing_baseline() {
|
||||
use redb::ReadableTableMetadata;
|
||||
let (dir, store, original, first) = fixture();
|
||||
let mut limits = CheckpointLimits::default();
|
||||
let tx = store.database.begin_read().expect("measure real baseline rows");
|
||||
limits.snapshot.records = [
|
||||
application::HEADS,
|
||||
application::VERSIONS,
|
||||
application::EVENTS,
|
||||
application::OPERATIONS,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|definition| {
|
||||
tx.open_table(definition)
|
||||
.expect("actual baseline table")
|
||||
.len()
|
||||
.expect("actual row count")
|
||||
})
|
||||
.sum();
|
||||
drop(tx);
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let receipt = runtime()
|
||||
.block_on(writer.checkpoint_local(limits))
|
||||
.expect("capture exact bounded baseline");
|
||||
{
|
||||
let mut store = writer.store.lock().expect("actual suffix writer");
|
||||
let mut suffix = command(21);
|
||||
suffix.object.key = b"suffix-new-key".to_vec();
|
||||
suffix.prepared.version_id = [22; 16];
|
||||
apply(&mut store, 3, suffix);
|
||||
}
|
||||
drop(writer);
|
||||
let path = dir.path().join("db");
|
||||
assert!(
|
||||
matches!(Store::open(&path, identity()), Err(StoreError::ResourceLimit)),
|
||||
"CHECKPOINT_RESOURCE_BOUND"
|
||||
);
|
||||
let mut raised = limits;
|
||||
raised.snapshot.records += 32;
|
||||
let reopened =
|
||||
Store::open_with_checkpoint_limits(&path, identity(), raised).expect("explicitly raise bounded recovery budget");
|
||||
assert_negative(&reopened, &original, &first);
|
||||
assert_eq!(reopened.last_applied().expect("retained suffix position"), Some(id(3)));
|
||||
let tx = reopened.database.begin_read().expect("unchanged baseline metadata");
|
||||
let meta = tx.open_table(META).expect("actual metadata");
|
||||
let baseline = checkpoint_baseline::read_metadata(&meta)
|
||||
.expect("baseline decode")
|
||||
.expect("baseline selected");
|
||||
assert_eq!(baseline.generation, receipt.generation());
|
||||
assert_eq!(
|
||||
baseline.limits.snapshot.records, limits.snapshot.records,
|
||||
"CHECKPOINT_RESOURCE_BOUND: override never rewrites evidence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_interrupted_purge_reopens_and_retries_idempotently() {
|
||||
for (sync, after) in [(1, false), (1, true), (2, false), (2, true)] {
|
||||
let (dir, store, original, first) = fixture();
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let receipt = runtime()
|
||||
.block_on(writer.checkpoint_local(CheckpointLimits::default()))
|
||||
.expect("durable baseline before purge");
|
||||
drop(writer);
|
||||
let path = dir.path().join("db");
|
||||
let backend = tests::CrashBackend::new(&path);
|
||||
let store = Store::from_database(backend.database(), identity()).expect("simulated purge durability backend");
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let (entered, release) = backend.pause_sync(sync, after);
|
||||
let worker = writer.clone();
|
||||
let task = std::thread::spawn(move || runtime().block_on(worker.purge_checkpoint(&receipt)));
|
||||
entered.wait();
|
||||
backend.cut();
|
||||
release.wait();
|
||||
let _unknown = task.join().expect("interrupted purge worker");
|
||||
drop(writer);
|
||||
let reopened = Store::open(&path, identity()).expect("baseline survives simulated interrupted purge");
|
||||
assert_negative(&reopened, &original, &first);
|
||||
let retained = (0..=2)
|
||||
.map(|index| reopened.read_entry(index).expect("atomic purge log state").is_some())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
retained.iter().all(|present| *present) || retained.iter().all(|present| !*present),
|
||||
"CHECKPOINT_PURGE_ATOMIC: complete old prefix or complete new purge"
|
||||
);
|
||||
let writer = NativeLogWriter::new(reopened);
|
||||
let rt = runtime();
|
||||
rt.block_on(writer.purge_checkpoint(&receipt)).expect("resume exact purge");
|
||||
rt.block_on(writer.purge_checkpoint(&receipt))
|
||||
.expect("repeat exact purge idempotently");
|
||||
drop(writer);
|
||||
let reopened = Store::open(&path, identity()).expect("reopen resumed purge");
|
||||
assert_negative(&reopened, &original, &first);
|
||||
assert!(reopened.read_entry(2).expect("purged cut absent").is_none(), "CHECKPOINT_PURGE_ATOMIC");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_ack_and_purge_ack_survive_simulated_power_cut() {
|
||||
let (dir, store, original, first) = fixture();
|
||||
drop(store);
|
||||
let path = dir.path().join("db");
|
||||
let backend = tests::CrashBackend::new(&path);
|
||||
let writer =
|
||||
NativeLogWriter::new(Store::from_database(backend.database(), identity()).expect("actual checkpoint fault backend"));
|
||||
let receipt = runtime()
|
||||
.block_on(writer.checkpoint_local(CheckpointLimits::default()))
|
||||
.expect("successful checkpoint ACK");
|
||||
backend.cut();
|
||||
drop(writer);
|
||||
let reopened = Store::open(&path, identity()).expect("ACK checkpoint survives simulated power cut");
|
||||
assert_eq!(schema(&reopened), 7, "CHECKPOINT_ACK_DURABLE");
|
||||
assert_negative(&reopened, &original, &first);
|
||||
let backend = tests::CrashBackend::new(&path);
|
||||
drop(reopened);
|
||||
let writer = NativeLogWriter::new(Store::from_database(backend.database(), identity()).expect("actual purge fault backend"));
|
||||
runtime()
|
||||
.block_on(writer.purge_checkpoint(&receipt))
|
||||
.expect("successful purge ACK");
|
||||
backend.cut();
|
||||
drop(writer);
|
||||
let reopened = Store::open(&path, identity()).expect("ACK purge survives simulated power cut");
|
||||
assert_negative(&reopened, &original, &first);
|
||||
assert!(reopened.read_entry(2).expect("ACK purge cut absent").is_none(), "CHECKPOINT_ACK_DURABLE");
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
//! Owned consistent native application snapshot view.
|
||||
use crate::{Store, StoreError, application, snapshot};
|
||||
use redb::{ReadTransaction, ReadableDatabase, ReadableTable, ReadableTableMetadata};
|
||||
use std::{
|
||||
io::Write,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
std::thread_local! {
|
||||
static AFTER_CAPTURE: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct CaptureHook;
|
||||
#[cfg(test)]
|
||||
impl Drop for CaptureHook {
|
||||
fn drop(&mut self) {
|
||||
AFTER_CAPTURE.with(|hook| {
|
||||
hook.borrow_mut().take();
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn after_capture(hook: impl FnOnce() + 'static) -> CaptureHook {
|
||||
AFTER_CAPTURE.with(|slot| {
|
||||
assert!(slot.borrow().is_none(), "one capture hook per test thread");
|
||||
*slot.borrow_mut() = Some(Box::new(hook));
|
||||
});
|
||||
CaptureHook
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NativeSnapshotError {
|
||||
#[error(transparent)]
|
||||
Store(#[from] StoreError),
|
||||
#[error(transparent)]
|
||||
Snapshot(#[from] snapshot::SnapshotError),
|
||||
#[error("native snapshot Store lock poisoned")]
|
||||
Poisoned,
|
||||
#[error("native snapshot worker failed")]
|
||||
Worker(#[source] tokio::task::JoinError),
|
||||
}
|
||||
|
||||
pub struct NativeSnapshotView {
|
||||
// Drop the transaction before the owner so the Database stays open throughout the view.
|
||||
transaction: ReadTransaction,
|
||||
header: snapshot::Header,
|
||||
counts: [u64; 4],
|
||||
schema: u64,
|
||||
_owner: Arc<Mutex<Store>>,
|
||||
}
|
||||
impl NativeSnapshotView {
|
||||
pub(crate) fn acquire(owner: Arc<Mutex<Store>>) -> Result<Self, NativeSnapshotError> {
|
||||
let transaction = {
|
||||
let store = owner.lock().map_err(|_| NativeSnapshotError::Poisoned)?;
|
||||
if !store.writable {
|
||||
return Err(StoreError::WriteDisabled.into());
|
||||
}
|
||||
store.database.begin_read().map_err(StoreError::from)?
|
||||
};
|
||||
#[cfg(test)]
|
||||
if let Some(hook) = AFTER_CAPTURE.with(|slot| slot.borrow_mut().take()) {
|
||||
hook();
|
||||
}
|
||||
let (applied, membership) = application::native_application_state(&transaction)?;
|
||||
let (header, schema) = {
|
||||
let meta = transaction.open_table(crate::META).map_err(StoreError::from)?;
|
||||
let logs = transaction.open_table(crate::LOGS).map_err(StoreError::from)?;
|
||||
let committed = crate::committed::validate_committed(&meta, &logs)?;
|
||||
if let Some(applied) = applied {
|
||||
let committed = committed.ok_or(StoreError::CorruptRecord)?;
|
||||
if applied.index > committed.index || (applied.index == committed.index && applied != committed) {
|
||||
return Err(StoreError::CorruptRecord.into());
|
||||
}
|
||||
}
|
||||
let identity = meta
|
||||
.get("identity")
|
||||
.map_err(StoreError::from)?
|
||||
.ok_or(StoreError::CorruptRecord)?;
|
||||
let schema = crate::number(
|
||||
meta.get("schema")
|
||||
.map_err(StoreError::from)?
|
||||
.ok_or(StoreError::CorruptRecord)?
|
||||
.value(),
|
||||
)?;
|
||||
(
|
||||
snapshot::Header {
|
||||
cluster_id: crate::decode_identity(identity.value())?.cluster_id,
|
||||
applied,
|
||||
membership,
|
||||
binding: application::read_binding(&meta)?,
|
||||
},
|
||||
schema,
|
||||
)
|
||||
};
|
||||
let mut counts = [0; 4];
|
||||
for (index, definition) in [
|
||||
application::HEADS,
|
||||
application::VERSIONS,
|
||||
application::EVENTS,
|
||||
application::OPERATIONS,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
counts[index] = transaction
|
||||
.open_table(definition)
|
||||
.map_err(StoreError::from)?
|
||||
.len()
|
||||
.map_err(StoreError::from)?;
|
||||
}
|
||||
Ok(Self {
|
||||
transaction,
|
||||
header,
|
||||
counts,
|
||||
schema,
|
||||
_owner: owner,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stream_capability_artifact<W: Write>(
|
||||
self,
|
||||
writer: W,
|
||||
limits: snapshot::Limits,
|
||||
) -> Result<snapshot::CapabilityArtifact<W>, NativeSnapshotError> {
|
||||
if !matches!(self.schema, 6 | 7) {
|
||||
return Err(StoreError::UnsupportedSchema.into());
|
||||
}
|
||||
let output = snapshot::CapabilityOutput::new(writer, limits.total_bytes);
|
||||
Ok(self.stream(output, limits)?.finish())
|
||||
}
|
||||
|
||||
pub(crate) fn transaction(&self) -> &ReadTransaction {
|
||||
&self.transaction
|
||||
}
|
||||
|
||||
pub(crate) fn header(&self) -> &snapshot::Header {
|
||||
&self.header
|
||||
}
|
||||
|
||||
pub(crate) fn owner(&self) -> &Arc<Mutex<Store>> {
|
||||
&self._owner
|
||||
}
|
||||
|
||||
pub(crate) fn stream_local_capability<W: Write>(
|
||||
&self,
|
||||
writer: W,
|
||||
limits: snapshot::Limits,
|
||||
) -> Result<snapshot::CapabilityArtifact<W>, NativeSnapshotError> {
|
||||
if !matches!(self.schema, 6 | 7) {
|
||||
return Err(StoreError::UnsupportedSchema.into());
|
||||
}
|
||||
let output = snapshot::CapabilityOutput::new(writer, limits.total_bytes);
|
||||
Ok(self.stream_borrowed(output, limits)?.finish())
|
||||
}
|
||||
|
||||
pub fn stream<W: Write>(self, writer: W, limits: snapshot::Limits) -> Result<W, NativeSnapshotError> {
|
||||
self.stream_borrowed(writer, limits)
|
||||
}
|
||||
|
||||
fn stream_borrowed<W: Write>(&self, writer: W, limits: snapshot::Limits) -> Result<W, NativeSnapshotError> {
|
||||
let mut encoder = snapshot::Encoder::new(writer, &self.header, self.counts, limits)?;
|
||||
for (definition, section) in [
|
||||
(application::HEADS, snapshot::Section::Heads),
|
||||
(application::VERSIONS, snapshot::Section::Versions),
|
||||
(application::EVENTS, snapshot::Section::Events),
|
||||
(application::OPERATIONS, snapshot::Section::Operations),
|
||||
] {
|
||||
let table = self.transaction.open_table(definition).map_err(StoreError::from)?;
|
||||
for row in table.iter().map_err(StoreError::from)? {
|
||||
let (key, value) = row.map_err(StoreError::from)?;
|
||||
encoder.row(snapshot::Row {
|
||||
section,
|
||||
key: key.value(),
|
||||
value: value.value(),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(encoder.finish()?)
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
//! Bounded snapshot transport bytes; receive ownership follows the buffer into install.
|
||||
use std::{
|
||||
io::{self, Read, Seek, SeekFrom, Write},
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite, ReadBuf};
|
||||
|
||||
pub struct NativeSnapshotData {
|
||||
bytes: Vec<u8>,
|
||||
position: u64,
|
||||
limit: u64,
|
||||
pub(crate) receive_guard: Option<crate::raft_state_machine::ReceiveGuard>,
|
||||
}
|
||||
|
||||
fn boundary() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "native snapshot byte limit")
|
||||
}
|
||||
|
||||
impl NativeSnapshotData {
|
||||
pub fn new(max_bytes: u64) -> io::Result<Self> {
|
||||
if max_bytes == 0 || usize::try_from(max_bytes).is_err() {
|
||||
return Err(boundary());
|
||||
}
|
||||
Ok(Self {
|
||||
bytes: Vec::new(),
|
||||
position: 0,
|
||||
limit: max_bytes,
|
||||
receive_guard: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn as_slice(&self) -> &[u8] {
|
||||
&self.bytes
|
||||
}
|
||||
|
||||
pub(crate) fn attach_receive_guard(&mut self, guard: crate::raft_state_machine::ReceiveGuard) {
|
||||
self.receive_guard = Some(guard);
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for NativeSnapshotData {
|
||||
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
|
||||
let position = usize::try_from(self.position).map_err(|_| boundary())?;
|
||||
let available = self.bytes.get(position..).unwrap_or_default();
|
||||
let count = output.len().min(available.len());
|
||||
output[..count].copy_from_slice(&available[..count]);
|
||||
self.position = self
|
||||
.position
|
||||
.checked_add(u64::try_from(count).map_err(|_| boundary())?)
|
||||
.ok_or_else(boundary)?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for NativeSnapshotData {
|
||||
fn write(&mut self, input: &[u8]) -> io::Result<usize> {
|
||||
if input.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = u64::try_from(input.len()).map_err(|_| boundary())?;
|
||||
let end = self
|
||||
.position
|
||||
.checked_add(count)
|
||||
.filter(|end| *end <= self.limit)
|
||||
.ok_or_else(boundary)?;
|
||||
let position = usize::try_from(self.position).map_err(|_| boundary())?;
|
||||
let length = usize::try_from(end).map_err(|_| boundary())?;
|
||||
if length > self.bytes.len() {
|
||||
self.bytes
|
||||
.try_reserve_exact(length - self.bytes.len())
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::OutOfMemory, "native snapshot allocation"))?;
|
||||
self.bytes.resize(length, 0);
|
||||
}
|
||||
self.bytes[position..length].copy_from_slice(input);
|
||||
self.position = end;
|
||||
Ok(input.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for NativeSnapshotData {
|
||||
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
|
||||
let position = match from {
|
||||
SeekFrom::Start(position) => Some(position),
|
||||
SeekFrom::Current(offset) => self.position.checked_add_signed(offset),
|
||||
SeekFrom::End(offset) => u64::try_from(self.bytes.len())
|
||||
.map_err(|_| boundary())?
|
||||
.checked_add_signed(offset),
|
||||
}
|
||||
.filter(|position| *position <= self.limit)
|
||||
.ok_or_else(boundary)?;
|
||||
self.position = position;
|
||||
Ok(position)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for NativeSnapshotData {
|
||||
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, output: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
let position = match usize::try_from(this.position) {
|
||||
Ok(position) => position,
|
||||
Err(_) => return Poll::Ready(Err(boundary())),
|
||||
};
|
||||
let available = this.bytes.get(position..).unwrap_or_default();
|
||||
let count = output.remaining().min(available.len());
|
||||
let end = this
|
||||
.position
|
||||
.checked_add(u64::try_from(count).map_err(|_| boundary())?)
|
||||
.ok_or_else(boundary)?;
|
||||
output.put_slice(&available[..count]);
|
||||
this.position = end;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for NativeSnapshotData {
|
||||
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, input: &[u8]) -> Poll<io::Result<usize>> {
|
||||
Poll::Ready(Write::write(self.get_mut(), input))
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncSeek for NativeSnapshotData {
|
||||
fn start_seek(self: Pin<&mut Self>, from: SeekFrom) -> io::Result<()> {
|
||||
Seek::seek(self.get_mut(), from).map(|_| ())
|
||||
}
|
||||
|
||||
fn poll_complete(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<u64>> {
|
||||
Poll::Ready(Ok(self.position))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_snapshot_data_rejects_growth_before_mutation() {
|
||||
let mut data = NativeSnapshotData::new(4).expect("bounded snapshot");
|
||||
Write::write_all(&mut data, b"ab").expect("initial bytes");
|
||||
assert!(Write::write_all(&mut data, b"cde").is_err());
|
||||
assert_eq!(data.as_slice(), b"ab", "rejected write leaves bytes intact");
|
||||
assert_eq!(Seek::stream_position(&mut data).expect("position"), 2);
|
||||
Seek::seek(&mut data, SeekFrom::Start(4)).expect("seek exactly to limit");
|
||||
Write::write(&mut data, b"").expect("empty write");
|
||||
assert_eq!(data.as_slice(), b"ab", "empty write never allocates a gap");
|
||||
assert!(Write::write(&mut data, b"x").is_err());
|
||||
assert!(Seek::seek(&mut data, SeekFrom::Current(1)).is_err());
|
||||
assert!(Seek::seek(&mut data, SeekFrom::Current(i64::MIN)).is_err());
|
||||
assert_eq!(Seek::stream_position(&mut data).expect("unchanged position"), 4);
|
||||
Seek::seek(&mut data, SeekFrom::Start(3)).expect("bounded sparse position");
|
||||
Write::write_all(&mut data, b"z").expect("write ending exactly at limit");
|
||||
assert_eq!(data.as_slice(), b"ab\0z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_snapshot_data_checks_length_overflow() {
|
||||
assert!(NativeSnapshotData::new(0).is_err());
|
||||
if usize::BITS == 64 {
|
||||
let mut data = NativeSnapshotData::new(u64::MAX).expect("representable byte limit");
|
||||
Seek::seek(&mut data, SeekFrom::Start(u64::MAX)).expect("seek does not allocate");
|
||||
assert!(Write::write(&mut data, b"x").is_err());
|
||||
assert!(Seek::seek(&mut data, SeekFrom::Current(1)).is_err());
|
||||
assert!(data.as_slice().is_empty(), "overflow never allocates");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_snapshot_data_async_io_uses_the_same_bounds() {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
let mut data = NativeSnapshotData::new(4).expect("bounded async snapshot");
|
||||
AsyncWriteExt::write_all(&mut data, b"abcd").await.expect("write exact limit");
|
||||
assert!(AsyncWriteExt::write_all(&mut data, b"e").await.is_err());
|
||||
assert!(AsyncSeekExt::seek(&mut data, SeekFrom::Start(5)).await.is_err());
|
||||
AsyncSeekExt::seek(&mut data, SeekFrom::End(-2))
|
||||
.await
|
||||
.expect("rewind within snapshot");
|
||||
let mut output = Vec::new();
|
||||
AsyncReadExt::read_to_end(&mut data, &mut output)
|
||||
.await
|
||||
.expect("read snapshot suffix");
|
||||
assert_eq!(output, b"cd");
|
||||
AsyncWriteExt::shutdown(&mut data).await.expect("transport shutdown");
|
||||
assert_eq!(data.as_slice(), b"abcd");
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//! Validate isolated received bytes before one durable shared-owner publication.
|
||||
use crate::{
|
||||
CaptureMembership, CheckpointLimits, NativeLogWriter, NativeSnapshotData, StoreError, application, checkpoint_baseline,
|
||||
native_checkpoint, snapshot,
|
||||
};
|
||||
use redb::{ReadableDatabase, ReadableTable};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
struct Cancel(Arc<AtomicBool>);
|
||||
impl Drop for Cancel {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
struct Stage {
|
||||
database: redb::Database,
|
||||
_directory: tempfile::TempDir,
|
||||
baseline: checkpoint_baseline::BaselineMetadata,
|
||||
}
|
||||
fn stage(data: &NativeSnapshotData, limits: CheckpointLimits) -> Result<Stage, StoreError> {
|
||||
let bytes = data.as_slice();
|
||||
if u64::try_from(bytes.len()).map_err(|_| StoreError::ResourceLimit)?
|
||||
> limits.snapshot.total_bytes.checked_add(56).ok_or(StoreError::ResourceLimit)?
|
||||
{
|
||||
return Err(StoreError::ResourceLimit);
|
||||
}
|
||||
let manifest = snapshot::CapabilityManifest::decode(bytes.get(..56).ok_or(StoreError::CorruptRecord)?)
|
||||
.map_err(|_| StoreError::CorruptRecord)?;
|
||||
let directory = tempfile::tempdir()?;
|
||||
let database = native_checkpoint::stage_database(directory.path().join("received.redb"), limits.staging_bytes)?;
|
||||
let tx = database.begin_write()?;
|
||||
for definition in [
|
||||
checkpoint_baseline::HEADS,
|
||||
checkpoint_baseline::VERSIONS,
|
||||
checkpoint_baseline::EVENTS,
|
||||
checkpoint_baseline::OPERATIONS,
|
||||
] {
|
||||
tx.open_table(definition)?;
|
||||
}
|
||||
let header = snapshot::decode_capability_artifact(
|
||||
snapshot::CapabilityArtifact {
|
||||
manifest,
|
||||
payload: &bytes[56..],
|
||||
},
|
||||
&mut native_checkpoint::StageSink(&tx),
|
||||
limits.snapshot,
|
||||
)
|
||||
.map_err(|_| StoreError::CorruptRecord)?;
|
||||
let baseline = checkpoint_baseline::BaselineMetadata {
|
||||
generation: 1,
|
||||
header,
|
||||
limits,
|
||||
};
|
||||
let encoded = checkpoint_baseline::encode_metadata(&baseline)?;
|
||||
{
|
||||
let mut meta = tx.open_table(crate::META)?;
|
||||
meta.insert("schema", 7u64.to_be_bytes().as_slice())?;
|
||||
meta.insert(checkpoint_baseline::META_KEY, encoded.as_slice())?;
|
||||
}
|
||||
tx.commit().map_err(StoreError::StagingCommit)?;
|
||||
checkpoint_baseline::validate_baseline_with_budget(&database.begin_read()?, &baseline, limits.staging_bytes)?;
|
||||
Ok(Stage {
|
||||
database,
|
||||
_directory: directory,
|
||||
baseline,
|
||||
})
|
||||
}
|
||||
pub(crate) async fn install(
|
||||
writer: NativeLogWriter,
|
||||
limits: CheckpointLimits,
|
||||
meta: openraft::SnapshotMeta<u64, crate::CaptureNode>,
|
||||
data: Box<NativeSnapshotData>,
|
||||
) -> Result<(), StoreError> {
|
||||
if data
|
||||
.receive_guard
|
||||
.as_ref()
|
||||
.is_none_or(|guard| !guard.belongs_to(&writer.snapshot_receives))
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let (mut stage, data) = tokio::task::spawn_blocking(move || Ok::<_, StoreError>((stage(&data, limits)?, data)))
|
||||
.await
|
||||
.map_err(|_| StoreError::CorruptRecord)??;
|
||||
if meta.last_log_id.map(crate::LogId::from) != stage.baseline.header.applied {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if CaptureMembership::try_from(&meta.last_membership)? != stage.baseline.header.membership {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let ordering = writer.ordering.clone().lock_owned().await;
|
||||
let cancel = Cancel(Arc::new(AtomicBool::new(false)));
|
||||
let cancelled = cancel.0.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _ordering = ordering;
|
||||
let _data = data;
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
return Err(StoreError::WriteDisabled);
|
||||
}
|
||||
let mut store = writer.store.lock().map_err(|_| StoreError::CorruptRecord)?;
|
||||
let read = store.database.begin_read()?;
|
||||
let old_meta = read.open_table(crate::META)?;
|
||||
let identity = crate::decode_identity(old_meta.get("identity")?.ok_or(StoreError::CorruptRecord)?.value())?;
|
||||
if identity.cluster_id != stage.baseline.header.cluster_id
|
||||
|| application::read_binding(&old_meta)? != stage.baseline.header.binding
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let old_applied = application::read_applied(&old_meta)?;
|
||||
let old_logs = read.open_table(crate::LOGS)?;
|
||||
let committed = crate::committed::validate_committed(&old_meta, &old_logs)?;
|
||||
let cut = stage.baseline.header.applied;
|
||||
if old_applied.is_some_and(|id| cut.is_none_or(|cut| id.index > cut.index || (id.index == cut.index && id != cut))) {
|
||||
return Err(StoreError::CommittedRegression);
|
||||
}
|
||||
let compatible = match cut {
|
||||
None => true,
|
||||
Some(cut) => {
|
||||
let at_cut = old_logs
|
||||
.get(cut.index)?
|
||||
.map(|v| crate::decode_log(v.value()).map(|v| v.id))
|
||||
.transpose()?
|
||||
.or(checkpoint_baseline::read_baseline_cut(&old_meta)?.filter(|id| id.index == cut.index));
|
||||
if committed
|
||||
.is_some_and(|id| id.index >= cut.index && (at_cut != Some(cut) || (id.index == cut.index && id != cut)))
|
||||
{
|
||||
return Err(StoreError::CommittedRegression);
|
||||
}
|
||||
at_cut == Some(cut)
|
||||
}
|
||||
};
|
||||
let previous = checkpoint_baseline::read_metadata(&old_meta)?;
|
||||
stage.baseline.generation =
|
||||
previous.map_or(Ok(1), |old| old.generation.checked_add(1).ok_or(StoreError::ResourceLimit))?;
|
||||
drop(old_logs);
|
||||
drop(old_meta);
|
||||
drop(read);
|
||||
let source = stage.database.begin_read()?;
|
||||
let tx = store.write_transaction()?;
|
||||
for (baseline, live) in [
|
||||
(checkpoint_baseline::HEADS, application::HEADS),
|
||||
(checkpoint_baseline::VERSIONS, application::VERSIONS),
|
||||
(checkpoint_baseline::EVENTS, application::EVENTS),
|
||||
(checkpoint_baseline::OPERATIONS, application::OPERATIONS),
|
||||
] {
|
||||
let table = source.open_table(baseline)?;
|
||||
for target in [baseline, live] {
|
||||
let mut target = tx.open_table(target)?;
|
||||
target.retain(|_, _| false)?;
|
||||
for row in table.iter()? {
|
||||
let (key, value) = row?;
|
||||
target.insert(key.value(), value.value())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut index = tx.open_table(crate::source_page::EVENT_SEQUENCE)?;
|
||||
index.retain(|_, _| false)?;
|
||||
}
|
||||
for row in source.open_table(checkpoint_baseline::EVENTS)?.iter()? {
|
||||
let (key, value) = row?;
|
||||
let event = application::decode_event(value.value())?;
|
||||
crate::source_page::insert_event_index(
|
||||
&tx,
|
||||
event.decision_log_id,
|
||||
key.value().try_into().map_err(|_| StoreError::CorruptRecord)?,
|
||||
)?;
|
||||
}
|
||||
let tail = {
|
||||
let mut logs = tx.open_table(crate::LOGS)?;
|
||||
logs.retain(|index, _| compatible && cut.is_none_or(|cut| index > cut.index))?;
|
||||
logs.last()?
|
||||
.map(|(_, v)| crate::decode_log(v.value()).map(|v| v.id))
|
||||
.transpose()?
|
||||
.or(cut)
|
||||
};
|
||||
tx.open_table(application::APPLY_RESULTS)?.retain(|_, _| false)?;
|
||||
{
|
||||
let mut target = tx.open_table(crate::META)?;
|
||||
target.insert("schema", 7u64.to_be_bytes().as_slice())?;
|
||||
target.insert(
|
||||
checkpoint_baseline::META_KEY,
|
||||
checkpoint_baseline::encode_metadata(&stage.baseline)?.as_slice(),
|
||||
)?;
|
||||
let mut applied = vec![0];
|
||||
if let Some(cut) = cut {
|
||||
target.insert(checkpoint_baseline::PURGED_KEY, checkpoint_baseline::encode_purged(cut).as_slice())?;
|
||||
applied = vec![1];
|
||||
applied.extend(crate::encode_id(cut));
|
||||
} else {
|
||||
target.remove(checkpoint_baseline::PURGED_KEY)?;
|
||||
}
|
||||
target.insert("last_applied", applied.as_slice())?;
|
||||
target.insert("native_membership", stage.baseline.header.membership.encode()?.as_slice())?;
|
||||
if let Some(tail) = tail {
|
||||
target.insert("tail", crate::encode_id(tail).as_slice())?;
|
||||
} else {
|
||||
target.remove("tail")?;
|
||||
}
|
||||
let committed = committed.filter(|id| cut.is_none_or(|cut| id.index > cut.index)).or(cut);
|
||||
if let Some(committed) = committed {
|
||||
target.insert("committed", crate::encode_id(committed).as_slice())?;
|
||||
} else {
|
||||
target.remove("committed")?;
|
||||
}
|
||||
crate::raft_state_machine::publish_meta(&mut target, &stage.baseline, meta)?;
|
||||
}
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
return Err(StoreError::WriteDisabled);
|
||||
}
|
||||
store.commit(tx)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| StoreError::CorruptRecord)?
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
//! Atomic installation failures over real native stores and disk fault backend.
|
||||
use super::raft_state_machine_tests::{Fixture, entries, entry, id, install, receive, snapshot_bytes};
|
||||
use super::*;
|
||||
use openraft::{
|
||||
RaftLogReader, RaftSnapshotBuilder,
|
||||
storage::{RaftLogStorage, RaftLogStorageExt, RaftStateMachine},
|
||||
};
|
||||
|
||||
async fn media_target() -> (Fixture, tests::CrashBackend) {
|
||||
let mut f = Fixture::new(1);
|
||||
f.apply(entries(1)).await;
|
||||
f.logs
|
||||
.save_vote(&openraft::Vote::new_committed(9, 1))
|
||||
.await
|
||||
.expect("preserved target vote");
|
||||
let Fixture {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
} = f;
|
||||
drop(sm);
|
||||
drop(logs);
|
||||
drop(writer);
|
||||
let backend = tests::CrashBackend::new(&directory.path().join("db"));
|
||||
let writer = NativeLogWriter::new(Store::from_database(backend.database(), identity).expect("actual redb fault target"));
|
||||
let logs = NativeRaftLogStore::new(writer.clone());
|
||||
let sm = NativeRaftStateMachine::new(writer.clone(), CheckpointLimits::default()).expect("fault target state machine");
|
||||
(
|
||||
Fixture {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
},
|
||||
backend,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_snapshot_install_corruption_preserves_state() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let mut target = Fixture::new(1);
|
||||
target.apply(entries(1)).await;
|
||||
let before = target.page().await;
|
||||
let state = target.sm.applied_state().await.expect("old applied state");
|
||||
let mut corrupted = bytes.clone();
|
||||
*corrupted.last_mut().expect("nonempty snapshot") ^= 1;
|
||||
for payload in [corrupted, bytes[..bytes.len() - 1].to_vec()] {
|
||||
let stable = std::fs::read(target.directory.path().join("db")).expect("old stable bytes");
|
||||
let data = receive(&mut target.sm, &payload).await;
|
||||
assert!(
|
||||
target.sm.install_snapshot(&snapshot.meta, data).await.is_err(),
|
||||
"SNAPSHOT_INSTALL_CORRUPT_FRAME_REJECTED"
|
||||
);
|
||||
assert_eq!(target.sm.applied_state().await.expect("unchanged applied state"), state);
|
||||
assert_eq!(target.page().await, before, "SNAPSHOT_INSTALL_CORRUPTION_NO_PARTIAL_TABLES");
|
||||
assert_eq!(std::fs::read(target.directory.path().join("db")).expect("unchanged stable bytes"), stable);
|
||||
assert!(
|
||||
target
|
||||
.sm
|
||||
.get_current_snapshot()
|
||||
.await
|
||||
.expect("unpublished corrupt snapshot")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
install(&mut target.sm, &mut snapshot).await;
|
||||
assert_eq!(target.page().await, source.page().await, "SNAPSHOT_INSTALL_RETRY_AFTER_CORRUPT_RECEIVE");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_snapshot_install_stale_cut_rejected() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(1)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let mut target = Fixture::new(1);
|
||||
target.apply(entries(3)).await;
|
||||
let current = target.build().await.meta;
|
||||
let events = target.page().await;
|
||||
let data = receive(&mut target.sm, &bytes).await;
|
||||
assert!(
|
||||
target.sm.install_snapshot(&snapshot.meta, data).await.is_err(),
|
||||
"SNAPSHOT_INSTALL_NEVER_ROLLS_BACK_APPLIED_STATE"
|
||||
);
|
||||
assert_eq!(target.sm.applied_state().await.expect("non-regressed applied").0, Some(id(3).into()));
|
||||
assert_eq!(
|
||||
target
|
||||
.sm
|
||||
.get_current_snapshot()
|
||||
.await
|
||||
.expect("old current snapshot")
|
||||
.expect("present")
|
||||
.meta,
|
||||
current
|
||||
);
|
||||
assert_eq!(target.page().await, events);
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let mut target = Fixture::new(1);
|
||||
let mut batch = entries(3);
|
||||
for entry in &mut batch[2..] {
|
||||
entry.log_id = LogId {
|
||||
term: 9,
|
||||
..id(entry.log_id.index)
|
||||
}
|
||||
.into();
|
||||
}
|
||||
target
|
||||
.logs
|
||||
.blocking_append(batch)
|
||||
.await
|
||||
.expect("conflicting committed suffix logs");
|
||||
target.sm.apply(entries(1)).await.expect("existing applied prefix");
|
||||
target
|
||||
.logs
|
||||
.save_committed(Some(LogId { term: 9, ..id(3) }.into()))
|
||||
.await
|
||||
.expect("protect conflicting committed suffix");
|
||||
let before = target
|
||||
.logs
|
||||
.get_log_reader()
|
||||
.await
|
||||
.try_get_log_entries(..)
|
||||
.await
|
||||
.expect("protected logs");
|
||||
let data = receive(&mut target.sm, &bytes).await;
|
||||
assert!(
|
||||
target.sm.install_snapshot(&snapshot.meta, data).await.is_err(),
|
||||
"SNAPSHOT_INSTALL_NEVER_DISCARDS_COMMITTED_CONFLICT"
|
||||
);
|
||||
assert_eq!(
|
||||
target
|
||||
.logs
|
||||
.get_log_reader()
|
||||
.await
|
||||
.try_get_log_entries(..)
|
||||
.await
|
||||
.expect("unchanged protected logs"),
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn native_snapshot_install_cancelled_owner() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let (mut target, backend) = media_target().await;
|
||||
let data = receive(&mut target.sm, &bytes).await;
|
||||
let (entered, resume) = backend.pause_sync(1, false);
|
||||
let mut installing = target.sm.clone();
|
||||
let meta = snapshot.meta.clone();
|
||||
let task = tokio::spawn(async move { installing.install_snapshot(&meta, data).await });
|
||||
entered.wait();
|
||||
assert!(!task.is_finished(), "SNAPSHOT_INSTALL_NOT_ACKED_BEFORE_SYNC");
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("cancelled install waiter").is_cancelled());
|
||||
assert!(
|
||||
target.writer.ordering.try_lock().is_err(),
|
||||
"SNAPSHOT_INSTALL_CANCELLED_WAITER_RETAINS_OWNER"
|
||||
);
|
||||
resume.wait();
|
||||
// Acquiring the production owner waits for the abandoned blocking commit.
|
||||
drop(target.writer.ordering.lock().await);
|
||||
assert_eq!(
|
||||
target.sm.applied_state().await.expect("completed background install").0,
|
||||
Some(id(2).into()),
|
||||
"SNAPSHOT_INSTALL_COMMIT_NOT_INTERRUPTED_BY_WAITER_CANCEL"
|
||||
);
|
||||
let vote = target.logs.read_vote().await.expect("preserved vote");
|
||||
assert_eq!(vote, Some(openraft::Vote::new_committed(9, 1)));
|
||||
backend.cut();
|
||||
let Fixture {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
} = target;
|
||||
drop(sm);
|
||||
drop(logs);
|
||||
drop(writer);
|
||||
let writer = NativeLogWriter::new(Store::open(directory.path().join("db"), identity).expect("reopen cancelled installation"));
|
||||
let mut sm =
|
||||
NativeRaftStateMachine::new(writer.clone(), CheckpointLimits::default()).expect("reopen installed state machine");
|
||||
assert_eq!(
|
||||
sm.get_current_snapshot()
|
||||
.await
|
||||
.expect("completed durable snapshot")
|
||||
.expect("present")
|
||||
.meta,
|
||||
snapshot.meta
|
||||
);
|
||||
assert_eq!(
|
||||
writer.created_events_page(None, 256).await.expect("installed events"),
|
||||
source.page().await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn native_snapshot_install_power_cut_atomic() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let new_events = source.page().await;
|
||||
for after_sync in [false, true] {
|
||||
let (mut target, backend) = media_target().await;
|
||||
let old_events = target.page().await;
|
||||
let data = receive(&mut target.sm, &bytes).await;
|
||||
let (entered, resume) = backend.pause_sync(1, after_sync);
|
||||
let meta = snapshot.meta.clone();
|
||||
let mut installing = target.sm.clone();
|
||||
let task = tokio::spawn(async move { installing.install_snapshot(&meta, data).await });
|
||||
entered.wait();
|
||||
backend.cut();
|
||||
resume.wait();
|
||||
assert!(
|
||||
task.await.expect("faulted install task").is_err(),
|
||||
"SNAPSHOT_INSTALL_POWER_CUT_NO_FALSE_ACK"
|
||||
);
|
||||
assert!(
|
||||
target.sm.get_current_snapshot().await.is_err(),
|
||||
"SNAPSHOT_INSTALL_FENCED_HANDLE_CANNOT_EXPORT"
|
||||
);
|
||||
let Fixture {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
} = target;
|
||||
drop(sm);
|
||||
drop(logs);
|
||||
drop(writer);
|
||||
let writer =
|
||||
NativeLogWriter::new(Store::open(directory.path().join("db"), identity).expect("atomic installation recovery"));
|
||||
let mut logs = NativeRaftLogStore::new(writer.clone());
|
||||
let mut sm = NativeRaftStateMachine::new(writer.clone(), CheckpointLimits::default()).expect("recovered SM");
|
||||
let events = writer
|
||||
.created_events_page(None, 256)
|
||||
.await
|
||||
.expect("complete recovered tables");
|
||||
let cut = sm.applied_state().await.expect("recovered applied marker").0;
|
||||
if events == old_events {
|
||||
assert_eq!(cut, Some(id(1).into()), "SNAPSHOT_INSTALL_OLD_TABLES_OLD_MARKER");
|
||||
assert!(sm.get_current_snapshot().await.expect("old snapshot selection").is_none());
|
||||
} else {
|
||||
assert_eq!(events, new_events, "SNAPSHOT_INSTALL_ALL_TABLES_OLD_OR_NEW");
|
||||
assert_eq!(cut, Some(id(2).into()), "SNAPSHOT_INSTALL_NEW_TABLES_NEW_MARKER");
|
||||
let mut current = sm
|
||||
.get_current_snapshot()
|
||||
.await
|
||||
.expect("new snapshot selection")
|
||||
.expect("new snapshot published");
|
||||
assert_eq!(current.meta, snapshot.meta);
|
||||
assert_eq!(snapshot_bytes(&mut current).await, bytes);
|
||||
}
|
||||
assert_eq!(writer.durable_identity().await.expect("recovered local identity"), identity);
|
||||
assert_eq!(
|
||||
logs.read_vote().await.expect("recovered local vote"),
|
||||
Some(openraft::Vote::new_committed(9, 1))
|
||||
);
|
||||
let state = logs.get_log_state().await.expect("coherent recovered log state");
|
||||
assert!(
|
||||
state
|
||||
.last_log_id
|
||||
.is_some_and(|last| cut.is_some_and(|applied| last.index >= applied.index)),
|
||||
"SNAPSHOT_INSTALL_LOG_STATE_COVERS_APPLIED_CUT"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_snapshot_install_ack_survives_power_cut() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let (mut target, backend) = media_target().await;
|
||||
install(&mut target.sm, &mut snapshot).await;
|
||||
backend.cut();
|
||||
let Fixture {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
} = target;
|
||||
drop(sm);
|
||||
drop(logs);
|
||||
drop(writer);
|
||||
let writer = NativeLogWriter::new(
|
||||
Store::open(directory.path().join("db"), identity).expect("acknowledged installation survives power cut"),
|
||||
);
|
||||
let mut logs = NativeRaftLogStore::new(writer.clone());
|
||||
let mut sm = NativeRaftStateMachine::new(writer.clone(), CheckpointLimits::default()).expect("reopened durable SM");
|
||||
let mut current = sm
|
||||
.get_current_snapshot()
|
||||
.await
|
||||
.expect("durable current snapshot")
|
||||
.expect("present");
|
||||
assert_eq!(current.meta, snapshot.meta, "SNAPSHOT_INSTALL_ACK_COMPLETE_META_DURABLE");
|
||||
assert_eq!(snapshot_bytes(&mut current).await, bytes);
|
||||
assert_eq!(
|
||||
writer.created_events_page(None, 256).await.expect("durable installed events"),
|
||||
source.page().await
|
||||
);
|
||||
logs.blocking_append(vec![entry(3)]).await.expect("append after ACK recovery");
|
||||
sm.apply(vec![entry(3)]).await.expect("apply after ACK recovery");
|
||||
assert_eq!(sm.applied_state().await.expect("continued state").0, Some(id(3).into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_snapshot_install_purge_without_pending_rejected() {
|
||||
let mut target = Fixture::new(1);
|
||||
target.apply(entries(1)).await;
|
||||
let before = target.page().await;
|
||||
let bytes = std::fs::read(target.directory.path().join("db")).expect("old unauthorized purge bytes");
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(2), target.logs.purge(id(3).into()))
|
||||
.await
|
||||
.expect("no pending receiver means purge cannot wait for hypothetical installation");
|
||||
assert!(result.is_err(), "SNAPSHOT_PURGE_WITHOUT_PENDING_FAILS_IMMEDIATELY");
|
||||
assert_eq!(target.page().await, before);
|
||||
assert_eq!(
|
||||
std::fs::read(target.directory.path().join("db")).expect("rejected purge preserves bytes"),
|
||||
bytes
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_pending_purge(writer: &NativeLogWriter) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while writer.snapshot_receives.waiters.load(std::sync::atomic::Ordering::Acquire) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("actual trait purge reached pending-receive wait");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_snapshot_install_pending_purge_waits_for_install() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(3)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let mut target = Fixture::new(1);
|
||||
target.apply(entries(1)).await;
|
||||
let data = receive(&mut target.sm, &bytes).await;
|
||||
let mut purging = target.logs.clone();
|
||||
let purge = tokio::spawn(async move { purging.purge(id(3).into()).await });
|
||||
wait_for_pending_purge(&target.writer).await;
|
||||
assert!(!purge.is_finished(), "SNAPSHOT_PURGE_WAITS_FOR_INCOMING_CUT_BEYOND_LOCAL_TAIL");
|
||||
target
|
||||
.sm
|
||||
.install_snapshot(&snapshot.meta, data)
|
||||
.await
|
||||
.expect("actual install while OpenRaft-style purge waits");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), purge)
|
||||
.await
|
||||
.expect("pending purge wakes after install")
|
||||
.expect("purge task")
|
||||
.expect("installed checkpoint now authorizes exact purge");
|
||||
assert_eq!(
|
||||
target
|
||||
.logs
|
||||
.get_log_state()
|
||||
.await
|
||||
.expect("installed log state")
|
||||
.last_purged_log_id,
|
||||
Some(id(3).into()),
|
||||
"SNAPSHOT_PENDING_PURGE_USES_DURABLE_INSTALLED_CUT"
|
||||
);
|
||||
assert_eq!(target.page().await, source.page().await);
|
||||
assert_eq!(
|
||||
target
|
||||
.writer
|
||||
.snapshot_receives
|
||||
.waiters
|
||||
.load(std::sync::atomic::Ordering::Acquire),
|
||||
0,
|
||||
"SNAPSHOT_PURGE_WAIT_OWNER_RELEASED"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_snapshot_install_cancelled_receive_wakes_purge() {
|
||||
let mut target = Fixture::new(1);
|
||||
target.apply(entries(1)).await;
|
||||
let page = target.page().await;
|
||||
let before = std::fs::read(target.directory.path().join("db")).expect("stable target before cancelled receive");
|
||||
let data = target.sm.begin_receiving_snapshot().await.expect("actual pending receiver");
|
||||
let mut purging = target.logs.clone();
|
||||
let purge = tokio::spawn(async move { purging.purge(id(3).into()).await });
|
||||
wait_for_pending_purge(&target.writer).await;
|
||||
assert!(!purge.is_finished(), "SNAPSHOT_CANCELLED_RECEIVE_TEST_HAS_REAL_WAITING_PURGE");
|
||||
drop(data);
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), purge)
|
||||
.await
|
||||
.expect("cancelled receive wakes pending purge")
|
||||
.expect("purge task")
|
||||
.is_err(),
|
||||
"SNAPSHOT_CANCELLED_RECEIVE_CANNOT_AUTHORIZE_PURGE"
|
||||
);
|
||||
assert_eq!(target.page().await, page);
|
||||
assert_eq!(
|
||||
std::fs::read(target.directory.path().join("db")).expect("retained cancelled-receive state"),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
target
|
||||
.writer
|
||||
.snapshot_receives
|
||||
.waiters
|
||||
.load(std::sync::atomic::Ordering::Acquire),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_snapshot_install_failed_receive_wakes_purge() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(3)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let mut target = Fixture::new(1);
|
||||
target.apply(entries(1)).await;
|
||||
let page = target.page().await;
|
||||
let before = std::fs::read(target.directory.path().join("db")).expect("stable target before failed install");
|
||||
let data = receive(&mut target.sm, &bytes).await;
|
||||
let mut purging = target.logs.clone();
|
||||
let purge = tokio::spawn(async move { purging.purge(id(3).into()).await });
|
||||
wait_for_pending_purge(&target.writer).await;
|
||||
assert!(!purge.is_finished(), "SNAPSHOT_FAILED_INSTALL_TEST_HAS_REAL_WAITING_PURGE");
|
||||
let mut wrong = snapshot.meta.clone();
|
||||
wrong.last_log_id = Some(LogId { term: 9, ..id(3) }.into());
|
||||
assert!(
|
||||
target.sm.install_snapshot(&wrong, data).await.is_err(),
|
||||
"SNAPSHOT_INVALID_META_INSTALL_REJECTED"
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), purge)
|
||||
.await
|
||||
.expect("failed install wakes pending purge")
|
||||
.expect("purge task")
|
||||
.is_err(),
|
||||
"SNAPSHOT_FAILED_INSTALL_CANNOT_AUTHORIZE_PURGE"
|
||||
);
|
||||
assert_eq!(target.page().await, page);
|
||||
assert_eq!(
|
||||
std::fs::read(target.directory.path().join("db")).expect("retained failed-install state"),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
target
|
||||
.writer
|
||||
.snapshot_receives
|
||||
.waiters
|
||||
.load(std::sync::atomic::Ordering::Acquire),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_snapshot_install_completed_between_purge_query_and_wait() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let data_bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let mut target = Fixture::new(1);
|
||||
let data = receive(&mut target.sm, &data_bytes).await;
|
||||
let entered = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
let resume = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
*target.writer.snapshot_receives.purge_recheck.lock().expect("test pause") = Some((entered.clone(), resume.clone()));
|
||||
let mut logs = target.logs.clone();
|
||||
let purge = tokio::spawn(async move { logs.purge(id(2).into()).await });
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
|
||||
.await
|
||||
.expect("purge failed coverage under owner before continuation");
|
||||
target
|
||||
.sm
|
||||
.install_snapshot(&snapshot.meta, data)
|
||||
.await
|
||||
.expect("install completes before purge continuation");
|
||||
resume.notify_one();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), purge)
|
||||
.await
|
||||
.expect("completed install wake retained")
|
||||
.expect("purge task")
|
||||
.expect("old StaleCheckpoint rechecked after install");
|
||||
assert_eq!(
|
||||
target.page().await,
|
||||
source.page().await,
|
||||
"SNAPSHOT_COMPLETED_INSTALL_RECHECKS_STALE_PURGE"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_snapshot_install_empty_baseline_pending_purge() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let mut target = Fixture::new(1);
|
||||
target.sm.build_snapshot().await.expect("real local empty baseline");
|
||||
let data = receive(&mut target.sm, &bytes).await;
|
||||
let mut logs = target.logs.clone();
|
||||
let purge = tokio::spawn(async move { logs.purge(id(2).into()).await });
|
||||
wait_for_pending_purge(&target.writer).await;
|
||||
target
|
||||
.sm
|
||||
.install_snapshot(&snapshot.meta, data)
|
||||
.await
|
||||
.expect("install supersedes empty baseline");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), purge)
|
||||
.await
|
||||
.expect("empty coverage wake")
|
||||
.expect("purge task")
|
||||
.expect("new covered purge");
|
||||
assert_eq!(
|
||||
target.page().await,
|
||||
source.page().await,
|
||||
"SNAPSHOT_EMPTY_BASELINE_IS_UNCOVERED_NOT_CORRUPT"
|
||||
);
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
//! Validated capture peer metadata and its independent CNOD v1 codec.
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeStruct};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CaptureNode {
|
||||
rpc_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CaptureNodeError {
|
||||
#[error("invalid RPC URI")]
|
||||
InvalidRpcUri,
|
||||
#[error("invalid RPC URI")]
|
||||
InvalidRpcUriParse(#[source] url::ParseError),
|
||||
#[error("RPC URI too long")]
|
||||
RpcUriTooLong,
|
||||
#[error("invalid node magic")]
|
||||
InvalidNodeMagic,
|
||||
#[error("unsupported node version")]
|
||||
UnsupportedNodeVersion,
|
||||
#[error("invalid node length")]
|
||||
InvalidNodeLength,
|
||||
#[error("truncated node record")]
|
||||
TruncatedNodeRecord,
|
||||
#[error("invalid node UTF-8")]
|
||||
InvalidNodeUtf8(#[source] std::str::Utf8Error),
|
||||
#[error("trailing node bytes")]
|
||||
TrailingNodeBytes,
|
||||
}
|
||||
impl CaptureNodeError {
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidRpcUri | Self::InvalidRpcUriParse(_) => "invalid_rpc_uri",
|
||||
Self::RpcUriTooLong => "rpc_uri_too_long",
|
||||
Self::InvalidNodeMagic => "invalid_node_magic",
|
||||
Self::UnsupportedNodeVersion => "unsupported_node_version",
|
||||
Self::InvalidNodeLength => "invalid_node_length",
|
||||
Self::TruncatedNodeRecord => "truncated_node_record",
|
||||
Self::InvalidNodeUtf8(_) => "invalid_node_utf8",
|
||||
Self::TrailingNodeBytes => "trailing_node_bytes",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl CaptureNode {
|
||||
pub fn new(rpc_uri: String) -> Result<Self, CaptureNodeError> {
|
||||
let node = Self { rpc_uri };
|
||||
node.validate()?;
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
pub fn rpc_uri(&self) -> &str {
|
||||
&self.rpc_uri
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), CaptureNodeError> {
|
||||
let uri = &self.rpc_uri;
|
||||
if uri.len() > 4096 {
|
||||
return Err(CaptureNodeError::RpcUriTooLong);
|
||||
}
|
||||
if uri.is_empty() || uri.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\\') {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
let rest = uri
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| uri.strip_prefix("https://"))
|
||||
.ok_or(CaptureNodeError::InvalidRpcUri)?;
|
||||
if uri.contains('?') || uri.contains('#') {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
|
||||
// An empty suffix after the first slash is the only allowed path.
|
||||
if !path.is_empty() || authority.contains('@') {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
let (host, port) = if authority.starts_with('[') {
|
||||
let end = authority.find(']').ok_or(CaptureNodeError::InvalidRpcUri)?;
|
||||
let port = authority
|
||||
.get(end + 1..)
|
||||
.and_then(|suffix| suffix.strip_prefix(':'))
|
||||
.ok_or(CaptureNodeError::InvalidRpcUri)?;
|
||||
(&authority[..=end], port)
|
||||
} else {
|
||||
authority.split_once(':').ok_or(CaptureNodeError::InvalidRpcUri)?
|
||||
};
|
||||
if host.is_empty() || port.is_empty() || !port.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
let port = port.parse::<u16>().map_err(|_| CaptureNodeError::InvalidRpcUri)?;
|
||||
if port == 0 {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
let parsed_url = url::Url::parse(uri).map_err(CaptureNodeError::InvalidRpcUriParse)?;
|
||||
if parsed_url.host().is_none() {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Result<Vec<u8>, CaptureNodeError> {
|
||||
self.validate()?;
|
||||
let length = u32::try_from(self.rpc_uri.len()).map_err(|_| CaptureNodeError::InvalidNodeLength)?;
|
||||
let mut bytes = b"CNOD\x01".to_vec();
|
||||
bytes.extend_from_slice(&length.to_be_bytes());
|
||||
bytes.extend_from_slice(self.rpc_uri.as_bytes());
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, CaptureNodeError> {
|
||||
if bytes.len() < 9 {
|
||||
return Err(CaptureNodeError::TruncatedNodeRecord);
|
||||
}
|
||||
if &bytes[..4] != b"CNOD" {
|
||||
return Err(CaptureNodeError::InvalidNodeMagic);
|
||||
}
|
||||
if bytes[4] != 1 {
|
||||
return Err(CaptureNodeError::UnsupportedNodeVersion);
|
||||
}
|
||||
let length = u32::from_be_bytes(bytes[5..9].try_into().map_err(|_| CaptureNodeError::InvalidNodeLength)?);
|
||||
if !(1..=4096).contains(&length) {
|
||||
return Err(CaptureNodeError::InvalidNodeLength);
|
||||
}
|
||||
let length = usize::try_from(length).map_err(|_| CaptureNodeError::InvalidNodeLength)?;
|
||||
let end = 9_usize.checked_add(length).ok_or(CaptureNodeError::InvalidNodeLength)?;
|
||||
if bytes.len() < end {
|
||||
return Err(CaptureNodeError::TruncatedNodeRecord);
|
||||
}
|
||||
if bytes.len() != end {
|
||||
return Err(CaptureNodeError::TrailingNodeBytes);
|
||||
}
|
||||
let uri = std::str::from_utf8(&bytes[9..end]).map_err(CaptureNodeError::InvalidNodeUtf8)?;
|
||||
Self::new(uri.to_owned())
|
||||
}
|
||||
}
|
||||
impl Serialize for CaptureNode {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.validate().map_err(serde::ser::Error::custom)?;
|
||||
let mut state = serializer.serialize_struct("CaptureNode", 1)?;
|
||||
state.serialize_field("rpc_uri", &self.rpc_uri)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for CaptureNode {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Fields {
|
||||
rpc_uri: String,
|
||||
}
|
||||
let fields = Fields::deserialize(deserializer).map_err(|_| serde::de::Error::custom("invalid capture node fields"))?;
|
||||
Self::new(fields.rpc_uri).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::error::Error;
|
||||
|
||||
fn record(payload: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = b"CNOD\x01".to_vec();
|
||||
bytes.extend_from_slice(&u32::try_from(payload.len()).expect("test payload length").to_be_bytes());
|
||||
bytes.extend_from_slice(payload);
|
||||
bytes
|
||||
}
|
||||
fn reject_uri(uri: &str) {
|
||||
let result = CaptureNode::new(uri.to_owned());
|
||||
assert!(result.is_err(), "URI should reject: {uri:?}");
|
||||
assert_eq!(result.expect_err("rejected URI").code(), "invalid_rpc_uri");
|
||||
}
|
||||
fn reject_record(bytes: &[u8], code: &str) {
|
||||
let result = CaptureNode::decode(bytes);
|
||||
assert!(result.is_err(), "record should reject with {code}");
|
||||
assert_eq!(result.expect_err("rejected record").code(), code);
|
||||
}
|
||||
#[test]
|
||||
fn actual_openraft_node_bound() {
|
||||
fn require_node<T: openraft::Node>() {}
|
||||
require_node::<CaptureNode>();
|
||||
assert_eq!(CaptureNode::default().rpc_uri(), "");
|
||||
}
|
||||
#[test]
|
||||
fn default_and_empty_rejected_at_every_boundary() {
|
||||
let node = CaptureNode::default();
|
||||
assert_eq!(node.rpc_uri(), "");
|
||||
reject_uri("");
|
||||
assert_eq!(node.validate().expect_err("empty validation").code(), "invalid_rpc_uri");
|
||||
assert!(serde_json::to_string(&node).is_err());
|
||||
assert!(serde_json::from_str::<CaptureNode>(r#"{"rpc_uri":""}"#).is_err());
|
||||
assert_eq!(node.encode().expect_err("empty encoding").code(), "invalid_rpc_uri");
|
||||
reject_record(&record(b""), "invalid_node_length");
|
||||
}
|
||||
#[test]
|
||||
fn accepted_uris_preserve_exact_bytes() {
|
||||
for uri in [
|
||||
"http://node:80",
|
||||
"https://node:443",
|
||||
"http://node:1",
|
||||
"https://node:65535/",
|
||||
"http://127.0.0.1:9000",
|
||||
"https://[::1]:443/",
|
||||
"http://[2001:db8::1]:1",
|
||||
"http://NoDe:00080/",
|
||||
] {
|
||||
let result = CaptureNode::new(uri.to_owned());
|
||||
assert!(result.is_ok(), "valid URI construction: {uri}");
|
||||
let node = result.expect("valid URI");
|
||||
assert_eq!(node.rpc_uri(), uri, "preserve explicit default port and raw URI");
|
||||
assert_eq!(node.encode().expect("encode URI"), record(uri.as_bytes()));
|
||||
assert_eq!(CaptureNode::decode(&record(uri.as_bytes())).expect("decode URI"), node);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn golden_cnod_bytes() {
|
||||
let node = CaptureNode {
|
||||
rpc_uri: "http://node:80".to_owned(),
|
||||
};
|
||||
let golden = b"CNOD\x01\x00\x00\x00\x0ehttp://node:80";
|
||||
assert_eq!(node.encode().ok().as_deref(), Some(golden.as_slice()), "golden CNOD bytes");
|
||||
}
|
||||
#[test]
|
||||
fn valid_cnod_decode() {
|
||||
let golden = b"CNOD\x01\x00\x00\x00\x0ehttp://node:80";
|
||||
let decoded = CaptureNode::decode(golden);
|
||||
assert!(decoded.is_ok(), "golden decode succeeds");
|
||||
assert_eq!(decoded.expect("golden decoded node").rpc_uri(), "http://node:80");
|
||||
}
|
||||
#[test]
|
||||
fn serde_shape_roundtrip_and_strict_fields() {
|
||||
let node = CaptureNode::new("https://node:443/".to_owned()).expect("serde node");
|
||||
let json = r#"{"rpc_uri":"https://node:443/"}"#;
|
||||
assert_eq!(serde_json::to_string(&node).expect("serialize node"), json);
|
||||
assert_eq!(serde_json::from_str::<CaptureNode>(json).expect("deserialize node"), node);
|
||||
for json in [
|
||||
r#"{}"#,
|
||||
r#"{"rpc_uri":1}"#,
|
||||
r#"{"rpc_uri":null}"#,
|
||||
r#"{"rpc_uri":"http://node:1","extra":0}"#,
|
||||
r#"{"rpc_uri":"http://node:1","rpc_uri":"http://node:2"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<CaptureNode>(json).is_err(), "strict fields: {json}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn credentials_and_raw_uri_shapes_reject() {
|
||||
for uri in [
|
||||
"http://user@node:1",
|
||||
"http://user:password@node:1",
|
||||
"http://@node:1",
|
||||
"HTTP://node:1",
|
||||
"Https://node:1",
|
||||
"http://node",
|
||||
"http://node:",
|
||||
"http://node:0",
|
||||
"http://node:-1",
|
||||
"http://node:+1",
|
||||
"http://node:65536",
|
||||
"http://node:999999999999999999999",
|
||||
"http://[::1:1",
|
||||
"http://[::1]extra:1",
|
||||
"http://[invalid]:1",
|
||||
"http://::1:1",
|
||||
"http://:1",
|
||||
"/disk/path",
|
||||
"file:///disk/path",
|
||||
"http://node:1/./",
|
||||
"http://node:1/../",
|
||||
"http://node:1/%2e/",
|
||||
"http://node:1/%2E%2e/",
|
||||
"http://node:1/path",
|
||||
"http://node:1\\",
|
||||
" http://node:1",
|
||||
"http://node:1\n",
|
||||
"http://no\tde:1",
|
||||
"http://node:1\u{00a0}",
|
||||
"http://node:1\u{0085}",
|
||||
"http://node:1\0",
|
||||
] {
|
||||
reject_uri(uri);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn query_and_fragment_markers_reject() {
|
||||
for uri in [
|
||||
"http://node:1?",
|
||||
"http://node:1?x=y",
|
||||
"http://node:1/?",
|
||||
"http://node:1#",
|
||||
"http://node:1#fragment",
|
||||
"http://node:1/#",
|
||||
] {
|
||||
reject_uri(uri);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn utf8_byte_length_boundaries() {
|
||||
for length in [4095, 4096] {
|
||||
let prefix = "http://node:";
|
||||
let uri = format!("{prefix}{}1", "0".repeat(length - prefix.len() - 1));
|
||||
assert_eq!(uri.len(), length);
|
||||
assert!(url::Url::parse(&uri).is_ok(), "boundary fixture accepted by actual URL parser");
|
||||
let node = CaptureNode::new(uri.clone()).expect("boundary node");
|
||||
assert_eq!(node.rpc_uri(), uri);
|
||||
assert_eq!(
|
||||
CaptureNode::decode(&node.encode().expect("boundary encoding")).expect("boundary decoding"),
|
||||
node
|
||||
);
|
||||
}
|
||||
let uri = format!("http://node:{}1", "0".repeat(4097 - "http://node:".len() - 1));
|
||||
assert_eq!(CaptureNode::new(uri).expect_err("oversize URI").code(), "rpc_uri_too_long");
|
||||
let unicode = format!("http://{}:1", "é".repeat(2044));
|
||||
assert!(unicode.chars().count() < 4096);
|
||||
assert!(unicode.len() > 4096);
|
||||
assert_eq!(CaptureNode::new(unicode).expect_err("UTF-8 byte limit").code(), "rpc_uri_too_long");
|
||||
}
|
||||
#[test]
|
||||
fn cnod_headers_and_lengths_reject_exact_codes() {
|
||||
let valid = record(b"http://node:80");
|
||||
for end in 0..9 {
|
||||
reject_record(&valid[..end], "truncated_node_record");
|
||||
}
|
||||
let mut bad = valid.clone();
|
||||
bad[0] = b'X';
|
||||
reject_record(&bad, "invalid_node_magic");
|
||||
bad = valid.clone();
|
||||
bad[4] = 2;
|
||||
reject_record(&bad, "unsupported_node_version");
|
||||
for length in [0_u32, 4097, u32::MAX] {
|
||||
bad = b"CNOD\x01".to_vec();
|
||||
bad.extend_from_slice(&length.to_be_bytes());
|
||||
reject_record(&bad, "invalid_node_length");
|
||||
}
|
||||
for end in 9..valid.len() {
|
||||
reject_record(&valid[..end], "truncated_node_record");
|
||||
}
|
||||
reject_record(&record(&[0xff]), "invalid_node_utf8");
|
||||
reject_record(&record(&[0xc3, 0x28]), "invalid_node_utf8");
|
||||
reject_record(&record(b"invalid URI"), "invalid_rpc_uri");
|
||||
}
|
||||
#[test]
|
||||
fn trailing_bytes_reject() {
|
||||
for tail in [&b"x"[..], &b"xyz"[..]] {
|
||||
let mut bytes = record(b"http://node:80");
|
||||
bytes.extend_from_slice(tail);
|
||||
reject_record(&bytes, "trailing_node_bytes");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn errors_and_sources_redact_input() {
|
||||
let sentinel = "http://SECRET_USER:SECRET_PASSWORD@node:1";
|
||||
let error = CaptureNode::new(sentinel.to_owned()).expect_err("credentials reject");
|
||||
let mut current: &dyn Error = &error;
|
||||
loop {
|
||||
let rendered = format!("{current} {current:?}");
|
||||
assert!(!rendered.contains("SECRET"));
|
||||
match current.source() {
|
||||
Some(source) => current = source,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let json = format!(r#"{{"rpc_uri":"{sentinel}"}}"#);
|
||||
let error = serde_json::from_str::<CaptureNode>(&json).expect_err("serde credentials reject");
|
||||
assert!(!format!("{error} {error:?}").contains("SECRET"));
|
||||
let json = format!(r#"{{"{sentinel}":0}}"#);
|
||||
let error = serde_json::from_str::<CaptureNode>(&json).expect_err("unknown fields reject");
|
||||
assert!(!format!("{error} {error:?}").contains("SECRET"));
|
||||
let error = CaptureNode::new("http://[bad]:1".to_owned()).expect_err("parser rejection");
|
||||
assert!(error.source().is_some(), "retain URL parser source");
|
||||
let error = CaptureNode::decode(&record(&[0xff])).expect_err("UTF-8 rejection");
|
||||
assert!(error.source().is_some(), "retain UTF-8 source");
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
//! OpenRaft log storage backed by the existing native writer and local durable baseline.
|
||||
use crate::{CaptureRaftEntry, CaptureTypeConfig, NativeLogReader, NativeLogWriter, NativeWriteError, StoreError};
|
||||
use openraft::{
|
||||
LogId, OptionalSend, RaftLogReader, StorageError, StorageIOError, Vote,
|
||||
storage::{LogFlushed, RaftLogStorage},
|
||||
};
|
||||
use redb::{ReadableDatabase, ReadableTable};
|
||||
|
||||
/// Clones share one native Store, write ordering and checkpoint authority.
|
||||
#[derive(Clone)]
|
||||
pub struct NativeRaftLogStore {
|
||||
writer: NativeLogWriter,
|
||||
// Last: owner drain is signaled only after this clone releases its writer.
|
||||
pub(crate) owner_lease: Option<std::sync::Arc<crate::raft_owner::OwnerLease>>,
|
||||
}
|
||||
impl NativeRaftLogStore {
|
||||
pub fn new(writer: NativeLogWriter) -> Self {
|
||||
Self {
|
||||
writer,
|
||||
owner_lease: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn error(error: NativeWriteError, operation: fn(openraft::AnyError) -> StorageIOError<u64>) -> StorageError<u64> {
|
||||
StorageError::IO {
|
||||
source: operation(openraft::AnyError::new(&error)),
|
||||
}
|
||||
}
|
||||
impl RaftLogReader<CaptureTypeConfig> for NativeRaftLogStore {
|
||||
async fn try_get_log_entries<RB: std::ops::RangeBounds<u64> + Clone + std::fmt::Debug + OptionalSend>(
|
||||
&mut self,
|
||||
range: RB,
|
||||
) -> Result<Vec<CaptureRaftEntry>, StorageError<u64>> {
|
||||
let mut reader = self.writer.reader();
|
||||
reader.owner_lease = self.owner_lease.clone();
|
||||
reader.try_get_log_entries(range).await
|
||||
}
|
||||
}
|
||||
impl RaftLogStorage<CaptureTypeConfig> for NativeRaftLogStore {
|
||||
type LogReader = NativeLogReader;
|
||||
async fn get_log_state(&mut self) -> Result<openraft::LogState<CaptureTypeConfig>, StorageError<u64>> {
|
||||
self.writer
|
||||
.write(|store| {
|
||||
if !store.writable {
|
||||
return Err(StoreError::WriteDisabled);
|
||||
}
|
||||
let read = store.database.begin_read()?;
|
||||
let meta = read.open_table(crate::META)?;
|
||||
let purged = crate::checkpoint_baseline::read_purged(&meta)?;
|
||||
let logs = read.open_table(crate::LOGS)?;
|
||||
let last = logs
|
||||
.last()?
|
||||
.map(|(index, value)| {
|
||||
let id = crate::decode_log(value.value())?.id;
|
||||
if id.index != index.value() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(id)
|
||||
})
|
||||
.transpose()?
|
||||
.or(purged);
|
||||
let tail = meta.get("tail")?.map(|value| crate::decode_id(value.value())).transpose()?;
|
||||
if tail != last {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(openraft::LogState {
|
||||
last_purged_log_id: purged.map(Into::into),
|
||||
last_log_id: last.map(Into::into),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| error(e, StorageIOError::read_logs))
|
||||
}
|
||||
async fn get_log_reader(&mut self) -> Self::LogReader {
|
||||
let mut reader = self.writer.reader();
|
||||
reader.owner_lease = self.owner_lease.clone();
|
||||
reader
|
||||
}
|
||||
async fn save_vote(&mut self, vote: &Vote<u64>) -> Result<(), StorageError<u64>> {
|
||||
self.writer
|
||||
.save_vote(*vote)
|
||||
.await
|
||||
.map_err(|e| error(e, StorageIOError::write_vote))
|
||||
}
|
||||
async fn read_vote(&mut self) -> Result<Option<Vote<u64>>, StorageError<u64>> {
|
||||
self.writer.read_vote().await.map_err(|e| error(e, StorageIOError::read_vote))
|
||||
}
|
||||
async fn save_committed(&mut self, committed: Option<LogId<u64>>) -> Result<(), StorageError<u64>> {
|
||||
self.writer
|
||||
.write(move |store| match committed {
|
||||
Some(id) => store.save_committed(id.into()),
|
||||
None if store.read_committed()?.is_none() => Ok(()),
|
||||
None => Err(StoreError::CommittedRegression),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| error(e, StorageIOError::write_logs))
|
||||
}
|
||||
async fn read_committed(&mut self) -> Result<Option<LogId<u64>>, StorageError<u64>> {
|
||||
self.writer
|
||||
.write(|store| store.read_committed().map(|id| id.map(Into::into)))
|
||||
.await
|
||||
.map_err(|e| error(e, StorageIOError::read_logs))
|
||||
}
|
||||
async fn append<I>(&mut self, entries: I, callback: LogFlushed<CaptureTypeConfig>) -> Result<(), StorageError<u64>>
|
||||
where
|
||||
I: IntoIterator<Item = CaptureRaftEntry> + OptionalSend,
|
||||
I::IntoIter: OptionalSend,
|
||||
{
|
||||
let entries = entries.into_iter().collect::<Vec<_>>();
|
||||
// The original blocking worker owns ordering and callback through durable commit,
|
||||
// even if OpenRaft cancels its awaiting append future.
|
||||
self.writer
|
||||
.write(move |store| {
|
||||
let result = store.append_native(&entries);
|
||||
callback.log_io_completed(
|
||||
result
|
||||
.as_ref()
|
||||
.map(|_| ())
|
||||
.map_err(|error| std::io::Error::other(error.code())),
|
||||
);
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|e| error(e, StorageIOError::write_logs))
|
||||
}
|
||||
async fn truncate(&mut self, log_id: LogId<u64>) -> Result<(), StorageError<u64>> {
|
||||
self.writer
|
||||
.truncate_suffix(log_id.index)
|
||||
.await
|
||||
.map_err(|e| error(e, StorageIOError::write_logs))
|
||||
}
|
||||
async fn purge(&mut self, log_id: LogId<u64>) -> Result<(), StorageError<u64>> {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(error(NativeWriteError::Store(StoreError::StaleCheckpoint), StorageIOError::write_logs));
|
||||
}
|
||||
let notified = self.writer.snapshot_receives.changed.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
let receives = self.writer.snapshot_receives.clone();
|
||||
// Observe coverage and receive ownership while install is excluded by ordering.
|
||||
let (result, pending) = self
|
||||
.writer
|
||||
.write(move |store| Ok((store.purge_native_prefix(log_id.into(), None), receives.pending())))
|
||||
.await
|
||||
.map_err(|e| error(e, StorageIOError::write_logs))?;
|
||||
if matches!(&result, Err(StoreError::StaleCheckpoint)) {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let hook = self
|
||||
.writer
|
||||
.snapshot_receives
|
||||
.purge_recheck
|
||||
.lock()
|
||||
.expect("test hook lock")
|
||||
.take();
|
||||
if let Some((entered, resume)) = hook {
|
||||
entered.notify_one();
|
||||
resume.notified().await;
|
||||
}
|
||||
}
|
||||
if pending {
|
||||
#[cfg(test)]
|
||||
let _waiter = crate::raft_state_machine::Waiter::new(self.writer.snapshot_receives.clone());
|
||||
if tokio::time::timeout_at(deadline, notified).await.is_err() {
|
||||
return Err(error(NativeWriteError::Store(StoreError::StaleCheckpoint), StorageIOError::write_logs));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return result.map_err(|e| error(e.into(), StorageIOError::write_logs));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
//! Exercises actual OpenRaft storage traits, not a substitute consensus engine.
|
||||
use super::*;
|
||||
use openraft::{
|
||||
RaftLogReader,
|
||||
storage::{RaftLogStorage, RaftLogStorageExt},
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn identity() -> NodeIdentity {
|
||||
NodeIdentity {
|
||||
cluster_id: [7; 16],
|
||||
node_id: 0,
|
||||
}
|
||||
}
|
||||
fn binding() -> CaptureBinding {
|
||||
CaptureBinding {
|
||||
revision: 9,
|
||||
binding_id: [8; 16],
|
||||
target_id: [9; 16],
|
||||
}
|
||||
}
|
||||
fn id(index: u64) -> LogId {
|
||||
LogId {
|
||||
term: 8,
|
||||
leader_node: 0,
|
||||
index,
|
||||
}
|
||||
}
|
||||
fn entry(index: u64) -> CaptureRaftEntry {
|
||||
let payload = if index == 0 {
|
||||
let membership = CaptureMembership::new(
|
||||
Some(id(0)),
|
||||
vec![BTreeSet::from([0])],
|
||||
BTreeMap::from([(0, CaptureNode::new("http://127.0.0.1:7000".into()).expect("test member"))]),
|
||||
)
|
||||
.expect("native membership");
|
||||
let membership = openraft::StoredMembership::try_from(&membership).expect("stored native membership");
|
||||
openraft::EntryPayload::Membership(membership.membership().clone())
|
||||
} else {
|
||||
let byte = u8::try_from(index).expect("small test event index");
|
||||
let command = DecideCreated {
|
||||
operation_id: [byte; 16],
|
||||
object: ObjectIdentity {
|
||||
bucket_incarnation: [2; 16],
|
||||
key: vec![byte],
|
||||
},
|
||||
expected_head: None,
|
||||
prepared: PreparedIdentity {
|
||||
version_id: [byte; 16],
|
||||
preparation_id: [4; 16],
|
||||
content_digest: [5; 32],
|
||||
content_length: 123,
|
||||
},
|
||||
binding_revision: binding().revision,
|
||||
};
|
||||
openraft::EntryPayload::Normal(CaptureCommand::try_created(command).expect("native test command"))
|
||||
};
|
||||
CaptureRaftEntry {
|
||||
log_id: id(index).into(),
|
||||
payload,
|
||||
}
|
||||
}
|
||||
fn entries(end: u64) -> Vec<CaptureRaftEntry> {
|
||||
(0..=end).map(entry).collect()
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, NativeLogWriter, NativeRaftLogStore) {
|
||||
let directory = tempfile::tempdir().expect("actual native database directory");
|
||||
let mut store = Store::initialize(directory.path().join("db"), identity()).expect("actual redb initialization");
|
||||
store.pin_capture_binding(binding()).expect("capture binding");
|
||||
store.enable_native_empty().expect("native mode");
|
||||
store.promote_created_provenance().expect("native provenance");
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let adapter = NativeRaftLogStore::new(writer.clone());
|
||||
(directory, writer, adapter)
|
||||
}
|
||||
fn media_fixture() -> (tempfile::TempDir, tests::CrashBackend, NativeLogWriter, NativeRaftLogStore) {
|
||||
let (directory, writer, adapter) = fixture();
|
||||
drop(adapter);
|
||||
drop(writer);
|
||||
let backend = tests::CrashBackend::new(&directory.path().join("db"));
|
||||
let store = Store::from_database(backend.database(), identity()).expect("existing real disk fault backend");
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let adapter = NativeRaftLogStore::new(writer.clone());
|
||||
(directory, backend, writer, adapter)
|
||||
}
|
||||
async fn logs(adapter: &mut NativeRaftLogStore) -> Vec<CaptureRaftEntry> {
|
||||
adapter
|
||||
.get_log_reader()
|
||||
.await
|
||||
.try_get_log_entries(..)
|
||||
.await
|
||||
.expect("actual trait reader")
|
||||
}
|
||||
async fn checkpoint(writer: &NativeLogWriter, adapter: &mut NativeRaftLogStore) -> CheckpointReceipt {
|
||||
let batch = entries(2);
|
||||
adapter
|
||||
.blocking_append(batch.clone())
|
||||
.await
|
||||
.expect("trait append before checkpoint");
|
||||
// Prepares genuine applied/checkpoint evidence; this does not claim Raft consensus.
|
||||
writer
|
||||
.apply_committed(batch)
|
||||
.await
|
||||
.expect("trusted test owner applies preparation entries");
|
||||
writer
|
||||
.checkpoint_local(CheckpointLimits::default())
|
||||
.await
|
||||
.expect("real durable baseline")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_log_storage_flush_restart() {
|
||||
let (directory, backend, writer, mut adapter) = media_fixture();
|
||||
let batch = entries(2);
|
||||
// blocking_append constructs the real library-private LogFlushed callback.
|
||||
adapter
|
||||
.blocking_append(batch.clone())
|
||||
.await
|
||||
.expect("real durable flush callback");
|
||||
let state = adapter.get_log_state().await.expect("trait log state");
|
||||
assert_eq!(state.last_log_id, Some(id(2).into()), "RAFT_LOG_TAIL_NOT_APPLIED_STATE");
|
||||
assert_eq!(state.last_purged_log_id, None);
|
||||
assert_eq!(writer.applied_state().await.expect("application state").0, None);
|
||||
assert_eq!(logs(&mut adapter).await, batch);
|
||||
backend.cut();
|
||||
drop(adapter);
|
||||
drop(writer);
|
||||
let reopened = Store::open(directory.path().join("db"), identity()).expect("reopen after successful callback and power cut");
|
||||
let mut adapter = NativeRaftLogStore::new(NativeLogWriter::new(reopened));
|
||||
assert_eq!(logs(&mut adapter).await, batch, "RAFT_LOG_FLUSH_CALLBACK_DURABLE");
|
||||
assert_eq!(
|
||||
adapter.get_log_state().await.expect("reopened trait state").last_log_id,
|
||||
Some(id(2).into())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_log_storage_vote_restart() {
|
||||
let (directory, backend, writer, mut adapter) = media_fixture();
|
||||
let vote = openraft::Vote::new_committed(9, 0);
|
||||
adapter.save_vote(&vote).await.expect("trait durable vote");
|
||||
assert_eq!(adapter.read_vote().await.expect("trait vote read"), Some(vote));
|
||||
adapter.blocking_append(entries(2)).await.expect("trait append");
|
||||
adapter
|
||||
.save_committed(Some(id(1).into()))
|
||||
.await
|
||||
.expect("trait durable commit marker");
|
||||
assert!(adapter.save_committed(None).await.is_err(), "RAFT_LOG_COMMITTED_NONE_CANNOT_RESET");
|
||||
assert_eq!(adapter.read_committed().await.expect("exact marker"), Some(id(1).into()));
|
||||
assert!(
|
||||
adapter.save_committed(Some(id(0).into())).await.is_err(),
|
||||
"RAFT_LOG_COMMITTED_NO_REGRESSION"
|
||||
);
|
||||
let mut wrong = id(2);
|
||||
wrong.term += 1;
|
||||
assert!(
|
||||
adapter.save_committed(Some(wrong.into())).await.is_err(),
|
||||
"RAFT_LOG_COMMITTED_EXACT_IDENTITY"
|
||||
);
|
||||
backend.cut();
|
||||
drop(adapter);
|
||||
drop(writer);
|
||||
let mut reopened = NativeRaftLogStore::new(NativeLogWriter::new(
|
||||
Store::open(directory.path().join("db"), identity()).expect("durable vote reopen"),
|
||||
));
|
||||
assert_eq!(
|
||||
reopened.read_vote().await.expect("reopened vote"),
|
||||
Some(vote),
|
||||
"RAFT_LOG_VOTE_ACK_DURABLE"
|
||||
);
|
||||
assert_eq!(reopened.read_committed().await.expect("reopened committed marker"), Some(id(1).into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_log_storage_partial_purge_restart() {
|
||||
let (directory, writer, mut adapter) = fixture();
|
||||
let receipt = checkpoint(&writer, &mut adapter).await;
|
||||
adapter
|
||||
.blocking_append(vec![entry(3), entry(4)])
|
||||
.await
|
||||
.expect("uncovered suffix append");
|
||||
let events = writer
|
||||
.created_events_page(None, 256)
|
||||
.await
|
||||
.expect("pre-purge authoritative events");
|
||||
adapter
|
||||
.purge(id(1).into())
|
||||
.await
|
||||
.expect("checkpoint authorizes strict prefix");
|
||||
assert_eq!(
|
||||
logs(&mut adapter).await,
|
||||
vec![entry(2), entry(3), entry(4)],
|
||||
"RAFT_LOG_PARTIAL_PURGE_RETAINS_SUFFIX"
|
||||
);
|
||||
let state = adapter.get_log_state().await.expect("partially purged state");
|
||||
assert_eq!(state.last_purged_log_id, Some(id(1).into()));
|
||||
assert_eq!(state.last_log_id, Some(id(4).into()));
|
||||
adapter.purge(id(1).into()).await.expect("repeat exact purge is idempotent");
|
||||
assert!(adapter.purge(id(0).into()).await.is_err(), "RAFT_LOG_PURGE_NO_REGRESSION");
|
||||
{
|
||||
let store = writer.store.lock().expect("inspect task-owned store");
|
||||
let tx = store.database.begin_read().expect("real persisted prefix inspection");
|
||||
let results = tx.open_table(application::APPLY_RESULTS).expect("apply receipts");
|
||||
assert!(results.get(0).expect("membership receipt").is_none());
|
||||
assert!(results.get(1).expect("first receipt").is_none());
|
||||
assert!(
|
||||
results.get(2).expect("retained receipt").is_some(),
|
||||
"RAFT_LOG_PARTIAL_PURGE_RECEIPT_SUFFIX"
|
||||
);
|
||||
}
|
||||
drop(adapter);
|
||||
drop(writer);
|
||||
let writer = NativeLogWriter::new(Store::open(directory.path().join("db"), identity()).expect("restart partial purge"));
|
||||
let mut adapter = NativeRaftLogStore::new(writer.clone());
|
||||
assert_eq!(
|
||||
logs(&mut adapter).await,
|
||||
vec![entry(2), entry(3), entry(4)],
|
||||
"RAFT_LOG_PARTIAL_PURGE_REOPEN"
|
||||
);
|
||||
adapter
|
||||
.blocking_append(vec![entry(5)])
|
||||
.await
|
||||
.expect("continue append after partial purge");
|
||||
writer
|
||||
.apply_committed(vec![entry(3), entry(4), entry(5)])
|
||||
.await
|
||||
.expect("continue application from true applied state");
|
||||
assert_eq!(
|
||||
writer
|
||||
.created_events_page(None, 256)
|
||||
.await
|
||||
.expect("retained plus new events")
|
||||
.events
|
||||
.len(),
|
||||
5,
|
||||
"RAFT_LOG_PURGE_NEVER_DELETES_EVENTS"
|
||||
);
|
||||
writer
|
||||
.purge_checkpoint(&receipt)
|
||||
.await
|
||||
.expect("existing exact receipt API remains compatible after partial purge");
|
||||
assert_eq!(
|
||||
adapter
|
||||
.get_log_state()
|
||||
.await
|
||||
.expect("exact compatibility state")
|
||||
.last_purged_log_id,
|
||||
Some(id(2).into())
|
||||
);
|
||||
assert_eq!(logs(&mut adapter).await, vec![entry(3), entry(4), entry(5)]);
|
||||
assert_eq!(
|
||||
writer
|
||||
.created_events_page(None, 2)
|
||||
.await
|
||||
.expect("original events retained")
|
||||
.events,
|
||||
events.events
|
||||
);
|
||||
drop(adapter);
|
||||
drop(writer);
|
||||
let mut reopened = NativeRaftLogStore::new(NativeLogWriter::new(
|
||||
Store::open(directory.path().join("db"), identity()).expect("reopen continued apply and exact purge"),
|
||||
));
|
||||
assert_eq!(logs(&mut reopened).await, vec![entry(3), entry(4), entry(5)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_log_storage_purge_rejects_uncovered() {
|
||||
let (directory, writer, mut adapter) = fixture();
|
||||
adapter
|
||||
.blocking_append(entries(2))
|
||||
.await
|
||||
.expect("trait append without snapshot authority");
|
||||
writer
|
||||
.apply_committed(entries(2))
|
||||
.await
|
||||
.expect("applied preparation without checkpoint");
|
||||
let before = std::fs::read(directory.path().join("db")).expect("stable pre-rejection bytes");
|
||||
assert!(
|
||||
adapter.purge(id(1).into()).await.is_err(),
|
||||
"PURGE_REQUIRES_CHECKPOINT_COVERAGE: application progress is not a durable checkpoint"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(directory.path().join("db")).expect("rejected purge bytes"),
|
||||
before,
|
||||
"RAFT_LOG_UNAUTHORIZED_PURGE_NO_MUTATION"
|
||||
);
|
||||
writer
|
||||
.checkpoint_local(CheckpointLimits::default())
|
||||
.await
|
||||
.expect("checkpoint at applied cut 2");
|
||||
adapter
|
||||
.blocking_append(vec![entry(3)])
|
||||
.await
|
||||
.expect("log beyond checkpoint cut");
|
||||
let before = std::fs::read(directory.path().join("db")).expect("post-checkpoint bytes");
|
||||
assert!(
|
||||
adapter.purge(id(3).into()).await.is_err(),
|
||||
"PURGE_REQUIRES_CHECKPOINT_COVERAGE: retained target exceeds durable cut"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(directory.path().join("db")).expect("uncovered target rejection bytes"),
|
||||
before,
|
||||
"RAFT_LOG_UNCOVERED_PURGE_NO_MUTATION"
|
||||
);
|
||||
assert_eq!(logs(&mut adapter).await, entries(3));
|
||||
assert_eq!(adapter.get_log_state().await.expect("unchanged marker").last_purged_log_id, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_log_storage_purge_identity() {
|
||||
let (directory, writer, mut adapter) = fixture();
|
||||
checkpoint(&writer, &mut adapter).await;
|
||||
for target in [
|
||||
LogId { term: 9, ..id(1) },
|
||||
LogId { leader_node: 1, ..id(1) },
|
||||
LogId { term: 9, ..id(2) },
|
||||
] {
|
||||
let before = std::fs::read(directory.path().join("db")).expect("before wrong complete LogId");
|
||||
assert!(adapter.purge(target.into()).await.is_err(), "RAFT_LOG_PURGE_EXACT_LOG_ID");
|
||||
assert_eq!(std::fs::read(directory.path().join("db")).expect("after identity rejection"), before);
|
||||
}
|
||||
adapter.purge(id(1).into()).await.expect("valid identity");
|
||||
let mut wrong = id(1);
|
||||
wrong.term += 1;
|
||||
assert!(adapter.purge(wrong.into()).await.is_err(), "RAFT_LOG_PURGED_REPEAT_IDENTITY_BOUND");
|
||||
assert_eq!(
|
||||
adapter
|
||||
.get_log_state()
|
||||
.await
|
||||
.expect("unchanged identity marker")
|
||||
.last_purged_log_id,
|
||||
Some(id(1).into())
|
||||
);
|
||||
adapter.purge(id(2).into()).await.expect("final exact covered target");
|
||||
let state = adapter.get_log_state().await.expect("no remaining logs");
|
||||
assert_eq!(state.last_purged_log_id, Some(id(2).into()));
|
||||
assert_eq!(state.last_log_id, Some(id(2).into()), "RAFT_LOG_EMPTY_TAIL_USES_PURGED_LOG_ID");
|
||||
assert!(logs(&mut adapter).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn raft_log_storage_cancelled_append() {
|
||||
let (directory, backend, writer, adapter) = media_fixture();
|
||||
let (entered, resume) = backend.pause_sync(1, false);
|
||||
let mut first = adapter.clone();
|
||||
let task = tokio::spawn(async move { first.blocking_append(entries(2)).await });
|
||||
entered.wait(); // Actual redb commit has begun but its first sync is paused.
|
||||
assert!(!task.is_finished(), "RAFT_LOG_FLUSH_NOT_BEFORE_DURABILITY");
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("cancelled blocking append waiter").is_cancelled());
|
||||
assert!(writer.ordering.try_lock().is_err(), "RAFT_LOG_CANCELLED_APPEND_RETAINS_ORDERING_OWNER");
|
||||
let mut following = adapter.clone();
|
||||
let next = tokio::spawn(async move { following.blocking_append(vec![entry(3)]).await });
|
||||
assert!(writer.ordering.try_lock().is_err(), "RAFT_LOG_NEXT_APPEND_CANNOT_BYPASS_PENDING_SYNC");
|
||||
resume.wait();
|
||||
next.await.expect("next trait append task").expect("ordered follow-up append");
|
||||
let mut adapter = adapter;
|
||||
assert_eq!(
|
||||
logs(&mut adapter).await,
|
||||
entries(3),
|
||||
"RAFT_LOG_CANCELLED_WAITER_BACKGROUND_COMMIT_COMPLETE"
|
||||
);
|
||||
backend.cut();
|
||||
drop(adapter);
|
||||
drop(writer);
|
||||
let mut reopened = NativeRaftLogStore::new(NativeLogWriter::new(
|
||||
Store::open(directory.path().join("db"), identity()).expect("reopen cancelled waiter background commit"),
|
||||
));
|
||||
assert_eq!(logs(&mut reopened).await, entries(3));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn raft_log_storage_failed_append() {
|
||||
let (directory, backend, writer, adapter) = media_fixture();
|
||||
let (entered, resume) = backend.pause_sync(1, false);
|
||||
let mut appending = adapter.clone();
|
||||
let task = tokio::spawn(async move { appending.blocking_append(entries(2)).await });
|
||||
entered.wait();
|
||||
assert!(!task.is_finished(), "RAFT_LOG_FAILED_FLUSH_NOT_EARLY_SUCCESS");
|
||||
backend.cut();
|
||||
resume.wait();
|
||||
assert!(
|
||||
task.await.expect("failed actual trait append task").is_err(),
|
||||
"RAFT_LOG_FAILED_APPEND_NEVER_REPORTS_CALLBACK_SUCCESS"
|
||||
);
|
||||
let mut adapter = adapter;
|
||||
assert!(
|
||||
adapter.blocking_append(entries(2)).await.is_err(),
|
||||
"RAFT_LOG_INDETERMINATE_STORE_REJECTS_LATER_APPEND"
|
||||
);
|
||||
assert!(
|
||||
adapter.save_vote(&openraft::Vote::new_committed(9, 0)).await.is_err(),
|
||||
"RAFT_LOG_INDETERMINATE_STORE_REJECTS_LATER_VOTE"
|
||||
);
|
||||
drop(adapter);
|
||||
drop(writer);
|
||||
let mut reopened = NativeRaftLogStore::new(NativeLogWriter::new(
|
||||
Store::open(directory.path().join("db"), identity()).expect("reopen failed-before-first-sync append"),
|
||||
));
|
||||
assert!(logs(&mut reopened).await.is_empty(), "RAFT_LOG_FAILURE_NO_PARTIAL_BATCH");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_log_storage_truncate_uncommitted_suffix() {
|
||||
let (directory, writer, mut adapter) = fixture();
|
||||
adapter.blocking_append(entries(4)).await.expect("trait append full log");
|
||||
adapter
|
||||
.save_committed(Some(id(2).into()))
|
||||
.await
|
||||
.expect("protect committed prefix");
|
||||
assert!(
|
||||
adapter.truncate(id(2).into()).await.is_err(),
|
||||
"RAFT_LOG_TRUNCATE_PROTECTS_COMMITTED_PREFIX"
|
||||
);
|
||||
adapter
|
||||
.truncate(id(3).into())
|
||||
.await
|
||||
.expect("truncate includes uncommitted target");
|
||||
assert_eq!(logs(&mut adapter).await, entries(2), "RAFT_LOG_TRUNCATE_INCLUSIVE");
|
||||
let mut replacement = entry(3);
|
||||
replacement.log_id = LogId { term: 9, ..id(3) }.into();
|
||||
adapter
|
||||
.blocking_append(vec![replacement.clone()])
|
||||
.await
|
||||
.expect("trait append replacement suffix");
|
||||
assert_eq!(
|
||||
adapter.get_log_state().await.expect("replacement tail").last_log_id,
|
||||
Some(replacement.log_id)
|
||||
);
|
||||
drop(adapter);
|
||||
drop(writer);
|
||||
let mut reopened = NativeRaftLogStore::new(NativeLogWriter::new(
|
||||
Store::open(directory.path().join("db"), identity()).expect("reopen truncate and replacement"),
|
||||
));
|
||||
let mut expected = entries(2);
|
||||
expected.push(replacement);
|
||||
assert_eq!(logs(&mut reopened).await, expected);
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
//! Explicit lifecycle for real OpenRaft over one durable native capture owner.
|
||||
use crate::{
|
||||
CaptureCommand, CaptureNode, CaptureNodeError, CaptureTypeConfig, CheckpointLimits, NativeLogWriter, NativeRaftLogStore,
|
||||
NativeRaftStateMachine, NativeSnapshotError, NativeWriteError, StoreError,
|
||||
};
|
||||
use openraft::{
|
||||
Config, ConfigError, Raft, RaftMetrics, RaftNetworkFactory,
|
||||
error::{ClientWriteError, Fatal, InitializeError, RaftError},
|
||||
raft::ClientWriteResponse,
|
||||
};
|
||||
use redb::ReadableDatabase;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
use tokio::sync::watch;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NativeRaftOwnerError {
|
||||
#[error(transparent)]
|
||||
Config(#[from] Box<ConfigError>),
|
||||
#[error(transparent)]
|
||||
Store(#[from] NativeWriteError),
|
||||
#[error(transparent)]
|
||||
Snapshot(#[from] NativeSnapshotError),
|
||||
#[error("runtime node ID {runtime} differs from durable node ID {durable}")]
|
||||
IdentityMismatch { runtime: u64, durable: u64 },
|
||||
#[error("bootstrap must contain the local node")]
|
||||
MissingLocalMember,
|
||||
#[error(transparent)]
|
||||
Node(#[from] CaptureNodeError),
|
||||
#[error("native Raft owner is stopping or stopped")]
|
||||
Stopped,
|
||||
#[error(transparent)]
|
||||
Fatal(Box<Fatal<u64>>),
|
||||
#[error(transparent)]
|
||||
Initialize(Box<RaftError<u64, InitializeError<u64, CaptureNode>>>),
|
||||
#[error(transparent)]
|
||||
Write(Box<RaftError<u64, ClientWriteError<u64, CaptureNode>>>),
|
||||
#[error("native Raft shutdown task failed")]
|
||||
Shutdown(#[source] tokio::task::JoinError),
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[error(transparent)]
|
||||
Reader(#[from] crate::source_reader::ReaderError),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DrainState {
|
||||
complete: AtomicBool,
|
||||
changed: tokio::sync::Notify,
|
||||
#[cfg(test)]
|
||||
waiters: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
impl DrainState {
|
||||
async fn wait(&self) {
|
||||
#[cfg(test)]
|
||||
let _waiter = DrainWaiter::new(self.waiters.clone());
|
||||
loop {
|
||||
let notified = self.changed.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
if self.complete.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
struct DrainWaiter(Arc<std::sync::atomic::AtomicUsize>);
|
||||
#[cfg(test)]
|
||||
impl DrainWaiter {
|
||||
fn new(waiters: Arc<std::sync::atomic::AtomicUsize>) -> Self {
|
||||
waiters.fetch_add(1, Ordering::AcqRel);
|
||||
Self(waiters)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl Drop for DrainWaiter {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
// The real worker, snapshot builders and replication readers share this one lease.
|
||||
// Its final drop runs after their NativeLogWriter fields have been released.
|
||||
pub(crate) struct OwnerLease {
|
||||
drain: Arc<DrainState>,
|
||||
}
|
||||
impl Drop for OwnerLease {
|
||||
fn drop(&mut self) {
|
||||
self.drain.complete.store(true, Ordering::Release);
|
||||
self.drain.changed.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// A lifecycle owner, not a transport. All storage adapters share its original writer.
|
||||
pub struct NativeRaftOwner {
|
||||
raft: Raft<CaptureTypeConfig>,
|
||||
writer: NativeLogWriter,
|
||||
identity: crate::NodeIdentity,
|
||||
accepting: AtomicBool,
|
||||
drain: Arc<DrainState>,
|
||||
#[cfg(test)]
|
||||
pub(crate) snapshot_build_pause: Arc<crate::raft_state_machine::TestPause>,
|
||||
}
|
||||
impl NativeRaftOwner {
|
||||
pub async fn start<N>(
|
||||
writer: NativeLogWriter,
|
||||
node_id: u64,
|
||||
config: Arc<Config>,
|
||||
limits: CheckpointLimits,
|
||||
network: N,
|
||||
) -> Result<Self, NativeRaftOwnerError>
|
||||
where
|
||||
N: RaftNetworkFactory<CaptureTypeConfig>,
|
||||
{
|
||||
// All operator-input checks precede Raft::new and its background workers.
|
||||
let config = Arc::new(
|
||||
(*config)
|
||||
.clone()
|
||||
.validate()
|
||||
.map_err(|error| NativeRaftOwnerError::Config(Box::new(error)))?,
|
||||
);
|
||||
let mut state_machine = NativeRaftStateMachine::new(writer.clone(), limits)?;
|
||||
let identity = writer
|
||||
.write(|store| {
|
||||
if !store.writable {
|
||||
return Err(StoreError::WriteDisabled);
|
||||
}
|
||||
let transaction = store.database.begin_read()?;
|
||||
let meta = transaction.open_table(crate::META)?;
|
||||
crate::application::native_application_state(&transaction)?;
|
||||
crate::application::read_binding(&meta)?;
|
||||
crate::decode_identity(meta.get("identity")?.ok_or(StoreError::CorruptRecord)?.value())
|
||||
})
|
||||
.await?;
|
||||
if identity.node_id != node_id {
|
||||
return Err(NativeRaftOwnerError::IdentityMismatch {
|
||||
runtime: node_id,
|
||||
durable: identity.node_id,
|
||||
});
|
||||
}
|
||||
let drain = Arc::new(DrainState::default());
|
||||
let lease = Arc::new(OwnerLease { drain: drain.clone() });
|
||||
state_machine.owner_lease = Some(lease.clone());
|
||||
let mut logs = NativeRaftLogStore::new(writer.clone());
|
||||
logs.owner_lease = Some(lease);
|
||||
#[cfg(test)]
|
||||
let snapshot_build_pause = state_machine.build_before_ordering.clone();
|
||||
let raft = Raft::new(node_id, config, network, logs, state_machine)
|
||||
.await
|
||||
.map_err(|error| NativeRaftOwnerError::Fatal(Box::new(error)))?;
|
||||
Ok(Self {
|
||||
raft,
|
||||
writer,
|
||||
identity,
|
||||
accepting: AtomicBool::new(true),
|
||||
drain,
|
||||
#[cfg(test)]
|
||||
snapshot_build_pause,
|
||||
})
|
||||
}
|
||||
|
||||
/// Identity read from durable META and checked before starting this owner.
|
||||
pub fn identity(&self) -> crate::NodeIdentity {
|
||||
self.identity
|
||||
}
|
||||
|
||||
fn require_running(&self) -> Result<(), NativeRaftOwnerError> {
|
||||
if self.accepting.load(Ordering::Acquire) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(NativeRaftOwnerError::Stopped)
|
||||
}
|
||||
}
|
||||
|
||||
/// Only explicit first-cluster formation calls initialize; start never does.
|
||||
pub async fn bootstrap(&self, members: BTreeMap<u64, CaptureNode>) -> Result<(), NativeRaftOwnerError> {
|
||||
self.require_running()?;
|
||||
if !members.contains_key(&self.identity.node_id) {
|
||||
return Err(NativeRaftOwnerError::MissingLocalMember);
|
||||
}
|
||||
for node in members.values() {
|
||||
node.validate()?;
|
||||
}
|
||||
self.raft
|
||||
.initialize(members)
|
||||
.await
|
||||
.map_err(|error| NativeRaftOwnerError::Initialize(Box::new(error)))
|
||||
}
|
||||
|
||||
/// The returned LogId and application response come from actual consensus apply.
|
||||
pub async fn client_write(
|
||||
&self,
|
||||
command: CaptureCommand,
|
||||
) -> Result<ClientWriteResponse<CaptureTypeConfig>, NativeRaftOwnerError> {
|
||||
self.require_running()?;
|
||||
self.raft
|
||||
.client_write(command)
|
||||
.await
|
||||
.map_err(|error| NativeRaftOwnerError::Write(Box::new(error)))
|
||||
}
|
||||
|
||||
pub fn metrics(&self) -> watch::Receiver<RaftMetrics<u64, CaptureNode>> {
|
||||
self.raft.metrics()
|
||||
}
|
||||
|
||||
/// Transport registration only; raw clones remain trusted caller-owned handles.
|
||||
pub fn raft(&self) -> &Raft<CaptureTypeConfig> {
|
||||
&self.raft
|
||||
}
|
||||
|
||||
/// The caller owns serve/shutdown and must drain it before releasing the Store.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub fn bind_reader(
|
||||
&self,
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(crate::source_reader::SourceReaderServer, crate::source_reader::Shutdown), NativeRaftOwnerError> {
|
||||
self.require_running()?;
|
||||
Ok(crate::source_reader::SourceReaderServer::bind(path, self.writer.clone())?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn shutdown_waiters(&self) -> Arc<std::sync::atomic::AtomicUsize> {
|
||||
self.drain.waiters.clone()
|
||||
}
|
||||
|
||||
/// Stop admission before signaling Raft; await its real core/tick shutdown.
|
||||
pub async fn shutdown(&self) -> Result<(), NativeRaftOwnerError> {
|
||||
self.accepting.store(false, Ordering::Release);
|
||||
self.raft.shutdown().await.map_err(NativeRaftOwnerError::Shutdown)?;
|
||||
// OpenRaft joins core/tick but detaches the SM worker and snapshot builders.
|
||||
self.drain.wait().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,882 +0,0 @@
|
||||
//! Real Raft engines and real redb; direct RPC routing exists only inside these tests.
|
||||
use super::*;
|
||||
use openraft::{
|
||||
Raft, RaftNetwork, RaftNetworkFactory, ServerState,
|
||||
error::{InstallSnapshotError, RPCError, RaftError, RemoteError, Unreachable},
|
||||
network::{Backoff, RPCOption},
|
||||
raft::{
|
||||
AppendEntriesRequest, AppendEntriesResponse, InstallSnapshotRequest, InstallSnapshotResponse, VoteRequest, VoteResponse,
|
||||
},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
type Engine = Raft<CaptureTypeConfig>;
|
||||
type RpcError = RPCError<u64, CaptureNode, RaftError<u64>>;
|
||||
type SnapshotRpcError = RPCError<u64, CaptureNode, RaftError<u64, InstallSnapshotError>>;
|
||||
type PausedLinks = Mutex<BTreeMap<(u64, u64), (Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>;
|
||||
#[derive(Default)]
|
||||
struct Routes {
|
||||
nodes: Mutex<BTreeMap<u64, Engine>>,
|
||||
blocked: Mutex<BTreeSet<(u64, u64)>>,
|
||||
snapshot_installs: AtomicUsize,
|
||||
pause_append: PausedLinks,
|
||||
}
|
||||
impl Routes {
|
||||
fn register(&self, id: u64, raft: Engine) {
|
||||
self.nodes.lock().expect("routes").insert(id, raft);
|
||||
}
|
||||
fn remove(&self, id: u64) {
|
||||
self.nodes.lock().expect("routes").remove(&id);
|
||||
}
|
||||
fn isolate(&self, id: u64, members: &[u64]) {
|
||||
let mut blocked = self.blocked.lock().expect("partition routes");
|
||||
for peer in members {
|
||||
if *peer != id {
|
||||
blocked.insert((id, *peer));
|
||||
blocked.insert((*peer, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn heal(&self) {
|
||||
self.blocked.lock().expect("restore routes").clear();
|
||||
}
|
||||
fn endpoint(&self, source: u64, target: u64) -> Result<Engine, Unreachable> {
|
||||
if self.blocked.lock().expect("partition check").contains(&(source, target)) {
|
||||
return Err(Unreachable::new(&std::io::Error::other("test partition")));
|
||||
}
|
||||
self.nodes
|
||||
.lock()
|
||||
.expect("endpoint registry")
|
||||
.get(&target)
|
||||
.cloned()
|
||||
.ok_or_else(|| Unreachable::new(&std::io::Error::other("test endpoint unavailable")))
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct DirectFactory {
|
||||
source: u64,
|
||||
routes: Arc<Routes>,
|
||||
}
|
||||
struct DirectClient {
|
||||
source: u64,
|
||||
target: u64,
|
||||
node: CaptureNode,
|
||||
routes: Arc<Routes>,
|
||||
}
|
||||
impl RaftNetworkFactory<CaptureTypeConfig> for DirectFactory {
|
||||
type Network = DirectClient;
|
||||
async fn new_client(&mut self, target: u64, node: &CaptureNode) -> DirectClient {
|
||||
DirectClient {
|
||||
source: self.source,
|
||||
target,
|
||||
node: node.clone(),
|
||||
routes: self.routes.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl RaftNetwork<CaptureTypeConfig> for DirectClient {
|
||||
async fn append_entries(
|
||||
&mut self,
|
||||
request: AppendEntriesRequest<CaptureTypeConfig>,
|
||||
_option: RPCOption,
|
||||
) -> Result<AppendEntriesResponse<u64>, RpcError> {
|
||||
let pause = self
|
||||
.routes
|
||||
.pause_append
|
||||
.lock()
|
||||
.expect("actual RPC pause hook")
|
||||
.remove(&(self.source, self.target));
|
||||
if let Some((entered, resume)) = pause {
|
||||
entered.notify_one();
|
||||
resume.notified().await;
|
||||
}
|
||||
let raft = self.routes.endpoint(self.source, self.target)?;
|
||||
raft.append_entries(request)
|
||||
.await
|
||||
.map_err(|error| RemoteError::new_with_node(self.target, self.node.clone(), error).into())
|
||||
}
|
||||
async fn vote(&mut self, request: VoteRequest<u64>, _option: RPCOption) -> Result<VoteResponse<u64>, RpcError> {
|
||||
let raft = self.routes.endpoint(self.source, self.target)?;
|
||||
raft.vote(request)
|
||||
.await
|
||||
.map_err(|error| RemoteError::new_with_node(self.target, self.node.clone(), error).into())
|
||||
}
|
||||
async fn install_snapshot(
|
||||
&mut self,
|
||||
request: InstallSnapshotRequest<CaptureTypeConfig>,
|
||||
_option: RPCOption,
|
||||
) -> Result<InstallSnapshotResponse<u64>, SnapshotRpcError> {
|
||||
let raft = self.routes.endpoint(self.source, self.target)?;
|
||||
let done = request.done;
|
||||
let result = raft
|
||||
.install_snapshot(request)
|
||||
.await
|
||||
.map_err(|error| RemoteError::new_with_node(self.target, self.node.clone(), error).into());
|
||||
if done && result.is_ok() {
|
||||
self.routes.snapshot_installs.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
result
|
||||
}
|
||||
fn backoff(&self) -> Backoff {
|
||||
Backoff::new(std::iter::repeat(Duration::from_millis(25)))
|
||||
}
|
||||
}
|
||||
fn identity(node_id: u64) -> NodeIdentity {
|
||||
NodeIdentity {
|
||||
cluster_id: [7; 16],
|
||||
node_id,
|
||||
}
|
||||
}
|
||||
fn binding() -> CaptureBinding {
|
||||
CaptureBinding {
|
||||
revision: 9,
|
||||
binding_id: [8; 16],
|
||||
target_id: [9; 16],
|
||||
}
|
||||
}
|
||||
fn command(value: u8) -> CaptureCommand {
|
||||
CaptureCommand::try_created(DecideCreated {
|
||||
operation_id: [value; 16],
|
||||
object: ObjectIdentity {
|
||||
bucket_incarnation: [2; 16],
|
||||
key: vec![value],
|
||||
},
|
||||
expected_head: None,
|
||||
prepared: PreparedIdentity {
|
||||
version_id: [value; 16],
|
||||
preparation_id: [4; 16],
|
||||
content_digest: [5; 32],
|
||||
content_length: 123,
|
||||
},
|
||||
binding_revision: 9,
|
||||
})
|
||||
.expect("native client command")
|
||||
}
|
||||
fn config(snapshot_every: u64) -> Arc<openraft::Config> {
|
||||
Arc::new(
|
||||
openraft::Config {
|
||||
cluster_name: "native-owner-tests".into(),
|
||||
heartbeat_interval: 100,
|
||||
election_timeout_min: 500,
|
||||
election_timeout_max: 1000,
|
||||
snapshot_policy: openraft::SnapshotPolicy::LogsSinceLast(snapshot_every),
|
||||
max_in_snapshot_log_to_keep: 0,
|
||||
purge_batch_size: 1,
|
||||
..openraft::Config::default()
|
||||
}
|
||||
.validate()
|
||||
.expect("valid real Raft test configuration"),
|
||||
)
|
||||
}
|
||||
fn members(ids: &[u64]) -> BTreeMap<u64, CaptureNode> {
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
(
|
||||
*id,
|
||||
CaptureNode::new(format!("http://127.0.0.1:{}", 7000 + id)).expect("bounded test endpoint"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
struct Node {
|
||||
directory: tempfile::TempDir,
|
||||
writer: NativeLogWriter,
|
||||
owner: Option<NativeRaftOwner>,
|
||||
id: u64,
|
||||
}
|
||||
impl Node {
|
||||
async fn start(id: u64, routes: Arc<Routes>, config: Arc<openraft::Config>) -> Self {
|
||||
let directory = tempfile::tempdir().expect("real Raft owner directory");
|
||||
let mut store = Store::initialize(directory.path().join("db"), identity(id)).expect("real owner redb");
|
||||
store.pin_capture_binding(binding()).expect("captured binding");
|
||||
store.enable_native_empty().expect("native storage mode");
|
||||
store.promote_created_provenance().expect("native provenance");
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let owner = NativeRaftOwner::start(
|
||||
writer.clone(),
|
||||
id,
|
||||
config,
|
||||
CheckpointLimits::default(),
|
||||
DirectFactory {
|
||||
source: id,
|
||||
routes: routes.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("actual owner start");
|
||||
routes.register(id, owner.raft().clone());
|
||||
Self {
|
||||
directory,
|
||||
writer,
|
||||
owner: Some(owner),
|
||||
id,
|
||||
}
|
||||
}
|
||||
fn owner(&self) -> &NativeRaftOwner {
|
||||
self.owner.as_ref().expect("live owner")
|
||||
}
|
||||
async fn page(&self) -> EventPage {
|
||||
self.writer
|
||||
.created_events_page(None, 256)
|
||||
.await
|
||||
.expect("actual committed events")
|
||||
}
|
||||
async fn stop(&mut self, routes: &Routes) {
|
||||
self.owner
|
||||
.take()
|
||||
.expect("running owner")
|
||||
.shutdown()
|
||||
.await
|
||||
.expect("real Raft shutdown");
|
||||
routes.remove(self.id);
|
||||
}
|
||||
}
|
||||
struct Cluster {
|
||||
nodes: Vec<Node>,
|
||||
routes: Arc<Routes>,
|
||||
}
|
||||
impl Cluster {
|
||||
async fn start(ids: &[u64], snapshot_every: u64) -> Self {
|
||||
let routes = Arc::new(Routes::default());
|
||||
let mut nodes = Vec::new();
|
||||
for id in ids {
|
||||
nodes.push(Node::start(*id, routes.clone(), config(snapshot_every)).await);
|
||||
}
|
||||
nodes[0]
|
||||
.owner()
|
||||
.bootstrap(members(ids))
|
||||
.await
|
||||
.expect("explicit initial cluster bootstrap");
|
||||
let cluster = Self { nodes, routes };
|
||||
cluster.leader(ids).await;
|
||||
cluster
|
||||
}
|
||||
fn node(&self, id: u64) -> &Node {
|
||||
self.nodes.iter().find(|node| node.id == id).expect("cluster node")
|
||||
}
|
||||
async fn leader(&self, eligible: &[u64]) -> u64 {
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
for id in eligible {
|
||||
let node = self.node(*id);
|
||||
let Some(owner) = &node.owner else {
|
||||
continue;
|
||||
};
|
||||
let metrics = owner.metrics().borrow().clone();
|
||||
assert!(metrics.running_state.is_ok(), "real Raft owner fatal state: {:?}", metrics.running_state);
|
||||
if metrics.state == ServerState::Leader && metrics.current_leader == Some(*id) {
|
||||
return *id;
|
||||
}
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("real leader election readiness")
|
||||
}
|
||||
async fn applied(&self, ids: &[u64], index: u64) {
|
||||
for id in ids {
|
||||
self.node(*id)
|
||||
.owner()
|
||||
.raft()
|
||||
.wait(Some(Duration::from_secs(15)))
|
||||
.applied_index_at_least(Some(index), "real replicated apply readiness")
|
||||
.await
|
||||
.expect("committed log applied on real node");
|
||||
}
|
||||
}
|
||||
async fn stop(mut self) {
|
||||
for node in &mut self.nodes {
|
||||
if node.owner.is_some() {
|
||||
node.stop(&self.routes).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_owner_single_node_bootstrap() {
|
||||
let cluster = Cluster::start(&[0], 100).await;
|
||||
let response = cluster
|
||||
.node(0)
|
||||
.owner()
|
||||
.client_write(command(1))
|
||||
.await
|
||||
.expect("real client_write");
|
||||
assert!(
|
||||
matches!(response.data.created_result(),Some(CreatedResult::Created { event_id,.. }) if *event_id==[1;16]),
|
||||
"OWNER_TRUE_RAFT_CREATED_RESPONSE"
|
||||
);
|
||||
let page = cluster.node(0).page().await;
|
||||
assert_eq!(page.events.len(), 1);
|
||||
assert_eq!(
|
||||
page.events[0].captured.decision_log_id,
|
||||
LogId::from(response.log_id),
|
||||
"OWNER_COMMITTED_EVENT_LOG_ID"
|
||||
);
|
||||
assert!(
|
||||
cluster.node(0).owner().bootstrap(members(&[0])).await.is_err(),
|
||||
"OWNER_BOOTSTRAP_NOT_REINITIALIZATION"
|
||||
);
|
||||
cluster.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_owner_single_node_restart() {
|
||||
let routes = Arc::new(Routes::default());
|
||||
let mut node = Node::start(0, routes.clone(), config(100)).await;
|
||||
node.owner().bootstrap(members(&[0])).await.expect("first bootstrap only");
|
||||
node.owner()
|
||||
.raft()
|
||||
.wait(Some(Duration::from_secs(15)))
|
||||
.state(ServerState::Leader, "single leader")
|
||||
.await
|
||||
.expect("leader ready");
|
||||
node.owner().client_write(command(1)).await.expect("first genuine write");
|
||||
let before = node.page().await;
|
||||
node.stop(&routes).await;
|
||||
let Node {
|
||||
directory,
|
||||
writer,
|
||||
owner: _,
|
||||
id,
|
||||
} = node;
|
||||
let vote = writer.read_vote().await.expect("durable vote before reopen");
|
||||
drop(writer);
|
||||
let store = Store::open(directory.path().join("db"), identity(id)).expect("restart same durable identity");
|
||||
assert_eq!(store.read_vote().expect("persisted vote"), vote.map(Into::into));
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let owner = NativeRaftOwner::start(
|
||||
writer.clone(),
|
||||
id,
|
||||
config(100),
|
||||
CheckpointLimits::default(),
|
||||
DirectFactory {
|
||||
source: id,
|
||||
routes: routes.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("restart existing Raft");
|
||||
routes.register(id, owner.raft().clone());
|
||||
assert!(
|
||||
owner.raft().is_initialized().await.expect("restored cluster membership"),
|
||||
"OWNER_RESTART_DOES_NOT_REQUIRE_BOOTSTRAP"
|
||||
);
|
||||
owner
|
||||
.raft()
|
||||
.wait(Some(Duration::from_secs(15)))
|
||||
.state(ServerState::Leader, "restored leader")
|
||||
.await
|
||||
.expect("restarted leader ready");
|
||||
owner
|
||||
.client_write(command(2))
|
||||
.await
|
||||
.expect("continue after restart without initialize");
|
||||
let after = writer.created_events_page(None, 256).await.expect("restart continued events");
|
||||
assert_eq!(after.events.len(), 2);
|
||||
assert_eq!(after.events[0], before.events[0], "OWNER_RESTART_PRESERVES_FIRST_DECISION");
|
||||
owner.shutdown().await.expect("restart owner shutdown");
|
||||
routes.remove(id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_owner_idempotent_retry() {
|
||||
let cluster = Cluster::start(&[0], 100).await;
|
||||
let owner = cluster.node(0).owner();
|
||||
let first = owner.client_write(command(1)).await.expect("first write");
|
||||
let replay = owner.client_write(command(1)).await.expect("same operation retry");
|
||||
assert_eq!(
|
||||
replay.data.created_result(),
|
||||
first.data.created_result(),
|
||||
"OWNER_RETRY_PRESERVES_FIRST_DECISION_RESULT"
|
||||
);
|
||||
assert!(replay.log_id.index > first.log_id.index, "real retry can append another Raft log");
|
||||
assert_eq!(cluster.node(0).page().await.events.len(), 1, "OWNER_RETRY_NO_DUPLICATE_EVENT");
|
||||
cluster.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_owner_identity_binding() {
|
||||
let routes = Arc::new(Routes::default());
|
||||
let mut node = Node::start(0, routes.clone(), config(100)).await;
|
||||
node.owner().bootstrap(members(&[0])).await.expect("real durable membership");
|
||||
node.owner()
|
||||
.raft()
|
||||
.wait(Some(Duration::from_secs(15)))
|
||||
.state(ServerState::Leader, "single leader")
|
||||
.await
|
||||
.expect("leader");
|
||||
node.owner().client_write(command(1)).await.expect("nonempty durable store");
|
||||
node.stop(&routes).await;
|
||||
let before = std::fs::read(node.directory.path().join("db")).expect("stable identity-bound store");
|
||||
let result = NativeRaftOwner::start(
|
||||
node.writer.clone(),
|
||||
1,
|
||||
config(100),
|
||||
CheckpointLimits::default(),
|
||||
DirectFactory {
|
||||
source: 1,
|
||||
routes: routes.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(NativeRaftOwnerError::IdentityMismatch { runtime: 1, durable: 0 })),
|
||||
"OWNER_REQUIRES_DURABLE_NODE_ID: runtime node cannot reinterpret stored membership/identity"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(node.directory.path().join("db")).expect("unchanged identity-bound store"),
|
||||
before
|
||||
);
|
||||
let invalid_config = Arc::new(openraft::Config {
|
||||
election_timeout_min: 1000,
|
||||
election_timeout_max: 500,
|
||||
..openraft::Config::default()
|
||||
});
|
||||
let invalid = NativeRaftOwner::start(
|
||||
node.writer.clone(),
|
||||
0,
|
||||
invalid_config,
|
||||
CheckpointLimits::default(),
|
||||
DirectFactory {
|
||||
source: 0,
|
||||
routes: routes.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(invalid, Err(NativeRaftOwnerError::Config(_))),
|
||||
"OWNER_REJECTS_INVALID_CONFIG_BEFORE_START"
|
||||
);
|
||||
let mut limits = CheckpointLimits::default();
|
||||
limits.snapshot.total_bytes = 0;
|
||||
let invalid = NativeRaftOwner::start(node.writer.clone(), 0, config(100), limits, DirectFactory { source: 0, routes }).await;
|
||||
assert!(
|
||||
matches!(invalid, Err(NativeRaftOwnerError::Snapshot(_))),
|
||||
"OWNER_REJECTS_INVALID_LIMITS_BEFORE_START"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(node.directory.path().join("db")).expect("invalid start leaves stable bytes"),
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[tokio::test]
|
||||
async fn raft_owner_shared_reader() {
|
||||
use source_reader::SourceReaderClient;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let cluster = Cluster::start(&[0], 100).await;
|
||||
let node = cluster.node(0);
|
||||
std::fs::set_permissions(node.directory.path(), std::fs::Permissions::from_mode(0o700)).expect("private reader parent");
|
||||
let socket = node.directory.path().join("reader.sock");
|
||||
let (server, shutdown) = node
|
||||
.owner()
|
||||
.bind_reader(&socket)
|
||||
.expect("owner shares live writer with reader");
|
||||
let task = tokio::spawn(server.serve());
|
||||
let client = SourceReaderClient::new(&socket).expect("live reader");
|
||||
assert!(
|
||||
client
|
||||
.page(identity(0).cluster_id, None, 1)
|
||||
.await
|
||||
.expect("initial reader page")
|
||||
.page
|
||||
.events
|
||||
.is_empty()
|
||||
);
|
||||
let response = node
|
||||
.owner()
|
||||
.client_write(command(1))
|
||||
.await
|
||||
.expect("real write while reader runs");
|
||||
let page = client
|
||||
.page(identity(0).cluster_id, None, 1)
|
||||
.await
|
||||
.expect("live committed event");
|
||||
assert_eq!(page.identity, identity(0));
|
||||
assert_eq!(
|
||||
page.page.events[0].captured.decision_log_id,
|
||||
LogId::from(response.log_id),
|
||||
"OWNER_READER_USES_REAL_COMMITTED_WRITER"
|
||||
);
|
||||
shutdown.shutdown();
|
||||
task.await.expect("reader serve task").expect("reader drains");
|
||||
cluster.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_owner_shutdown() {
|
||||
let cluster = Cluster::start(&[0], 100).await;
|
||||
let owner = cluster.node(0).owner();
|
||||
owner.client_write(command(1)).await.expect("accepted before shutdown");
|
||||
owner.shutdown().await.expect("actual owner shutdown");
|
||||
assert!(owner.client_write(command(2)).await.is_err(), "OWNER_SHUTDOWN_REJECTS_NEW_WRITES");
|
||||
assert_eq!(cluster.node(0).page().await.events.len(), 1);
|
||||
cluster.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn raft_owner_three_node_majority() {
|
||||
let cluster = Cluster::start(&[0, 1, 2], 100).await;
|
||||
let leader = cluster.leader(&[0, 1, 2]).await;
|
||||
let first = cluster
|
||||
.node(leader)
|
||||
.owner()
|
||||
.client_write(command(1))
|
||||
.await
|
||||
.expect("real quorum write");
|
||||
cluster.applied(&[0, 1, 2], first.log_id.index).await;
|
||||
let follower = [0, 1, 2].into_iter().find(|id| *id != leader).expect("nonleader");
|
||||
let rejected = cluster.node(follower).owner().client_write(command(254)).await;
|
||||
match rejected {
|
||||
Err(NativeRaftOwnerError::Write(error)) => {
|
||||
assert!(
|
||||
matches!(error.api_error(), Some(openraft::error::ClientWriteError::ForwardToLeader(_))),
|
||||
"OWNER_PRESERVES_TYPED_FORWARD_TO_LEADER"
|
||||
);
|
||||
}
|
||||
other => panic!("OWNER_NONLEADER_WRITE_MUST_NOT_SUCCEED: {other:?}"),
|
||||
}
|
||||
cluster.routes.isolate(leader, &[0, 1, 2]);
|
||||
let uncertain =
|
||||
tokio::time::timeout(Duration::from_millis(1500), cluster.node(leader).owner().client_write(command(2))).await;
|
||||
assert!(!matches!(uncertain, Ok(Ok(_))), "OWNER_MAJORITY_REQUIRED_FOR_SUCCESS");
|
||||
let others: Vec<_> = [0, 1, 2].into_iter().filter(|id| *id != leader).collect();
|
||||
let next = cluster.leader(&others).await;
|
||||
let response = cluster
|
||||
.node(next)
|
||||
.owner()
|
||||
.client_write(command(2))
|
||||
.await
|
||||
.expect("majority-owned retry of exact same operation");
|
||||
cluster.routes.heal();
|
||||
cluster.applied(&[0, 1, 2], response.log_id.index).await;
|
||||
for node in &cluster.nodes {
|
||||
assert_eq!(node.page().await.events.len(), 2, "OWNER_QUORUM_RETRY_NO_DUPLICATE_EVENT");
|
||||
}
|
||||
cluster.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn raft_owner_leader_failover() {
|
||||
let mut cluster = Cluster::start(&[0, 1, 2], 100).await;
|
||||
let leader = cluster.leader(&[0, 1, 2]).await;
|
||||
let first = cluster
|
||||
.node(leader)
|
||||
.owner()
|
||||
.client_write(command(1))
|
||||
.await
|
||||
.expect("first confirmed write");
|
||||
cluster.applied(&[0, 1, 2], first.log_id.index).await;
|
||||
let before = cluster.node(leader).page().await.events[0].clone();
|
||||
cluster
|
||||
.nodes
|
||||
.iter_mut()
|
||||
.find(|node| node.id == leader)
|
||||
.expect("old leader")
|
||||
.stop(&cluster.routes)
|
||||
.await;
|
||||
let survivors: Vec<_> = [0, 1, 2].into_iter().filter(|id| *id != leader).collect();
|
||||
let next = cluster.leader(&survivors).await;
|
||||
let response = cluster
|
||||
.node(next)
|
||||
.owner()
|
||||
.client_write(command(2))
|
||||
.await
|
||||
.expect("new leader actual quorum write");
|
||||
cluster.applied(&survivors, response.log_id.index).await;
|
||||
for id in survivors {
|
||||
let page = cluster.node(id).page().await;
|
||||
assert_eq!(page.events.len(), 2);
|
||||
assert_eq!(page.events[0], before, "OWNER_FAILOVER_PRESERVES_CONFIRMED_DECISION");
|
||||
}
|
||||
cluster.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn raft_owner_snapshot_catchup() {
|
||||
let cluster = Cluster::start(&[0, 1, 2], 1000).await;
|
||||
let leader = cluster.leader(&[0, 1, 2]).await;
|
||||
let follower = [0, 1, 2].into_iter().find(|id| *id != leader).expect("lagging follower");
|
||||
let first = cluster
|
||||
.node(leader)
|
||||
.owner()
|
||||
.client_write(command(1))
|
||||
.await
|
||||
.expect("initial confirmed event");
|
||||
cluster.applied(&[0, 1, 2], first.log_id.index).await;
|
||||
// Keep the catch-up test focused on replication rather than an isolated follower's election.
|
||||
cluster.node(follower).owner().raft().runtime_config().elect(false);
|
||||
cluster.routes.isolate(follower, &[0, 1, 2]);
|
||||
let mut last = first;
|
||||
for value in 2..=12 {
|
||||
last = cluster
|
||||
.node(leader)
|
||||
.owner()
|
||||
.client_write(command(value))
|
||||
.await
|
||||
.expect("majority writes while follower offline");
|
||||
}
|
||||
cluster
|
||||
.node(leader)
|
||||
.owner()
|
||||
.raft()
|
||||
.trigger()
|
||||
.snapshot()
|
||||
.await
|
||||
.expect("true Raft snapshot trigger");
|
||||
cluster
|
||||
.node(leader)
|
||||
.owner()
|
||||
.raft()
|
||||
.wait(Some(Duration::from_secs(15)))
|
||||
.metrics(
|
||||
|metrics| metrics.purged.is_some_and(|cut| cut.index >= last.log_id.index),
|
||||
"snapshot durably purged lagging log prefix",
|
||||
)
|
||||
.await
|
||||
.expect("real snapshot and purge");
|
||||
let before = cluster.routes.snapshot_installs.load(Ordering::Acquire);
|
||||
cluster.routes.heal();
|
||||
cluster.applied(&[0, 1, 2], last.log_id.index).await;
|
||||
cluster
|
||||
.node(follower)
|
||||
.owner()
|
||||
.raft()
|
||||
.wait(Some(Duration::from_secs(15)))
|
||||
.metrics(
|
||||
|metrics| metrics.snapshot.is_some_and(|cut| cut.index >= last.log_id.index),
|
||||
"follower installed actual transferred snapshot",
|
||||
)
|
||||
.await
|
||||
.expect("real snapshot catch-up");
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
while cluster.routes.snapshot_installs.load(Ordering::Acquire) <= before {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("snapshot readiness includes actual successful done-RPC return");
|
||||
assert!(
|
||||
cluster.routes.snapshot_installs.load(Ordering::Acquire) > before,
|
||||
"OWNER_CATCHUP_USED_ACTUAL_SNAPSHOT_RPC"
|
||||
);
|
||||
let expected = cluster.node(leader).page().await;
|
||||
for node in &cluster.nodes {
|
||||
assert_eq!(node.page().await, expected, "OWNER_SNAPSHOT_CATCHUP_FULL_SOURCE_EVENTS");
|
||||
}
|
||||
cluster.node(follower).owner().raft().runtime_config().elect(true);
|
||||
let current = cluster.leader(&[0, 1, 2]).await;
|
||||
let next = cluster
|
||||
.node(current)
|
||||
.owner()
|
||||
.client_write(command(13))
|
||||
.await
|
||||
.expect("continue after real snapshot install");
|
||||
cluster.applied(&[0, 1, 2], next.log_id.index).await;
|
||||
for node in &cluster.nodes {
|
||||
assert_eq!(node.page().await.events.len(), 13);
|
||||
}
|
||||
cluster.stop().await;
|
||||
}
|
||||
|
||||
async fn wait_for_shutdown_drain(waiters: &AtomicUsize) {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while waiters.load(Ordering::Acquire) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("OWNER_SHUTDOWN_DRAINS_INFLIGHT_WORK: owner reached real worker drain wait");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_owner_shutdown_inflight_snapshot() {
|
||||
let routes = Arc::new(Routes::default());
|
||||
let mut node = Node::start(0, routes.clone(), config(1000)).await;
|
||||
node.owner()
|
||||
.bootstrap(members(&[0]))
|
||||
.await
|
||||
.expect("real single-node bootstrap");
|
||||
node.owner()
|
||||
.raft()
|
||||
.wait(Some(Duration::from_secs(15)))
|
||||
.state(ServerState::Leader, "leader before snapshot")
|
||||
.await
|
||||
.expect("leader readiness");
|
||||
node.owner()
|
||||
.client_write(command(1))
|
||||
.await
|
||||
.expect("real committed snapshot input");
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let resume = Arc::new(tokio::sync::Notify::new());
|
||||
*node.owner().snapshot_build_pause.lock().expect("real builder hook") = Some((entered.clone(), resume.clone()));
|
||||
node.owner()
|
||||
.raft()
|
||||
.trigger()
|
||||
.snapshot()
|
||||
.await
|
||||
.expect("real Raft snapshot request");
|
||||
tokio::time::timeout(Duration::from_secs(5), entered.notified())
|
||||
.await
|
||||
.expect("real detached snapshot builder paused");
|
||||
let owner = node.owner.take().expect("running owner");
|
||||
let waiters = owner.shutdown_waiters();
|
||||
let observer = owner.raft().clone();
|
||||
let shutdown = tokio::spawn(async move {
|
||||
let result = owner.shutdown().await;
|
||||
(owner, result)
|
||||
});
|
||||
wait_for_shutdown_drain(&waiters).await;
|
||||
assert!(
|
||||
observer.metrics().borrow().running_state.is_err(),
|
||||
"OWNER_CORE_STOPPED_WHILE_SNAPSHOT_DRAIN_PENDING"
|
||||
);
|
||||
assert!(!shutdown.is_finished(), "OWNER_SHUTDOWN_DRAINS_INFLIGHT_SNAPSHOT");
|
||||
resume.notify_one();
|
||||
let (owner, result) = tokio::time::timeout(Duration::from_secs(10), shutdown)
|
||||
.await
|
||||
.expect("released snapshot lets shutdown finish")
|
||||
.expect("shutdown task");
|
||||
result.expect("snapshot work drained");
|
||||
routes.remove(0);
|
||||
drop(observer);
|
||||
drop(owner);
|
||||
drop(waiters);
|
||||
let Node {
|
||||
directory,
|
||||
writer,
|
||||
owner: _,
|
||||
id,
|
||||
} = node;
|
||||
drop(writer);
|
||||
let store = Store::open(directory.path().join("db"), identity(id)).expect("OWNER_SHUTDOWN_SNAPSHOT_RELEASES_LIVE_DATABASE");
|
||||
assert_eq!(
|
||||
store
|
||||
.created_events_page(None, 256)
|
||||
.expect("retained committed source events")
|
||||
.events
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn raft_owner_shutdown_inflight_replication() {
|
||||
// OpenRaft times append_entries by heartbeat_interval. Keep the real deadline
|
||||
// longer than readiness checks so only explicit RPC release ends this pause.
|
||||
let slow = Arc::new(
|
||||
openraft::Config {
|
||||
heartbeat_interval: 10_000,
|
||||
election_timeout_min: 20_000,
|
||||
election_timeout_max: 30_000,
|
||||
snapshot_policy: openraft::SnapshotPolicy::LogsSinceLast(1000),
|
||||
..openraft::Config::default()
|
||||
}
|
||||
.validate()
|
||||
.expect("long real RPC deadline"),
|
||||
);
|
||||
let routes = Arc::new(Routes::default());
|
||||
let mut nodes = Vec::new();
|
||||
for id in [0, 1, 2] {
|
||||
nodes.push(Node::start(id, routes.clone(), slow.clone()).await);
|
||||
}
|
||||
nodes[0]
|
||||
.owner()
|
||||
.bootstrap(members(&[0, 1, 2]))
|
||||
.await
|
||||
.expect("real three-node bootstrap");
|
||||
nodes[0]
|
||||
.owner()
|
||||
.raft()
|
||||
.trigger()
|
||||
.elect()
|
||||
.await
|
||||
.expect("explicit test election readiness");
|
||||
let mut cluster = Cluster { nodes, routes };
|
||||
let leader = cluster.leader(&[0, 1, 2]).await;
|
||||
let response = cluster
|
||||
.node(leader)
|
||||
.owner()
|
||||
.client_write(command(1))
|
||||
.await
|
||||
.expect("real replicated write before pause");
|
||||
cluster.applied(&[0, 1, 2], response.log_id.index).await;
|
||||
let follower = [0, 1, 2].into_iter().find(|id| *id != leader).expect("replication target");
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let resume = Arc::new(tokio::sync::Notify::new());
|
||||
cluster
|
||||
.routes
|
||||
.pause_append
|
||||
.lock()
|
||||
.expect("actual request pause")
|
||||
.insert((leader, follower), (entered.clone(), resume.clone()));
|
||||
cluster
|
||||
.node(leader)
|
||||
.owner()
|
||||
.raft()
|
||||
.trigger()
|
||||
.heartbeat()
|
||||
.await
|
||||
.expect("real replication RPC");
|
||||
tokio::time::timeout(Duration::from_secs(5), entered.notified())
|
||||
.await
|
||||
.expect("actual AppendEntries in flight");
|
||||
let node = cluster.nodes.iter_mut().find(|node| node.id == leader).expect("leader node");
|
||||
let owner = node.owner.take().expect("leader owner");
|
||||
let waiters = owner.shutdown_waiters();
|
||||
let observer = owner.raft().clone();
|
||||
let shutdown = tokio::spawn(async move {
|
||||
let result = owner.shutdown().await;
|
||||
(owner, result)
|
||||
});
|
||||
wait_for_shutdown_drain(&waiters).await;
|
||||
assert!(
|
||||
observer.metrics().borrow().running_state.is_err(),
|
||||
"OWNER_CORE_STOPPED_WHILE_REPLICATION_DRAIN_PENDING"
|
||||
);
|
||||
assert!(!shutdown.is_finished(), "OWNER_SHUTDOWN_DRAINS_INFLIGHT_REPLICATION");
|
||||
resume.notify_one();
|
||||
let (owner, result) = tokio::time::timeout(Duration::from_secs(10), shutdown)
|
||||
.await
|
||||
.expect("released real RPC lets shutdown finish")
|
||||
.expect("replication shutdown task");
|
||||
result.expect("actual replication reader drained");
|
||||
cluster.routes.remove(leader);
|
||||
drop(observer);
|
||||
drop(owner);
|
||||
drop(waiters);
|
||||
let position = cluster
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|node| node.id == leader)
|
||||
.expect("stopped leader");
|
||||
let Node {
|
||||
directory,
|
||||
writer,
|
||||
owner: _,
|
||||
id,
|
||||
} = cluster.nodes.remove(position);
|
||||
drop(writer);
|
||||
let store =
|
||||
Store::open(directory.path().join("db"), identity(id)).expect("OWNER_SHUTDOWN_REPLICATION_RELEASES_LIVE_DATABASE");
|
||||
assert_eq!(
|
||||
store
|
||||
.created_events_page(None, 256)
|
||||
.expect("real quorum event remains")
|
||||
.events
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
drop(store);
|
||||
cluster.stop().await;
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
//! Durable OpenRaft application state and selected baseline snapshots.
|
||||
use crate::{
|
||||
CaptureRaftEntry, CaptureResponse, CaptureTypeConfig, CheckpointLimits, NativeLogWriter, NativeSnapshotData,
|
||||
NativeSnapshotError, StoreError, checkpoint_baseline, snapshot,
|
||||
};
|
||||
use openraft::{
|
||||
OptionalSend, RaftSnapshotBuilder, Snapshot, SnapshotMeta, StorageError, StorageIOError, StoredMembership,
|
||||
storage::RaftStateMachine,
|
||||
};
|
||||
use redb::{ReadableDatabase, ReadableTable, ReadableTableMetadata};
|
||||
use std::{
|
||||
io::{Seek, Write},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) const SNAPSHOT_META: &str = "native_snapshot_meta_v1";
|
||||
type Meta = SnapshotMeta<u64, crate::CaptureNode>;
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SelectedMeta {
|
||||
generation: u64,
|
||||
meta: Meta,
|
||||
}
|
||||
|
||||
pub(crate) fn publish_meta(
|
||||
meta: &mut redb::Table<&str, &[u8]>,
|
||||
baseline: &checkpoint_baseline::BaselineMetadata,
|
||||
value: Meta,
|
||||
) -> Result<(), StoreError> {
|
||||
let bytes = serde_json::to_vec(&SelectedMeta {
|
||||
generation: baseline.generation,
|
||||
meta: value,
|
||||
})
|
||||
.map_err(|_| StoreError::CorruptRecord)?;
|
||||
if bytes.len() > 1024 * 1024 {
|
||||
return Err(StoreError::ResourceLimit);
|
||||
}
|
||||
meta.insert(SNAPSHOT_META, bytes.as_slice())?;
|
||||
meta.insert("native_snapshot_meta_required", &[1u8][..])?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn local_meta(
|
||||
baseline: &checkpoint_baseline::BaselineMetadata,
|
||||
identity: crate::NodeIdentity,
|
||||
) -> Result<Meta, StoreError> {
|
||||
Ok(Meta {
|
||||
last_log_id: baseline.header.applied.map(Into::into),
|
||||
last_membership: StoredMembership::try_from(&baseline.header.membership)?,
|
||||
snapshot_id: format!("native-local-{:x?}-{}-{}", identity.cluster_id, identity.node_id, baseline.generation),
|
||||
})
|
||||
}
|
||||
pub(crate) fn publish_local_meta(
|
||||
meta: &mut redb::Table<&str, &[u8]>,
|
||||
baseline: &checkpoint_baseline::BaselineMetadata,
|
||||
) -> Result<(), StoreError> {
|
||||
let identity = crate::decode_identity(meta.get("identity")?.ok_or(StoreError::CorruptRecord)?.value())?;
|
||||
publish_meta(meta, baseline, local_meta(baseline, identity)?)
|
||||
}
|
||||
pub(crate) fn selected_meta(
|
||||
meta: &impl ReadableTable<&'static str, &'static [u8]>,
|
||||
baseline: &checkpoint_baseline::BaselineMetadata,
|
||||
) -> Result<Meta, StoreError> {
|
||||
let Some(bytes) = meta.get(SNAPSHOT_META)? else {
|
||||
if meta.get("native_snapshot_meta_required")?.is_some() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
return local_meta(
|
||||
baseline,
|
||||
crate::decode_identity(meta.get("identity")?.ok_or(StoreError::CorruptRecord)?.value())?,
|
||||
);
|
||||
};
|
||||
if bytes.value().len() > 1024 * 1024 {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let selected: SelectedMeta = serde_json::from_slice(bytes.value()).map_err(|_| StoreError::CorruptRecord)?;
|
||||
if selected.generation != baseline.generation
|
||||
|| selected.meta.last_log_id.map(crate::LogId::from) != baseline.header.applied
|
||||
|| crate::CaptureMembership::try_from(&selected.meta.last_membership)? != baseline.header.membership
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(selected.meta)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) type TestPause = std::sync::Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ReceiveState {
|
||||
count: AtomicUsize,
|
||||
#[cfg(test)]
|
||||
pub(crate) purge_recheck: TestPause,
|
||||
pub(crate) changed: tokio::sync::Notify,
|
||||
#[cfg(test)]
|
||||
pub(crate) waiters: AtomicUsize,
|
||||
}
|
||||
pub(crate) struct ReceiveGuard {
|
||||
state: Arc<ReceiveState>,
|
||||
}
|
||||
impl ReceiveGuard {
|
||||
pub(crate) fn belongs_to(&self, state: &Arc<ReceiveState>) -> bool {
|
||||
Arc::ptr_eq(&self.state, state)
|
||||
}
|
||||
fn new(state: Arc<ReceiveState>) -> Self {
|
||||
state.count.fetch_add(1, Ordering::AcqRel);
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
impl Drop for ReceiveGuard {
|
||||
fn drop(&mut self) {
|
||||
self.state.count.fetch_sub(1, Ordering::AcqRel);
|
||||
self.state.changed.notify_waiters();
|
||||
}
|
||||
}
|
||||
impl ReceiveState {
|
||||
pub(crate) fn pending(&self) -> bool {
|
||||
self.count.load(Ordering::Acquire) > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Both adapters must use clones of the same writer. Limits apply while receiving.
|
||||
#[derive(Clone)]
|
||||
pub struct NativeRaftStateMachine {
|
||||
pub(crate) writer: NativeLogWriter,
|
||||
pub(crate) limits: CheckpointLimits,
|
||||
#[cfg(test)]
|
||||
pub(crate) build_before_ordering: Arc<TestPause>,
|
||||
// Declared last: every clone releases its writer before the final owner lease.
|
||||
pub(crate) owner_lease: Option<Arc<crate::raft_owner::OwnerLease>>,
|
||||
}
|
||||
impl NativeRaftStateMachine {
|
||||
pub fn new(writer: NativeLogWriter, limits: CheckpointLimits) -> Result<Self, NativeSnapshotError> {
|
||||
limits.validate()?;
|
||||
limits.snapshot.total_bytes.checked_add(56).ok_or(StoreError::ResourceLimit)?;
|
||||
Ok(Self {
|
||||
writer,
|
||||
limits,
|
||||
#[cfg(test)]
|
||||
build_before_ordering: Arc::new(TestPause::default()),
|
||||
owner_lease: None,
|
||||
})
|
||||
}
|
||||
fn maximum(&self) -> u64 {
|
||||
self.limits.snapshot.total_bytes + 56
|
||||
}
|
||||
async fn current(&self) -> Result<Option<Snapshot<CaptureTypeConfig>>, NativeSnapshotError> {
|
||||
let owner = self.writer.store.clone();
|
||||
let limits = self.limits;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let store = owner.lock().map_err(|_| StoreError::CorruptRecord)?;
|
||||
if !store.writable {
|
||||
return Err(StoreError::WriteDisabled);
|
||||
}
|
||||
let tx = store.database.begin_read()?;
|
||||
drop(store);
|
||||
let table = tx.open_table(crate::META)?;
|
||||
let Some(baseline) = checkpoint_baseline::read_metadata(&table)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let meta = selected_meta(&table, &baseline)?;
|
||||
let definitions = [
|
||||
checkpoint_baseline::HEADS,
|
||||
checkpoint_baseline::VERSIONS,
|
||||
checkpoint_baseline::EVENTS,
|
||||
checkpoint_baseline::OPERATIONS,
|
||||
];
|
||||
let mut counts = [0; 4];
|
||||
for (i, definition) in definitions.iter().enumerate() {
|
||||
counts[i] = tx.open_table(*definition)?.len()?;
|
||||
}
|
||||
let out = snapshot::CapabilityOutput::new(Vec::new(), limits.snapshot.total_bytes);
|
||||
let mut encoder =
|
||||
snapshot::Encoder::new(out, &baseline.header, counts, limits.snapshot).map_err(|_| StoreError::CorruptRecord)?;
|
||||
for (definition, section) in definitions.into_iter().zip([
|
||||
snapshot::Section::Heads,
|
||||
snapshot::Section::Versions,
|
||||
snapshot::Section::Events,
|
||||
snapshot::Section::Operations,
|
||||
]) {
|
||||
for row in tx.open_table(definition)?.iter()? {
|
||||
let (key, value) = row?;
|
||||
encoder
|
||||
.row(snapshot::Row {
|
||||
section,
|
||||
key: key.value(),
|
||||
value: value.value(),
|
||||
})
|
||||
.map_err(|_| StoreError::CorruptRecord)?;
|
||||
}
|
||||
}
|
||||
let artifact = encoder.finish().map_err(|_| StoreError::CorruptRecord)?.finish();
|
||||
let mut data = NativeSnapshotData::new(limits.snapshot.total_bytes + 56).map_err(|_| StoreError::ResourceLimit)?;
|
||||
data.write_all(&artifact.manifest.bytes)
|
||||
.map_err(|_| StoreError::ResourceLimit)?;
|
||||
data.write_all(&artifact.payload).map_err(|_| StoreError::ResourceLimit)?;
|
||||
data.rewind().map_err(|_| StoreError::CorruptRecord)?;
|
||||
Ok::<_, StoreError>(Some(Snapshot {
|
||||
meta,
|
||||
snapshot: Box::new(data),
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(NativeSnapshotError::Worker)?
|
||||
.map_err(NativeSnapshotError::Store)
|
||||
}
|
||||
}
|
||||
fn failure(error: impl std::error::Error + 'static) -> StorageError<u64> {
|
||||
StorageIOError::read_state_machine(openraft::AnyError::new(&error)).into()
|
||||
}
|
||||
impl RaftStateMachine<CaptureTypeConfig> for NativeRaftStateMachine {
|
||||
type SnapshotBuilder = Self;
|
||||
async fn applied_state(
|
||||
&mut self,
|
||||
) -> Result<(Option<openraft::LogId<u64>>, StoredMembership<u64, crate::CaptureNode>), StorageError<u64>> {
|
||||
let (id, membership) = self.writer.applied_state().await.map_err(failure)?;
|
||||
Ok((id.map(Into::into), StoredMembership::try_from(&membership).map_err(failure)?))
|
||||
}
|
||||
async fn apply<I>(&mut self, entries: I) -> Result<Vec<CaptureResponse>, StorageError<u64>>
|
||||
where
|
||||
I: IntoIterator<Item = CaptureRaftEntry> + OptionalSend,
|
||||
I::IntoIter: OptionalSend,
|
||||
{
|
||||
self.writer
|
||||
.apply_committed(entries.into_iter().collect())
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
async fn get_snapshot_builder(&mut self) -> Self {
|
||||
self.clone()
|
||||
}
|
||||
async fn begin_receiving_snapshot(&mut self) -> Result<Box<NativeSnapshotData>, StorageError<u64>> {
|
||||
let mut data = NativeSnapshotData::new(self.maximum()).map_err(failure)?;
|
||||
data.attach_receive_guard(ReceiveGuard::new(self.writer.snapshot_receives.clone()));
|
||||
Ok(Box::new(data))
|
||||
}
|
||||
async fn install_snapshot(&mut self, meta: &Meta, data: Box<NativeSnapshotData>) -> Result<(), StorageError<u64>> {
|
||||
crate::native_snapshot_install::install(self.writer.clone(), self.limits, meta.clone(), data)
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
async fn get_current_snapshot(&mut self) -> Result<Option<Snapshot<CaptureTypeConfig>>, StorageError<u64>> {
|
||||
self.current().await.map_err(failure)
|
||||
}
|
||||
}
|
||||
impl RaftSnapshotBuilder<CaptureTypeConfig> for NativeRaftStateMachine {
|
||||
async fn build_snapshot(&mut self) -> Result<Snapshot<CaptureTypeConfig>, StorageError<u64>> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let hook = self.build_before_ordering.lock().expect("build test hook").take();
|
||||
if let Some((entered, resume)) = hook {
|
||||
entered.notify_one();
|
||||
resume.notified().await;
|
||||
}
|
||||
}
|
||||
let limits = self.limits;
|
||||
let published_empty = self
|
||||
.writer
|
||||
.write(move |store| {
|
||||
let tx = store.write_transaction()?;
|
||||
let mut meta = tx.open_table(crate::META)?;
|
||||
// Decide the empty/nonempty path under the original writer ordering.
|
||||
if crate::application::read_applied(&meta)?.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
let identity = crate::decode_identity(meta.get("identity")?.ok_or(StoreError::CorruptRecord)?.value())?;
|
||||
let membership =
|
||||
crate::CaptureMembership::decode(meta.get("native_membership")?.ok_or(StoreError::CorruptRecord)?.value())?;
|
||||
let previous = checkpoint_baseline::read_metadata(&meta)?;
|
||||
let generation = previous.map_or(Ok(1), |v| v.generation.checked_add(1).ok_or(StoreError::ResourceLimit))?;
|
||||
let baseline = checkpoint_baseline::BaselineMetadata {
|
||||
generation,
|
||||
header: snapshot::Header {
|
||||
cluster_id: identity.cluster_id,
|
||||
applied: None,
|
||||
membership,
|
||||
binding: crate::application::read_binding(&meta)?,
|
||||
},
|
||||
limits,
|
||||
};
|
||||
for (live, base) in [
|
||||
(crate::application::HEADS, checkpoint_baseline::HEADS),
|
||||
(crate::application::VERSIONS, checkpoint_baseline::VERSIONS),
|
||||
(crate::application::EVENTS, checkpoint_baseline::EVENTS),
|
||||
(crate::application::OPERATIONS, checkpoint_baseline::OPERATIONS),
|
||||
] {
|
||||
if tx.open_table(live)?.len()? != 0 {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
tx.open_table(base)?.retain(|_, _| false)?;
|
||||
}
|
||||
meta.insert("schema", 7u64.to_be_bytes().as_slice())?;
|
||||
meta.insert(checkpoint_baseline::META_KEY, checkpoint_baseline::encode_metadata(&baseline)?.as_slice())?;
|
||||
publish_local_meta(&mut meta, &baseline)?;
|
||||
drop(meta);
|
||||
store.commit(tx)?;
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
.map_err(failure)?;
|
||||
if !published_empty {
|
||||
self.writer.checkpoint_local(self.limits).await.map_err(failure)?;
|
||||
}
|
||||
self.current()
|
||||
.await
|
||||
.map_err(failure)?
|
||||
.ok_or_else(|| failure(StoreError::CorruptRecord))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct Waiter(Arc<ReceiveState>);
|
||||
#[cfg(test)]
|
||||
impl Waiter {
|
||||
pub(crate) fn new(state: Arc<ReceiveState>) -> Self {
|
||||
state.waiters.fetch_add(1, Ordering::AcqRel);
|
||||
Self(state)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl Drop for Waiter {
|
||||
fn drop(&mut self) {
|
||||
self.0.waiters.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
//! Actual OpenRaft storage traits over real redb; no simulated state machine.
|
||||
use super::*;
|
||||
use openraft::{
|
||||
RaftLogReader, RaftSnapshotBuilder,
|
||||
storage::{RaftLogStorage, RaftLogStorageExt, RaftStateMachine},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
io::SeekFrom,
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
|
||||
pub(super) type Snapshot = openraft::Snapshot<CaptureTypeConfig>;
|
||||
pub(super) fn identity(node_id: u64) -> NodeIdentity {
|
||||
NodeIdentity {
|
||||
cluster_id: [7; 16],
|
||||
node_id,
|
||||
}
|
||||
}
|
||||
pub(super) fn binding() -> CaptureBinding {
|
||||
CaptureBinding {
|
||||
revision: 9,
|
||||
binding_id: [8; 16],
|
||||
target_id: [9; 16],
|
||||
}
|
||||
}
|
||||
pub(super) fn id(index: u64) -> LogId {
|
||||
LogId {
|
||||
term: 8,
|
||||
leader_node: 0,
|
||||
index,
|
||||
}
|
||||
}
|
||||
pub(super) fn entry(index: u64) -> CaptureRaftEntry {
|
||||
let payload = if index == 0 {
|
||||
let membership = CaptureMembership::new(
|
||||
Some(id(0)),
|
||||
vec![BTreeSet::from([0])],
|
||||
BTreeMap::from([(0, CaptureNode::new("http://127.0.0.1:7000".into()).expect("member"))]),
|
||||
)
|
||||
.expect("native membership");
|
||||
let stored = openraft::StoredMembership::try_from(&membership).expect("stored membership");
|
||||
openraft::EntryPayload::Membership(stored.membership().clone())
|
||||
} else {
|
||||
let byte = u8::try_from(index).expect("bounded test event");
|
||||
openraft::EntryPayload::Normal(
|
||||
CaptureCommand::try_created(DecideCreated {
|
||||
operation_id: [byte; 16],
|
||||
object: ObjectIdentity {
|
||||
bucket_incarnation: [2; 16],
|
||||
key: vec![byte],
|
||||
},
|
||||
expected_head: None,
|
||||
prepared: PreparedIdentity {
|
||||
version_id: [byte; 16],
|
||||
preparation_id: [4; 16],
|
||||
content_digest: [5; 32],
|
||||
content_length: 123,
|
||||
},
|
||||
binding_revision: binding().revision,
|
||||
})
|
||||
.expect("native command"),
|
||||
)
|
||||
};
|
||||
CaptureRaftEntry {
|
||||
log_id: id(index).into(),
|
||||
payload,
|
||||
}
|
||||
}
|
||||
pub(super) fn entries(end: u64) -> Vec<CaptureRaftEntry> {
|
||||
(0..=end).map(entry).collect()
|
||||
}
|
||||
pub(super) struct Fixture {
|
||||
pub directory: tempfile::TempDir,
|
||||
pub writer: NativeLogWriter,
|
||||
pub logs: NativeRaftLogStore,
|
||||
pub sm: NativeRaftStateMachine,
|
||||
pub identity: NodeIdentity,
|
||||
}
|
||||
impl Fixture {
|
||||
pub fn new(node_id: u64) -> Self {
|
||||
Self::with_identity_binding(identity(node_id), binding())
|
||||
}
|
||||
pub fn with_identity_binding(identity: NodeIdentity, binding: CaptureBinding) -> Self {
|
||||
let directory = tempfile::tempdir().expect("real state machine directory");
|
||||
let mut store = Store::initialize(directory.path().join("db"), identity).expect("real redb initialization");
|
||||
store.pin_capture_binding(binding).expect("native binding");
|
||||
store.enable_native_empty().expect("native mode");
|
||||
store.promote_created_provenance().expect("provenance");
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let logs = NativeRaftLogStore::new(writer.clone());
|
||||
let sm = NativeRaftStateMachine::new(writer.clone(), CheckpointLimits::default()).expect("real state machine adapter");
|
||||
Self {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
}
|
||||
}
|
||||
pub async fn apply(&mut self, batch: Vec<CaptureRaftEntry>) -> Vec<CaptureResponse> {
|
||||
self.logs
|
||||
.blocking_append(batch.clone())
|
||||
.await
|
||||
.expect("actual trait append and library flush callback");
|
||||
self.sm.apply(batch).await.expect("actual state machine trait apply")
|
||||
}
|
||||
pub async fn build(&mut self) -> Snapshot {
|
||||
self.sm
|
||||
.get_snapshot_builder()
|
||||
.await
|
||||
.build_snapshot()
|
||||
.await
|
||||
.expect("actual durable snapshot builder")
|
||||
}
|
||||
pub async fn page(&self) -> EventPage {
|
||||
self.writer
|
||||
.created_events_page(None, 256)
|
||||
.await
|
||||
.expect("authoritative native events")
|
||||
}
|
||||
}
|
||||
pub(super) async fn snapshot_bytes(snapshot: &mut Snapshot) -> Vec<u8> {
|
||||
snapshot.snapshot.seek(SeekFrom::Start(0)).await.expect("snapshot rewind");
|
||||
let mut bytes = Vec::new();
|
||||
snapshot
|
||||
.snapshot
|
||||
.read_to_end(&mut bytes)
|
||||
.await
|
||||
.expect("actual snapshot bytes");
|
||||
bytes
|
||||
}
|
||||
pub(super) async fn receive(
|
||||
sm: &mut NativeRaftStateMachine,
|
||||
bytes: &[u8],
|
||||
) -> Box<<CaptureTypeConfig as openraft::RaftTypeConfig>::SnapshotData> {
|
||||
let mut data = sm.begin_receiving_snapshot().await.expect("actual bounded receive handle");
|
||||
data.write_all(bytes).await.expect("receive real snapshot bytes");
|
||||
data.seek(SeekFrom::Start(0)).await.expect("received snapshot rewind");
|
||||
data
|
||||
}
|
||||
pub(super) async fn install(sm: &mut NativeRaftStateMachine, snapshot: &mut Snapshot) {
|
||||
let bytes = snapshot_bytes(snapshot).await;
|
||||
let data = receive(sm, &bytes).await;
|
||||
sm.install_snapshot(&snapshot.meta, data)
|
||||
.await
|
||||
.expect("actual state machine trait install");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_state_machine_apply_restart() {
|
||||
let mut f = Fixture::new(0);
|
||||
assert_eq!(f.sm.applied_state().await.expect("empty applied state").0, None);
|
||||
assert!(f.sm.get_current_snapshot().await.expect("no published snapshot").is_none());
|
||||
let batch = entries(2);
|
||||
let responses = f.apply(batch.clone()).await;
|
||||
assert_eq!(responses.len(), 3, "RAFT_SM_REAL_RESPONSE_COUNT");
|
||||
assert_eq!(responses[0].kind(), CaptureResponseKind::Membership);
|
||||
assert!(matches!(responses[1].created_result(), Some(CreatedResult::Created { .. })));
|
||||
let state = f.sm.applied_state().await.expect("durable applied state");
|
||||
assert_eq!(state.0, Some(id(2).into()));
|
||||
assert_eq!(state.1.log_id(), &Some(id(0).into()));
|
||||
let before = f.page().await;
|
||||
let Fixture {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
} = f;
|
||||
drop(sm);
|
||||
drop(logs);
|
||||
drop(writer);
|
||||
let writer = NativeLogWriter::new(Store::open(directory.path().join("db"), identity).expect("restart real store"));
|
||||
let mut sm = NativeRaftStateMachine::new(writer.clone(), CheckpointLimits::default()).expect("reopened state machine");
|
||||
assert_eq!(
|
||||
sm.applied_state().await.expect("reopened applied state"),
|
||||
state,
|
||||
"RAFT_SM_APPLY_ACK_DURABLE"
|
||||
);
|
||||
assert_eq!(writer.created_events_page(None, 256).await.expect("reopened events"), before);
|
||||
assert_eq!(
|
||||
sm.apply(batch).await.expect("exact replay responses"),
|
||||
responses,
|
||||
"RAFT_SM_REPLAY_SAME_RESPONSES"
|
||||
);
|
||||
assert_eq!(
|
||||
writer
|
||||
.created_events_page(None, 256)
|
||||
.await
|
||||
.expect("no duplicate replay events"),
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_state_machine_snapshot_build_get_restart() {
|
||||
let mut f = Fixture::new(0);
|
||||
f.apply(entries(2)).await;
|
||||
let mut built = f.build().await;
|
||||
let meta = built.meta.clone();
|
||||
let bytes = snapshot_bytes(&mut built).await;
|
||||
assert_eq!(meta.last_log_id, Some(id(2).into()), "RAFT_SM_SNAPSHOT_META_CUT");
|
||||
assert_eq!(meta.last_membership, f.sm.applied_state().await.expect("membership").1);
|
||||
f.apply(vec![entry(3)]).await;
|
||||
let mut current =
|
||||
f.sm.get_current_snapshot()
|
||||
.await
|
||||
.expect("persisted snapshot")
|
||||
.expect("snapshot exists");
|
||||
assert_eq!(current.meta, meta, "RAFT_SM_LIVE_APPLY_DOES_NOT_REPIN_SNAPSHOT");
|
||||
assert_eq!(snapshot_bytes(&mut current).await, bytes, "RAFT_SM_SNAPSHOT_FROZEN_BYTES");
|
||||
let Fixture {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
} = f;
|
||||
drop(current);
|
||||
drop(built);
|
||||
drop(sm);
|
||||
drop(logs);
|
||||
drop(writer);
|
||||
let writer = NativeLogWriter::new(Store::open(directory.path().join("db"), identity).expect("restart snapshot owner"));
|
||||
let mut sm = NativeRaftStateMachine::new(writer, CheckpointLimits::default()).expect("reopen adapter");
|
||||
let mut current = sm
|
||||
.get_current_snapshot()
|
||||
.await
|
||||
.expect("restarted current snapshot")
|
||||
.expect("durable snapshot exists");
|
||||
assert_eq!(current.meta, meta, "RAFT_SM_COMPLETE_META_RECONSTRUCTED");
|
||||
assert_eq!(snapshot_bytes(&mut current).await, bytes, "RAFT_SM_RESTART_SNAPSHOT_BYTES");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_state_machine_receive_bounds() {
|
||||
let f = Fixture::new(0);
|
||||
let mut limits = CheckpointLimits::default();
|
||||
limits.snapshot.total_bytes = 1024;
|
||||
let mut sm = NativeRaftStateMachine::new(f.writer.clone(), limits).expect("bounded state machine");
|
||||
let mut data = sm.begin_receiving_snapshot().await.expect("bounded receive");
|
||||
data.write_all(&[1; 16]).await.expect("small write");
|
||||
assert!(data.seek(SeekFrom::Start(u64::MAX)).await.is_err(), "RAFT_SM_RECEIVE_SEEK_BOUND");
|
||||
data.seek(SeekFrom::Start(0)).await.expect("restore seek");
|
||||
let mut bytes = [0; 16];
|
||||
data.read_exact(&mut bytes).await.expect("small received bytes");
|
||||
assert_eq!(bytes, [1; 16], "RAFT_SM_BAD_SEEK_PRESERVES_BYTES");
|
||||
assert!(data.write_all(&[2; 2048]).await.is_err(), "RAFT_SM_RECEIVE_WRITE_BOUND_BEFORE_INSTALL");
|
||||
assert_eq!(sm.applied_state().await.expect("receive is not install").0, None);
|
||||
assert!(sm.get_current_snapshot().await.expect("no received publication").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_state_machine_install_rejects_meta_identity() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let bytes = snapshot_bytes(&mut snapshot).await;
|
||||
let mut target = Fixture::new(1);
|
||||
target.apply(entries(1)).await;
|
||||
let old_page = target.page().await;
|
||||
let stable = std::fs::read(target.directory.path().join("db")).expect("nonempty target bytes");
|
||||
let mut wrong = snapshot.meta.clone();
|
||||
wrong.last_log_id = Some(LogId { term: 9, ..id(2) }.into());
|
||||
let data = receive(&mut target.sm, &bytes).await;
|
||||
assert!(
|
||||
target.sm.install_snapshot(&wrong, data).await.is_err(),
|
||||
"SNAPSHOT_META_BOUND_TO_FRAME: full LogId term cannot differ from snapshot header"
|
||||
);
|
||||
assert_eq!(target.sm.applied_state().await.expect("rejection state").0, Some(id(1).into()));
|
||||
assert_eq!(target.page().await, old_page, "RAFT_SM_META_REJECTION_RETAINS_EVENTS");
|
||||
assert_eq!(
|
||||
std::fs::read(target.directory.path().join("db")).expect("unchanged nonempty target"),
|
||||
stable,
|
||||
"RAFT_SM_META_REJECTION_NO_MUTATION"
|
||||
);
|
||||
assert!(target.sm.get_current_snapshot().await.expect("rejection snapshot").is_none());
|
||||
let mut wrong = snapshot.meta.clone();
|
||||
wrong.last_membership = openraft::StoredMembership::default();
|
||||
let data = receive(&mut target.sm, &bytes).await;
|
||||
assert!(
|
||||
target.sm.install_snapshot(&wrong, data).await.is_err(),
|
||||
"RAFT_SM_MEMBERSHIP_BOUND_TO_FRAME"
|
||||
);
|
||||
let mut foreign = Fixture::with_identity_binding(
|
||||
NodeIdentity {
|
||||
cluster_id: [6; 16],
|
||||
..identity(1)
|
||||
},
|
||||
binding(),
|
||||
);
|
||||
let data = receive(&mut foreign.sm, &bytes).await;
|
||||
assert!(
|
||||
foreign.sm.install_snapshot(&snapshot.meta, data).await.is_err(),
|
||||
"RAFT_SM_CLUSTER_BOUND_TO_DURABLE_OWNER"
|
||||
);
|
||||
let mut foreign = Fixture::with_identity_binding(
|
||||
identity(1),
|
||||
CaptureBinding {
|
||||
target_id: [5; 16],
|
||||
..binding()
|
||||
},
|
||||
);
|
||||
let data = receive(&mut foreign.sm, &bytes).await;
|
||||
assert!(
|
||||
foreign.sm.install_snapshot(&snapshot.meta, data).await.is_err(),
|
||||
"RAFT_SM_CAPTURE_BINDING_BOUND"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_state_machine_install_preserves_local_identity_vote() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
snapshot.meta.snapshot_id = "foreign-snapshot-id-preserved-verbatim".into();
|
||||
let mut target = Fixture::new(1);
|
||||
let vote = openraft::Vote::new_committed(10, 1);
|
||||
target.logs.save_vote(&vote).await.expect("local election vote");
|
||||
install(&mut target.sm, &mut snapshot).await;
|
||||
assert_eq!(
|
||||
target.writer.durable_identity().await.expect("local identity"),
|
||||
identity(1),
|
||||
"RAFT_SM_INSTALL_PRESERVES_LOCAL_NODE"
|
||||
);
|
||||
assert_eq!(
|
||||
target.logs.read_vote().await.expect("local vote"),
|
||||
Some(vote),
|
||||
"RAFT_SM_INSTALL_PRESERVES_LOCAL_VOTE"
|
||||
);
|
||||
let mut current = target
|
||||
.sm
|
||||
.get_current_snapshot()
|
||||
.await
|
||||
.expect("installed snapshot")
|
||||
.expect("present");
|
||||
assert_eq!(current.meta, snapshot.meta, "RAFT_SM_INSTALL_PRESERVES_COMPLETE_INCOMING_META");
|
||||
assert_eq!(snapshot_bytes(&mut current).await, snapshot_bytes(&mut snapshot).await);
|
||||
let Fixture {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
} = target;
|
||||
drop(current);
|
||||
drop(sm);
|
||||
drop(logs);
|
||||
drop(writer);
|
||||
let writer = NativeLogWriter::new(Store::open(directory.path().join("db"), identity).expect("restart installed target"));
|
||||
let mut logs = NativeRaftLogStore::new(writer.clone());
|
||||
let mut sm = NativeRaftStateMachine::new(writer.clone(), CheckpointLimits::default()).expect("reopened installed SM");
|
||||
assert_eq!(writer.durable_identity().await.expect("restarted local identity"), identity);
|
||||
assert_eq!(logs.read_vote().await.expect("restarted vote"), Some(vote));
|
||||
assert_eq!(
|
||||
sm.get_current_snapshot()
|
||||
.await
|
||||
.expect("restarted snapshot")
|
||||
.expect("present")
|
||||
.meta,
|
||||
snapshot.meta,
|
||||
"RAFT_SM_INCOMING_SNAPSHOT_ID_DURABLE"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_state_machine_install_suffix() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let mut snapshot = source.build().await;
|
||||
let mut target = Fixture::new(1);
|
||||
target
|
||||
.logs
|
||||
.blocking_append(entries(4))
|
||||
.await
|
||||
.expect("retained local log suffix");
|
||||
target.sm.apply(entries(1)).await.expect("old local applied prefix");
|
||||
target
|
||||
.logs
|
||||
.save_committed(Some(id(4).into()))
|
||||
.await
|
||||
.expect("known committed compatible suffix");
|
||||
install(&mut target.sm, &mut snapshot).await;
|
||||
assert_eq!(
|
||||
target
|
||||
.logs
|
||||
.get_log_reader()
|
||||
.await
|
||||
.try_get_log_entries(3..)
|
||||
.await
|
||||
.expect("compatible suffix"),
|
||||
vec![entry(3), entry(4)],
|
||||
"RAFT_SM_INSTALL_PRESERVES_COMPATIBLE_COMMITTED_SUFFIX"
|
||||
);
|
||||
assert_eq!(
|
||||
target.logs.read_committed().await.expect("preserved committed marker"),
|
||||
Some(id(4).into())
|
||||
);
|
||||
target
|
||||
.sm
|
||||
.apply(vec![entry(3), entry(4)])
|
||||
.await
|
||||
.expect("apply retained committed suffix");
|
||||
assert_eq!(target.page().await.events.len(), 4);
|
||||
let mut conflict = Fixture::new(2);
|
||||
let mut batch = entries(4);
|
||||
for entry in &mut batch[2..] {
|
||||
entry.log_id = LogId {
|
||||
term: 9,
|
||||
..id(entry.log_id.index)
|
||||
}
|
||||
.into();
|
||||
}
|
||||
conflict
|
||||
.logs
|
||||
.blocking_append(batch)
|
||||
.await
|
||||
.expect("conflicting uncommitted suffix");
|
||||
conflict.sm.apply(entries(1)).await.expect("protected applied prefix");
|
||||
install(&mut conflict.sm, &mut snapshot).await;
|
||||
assert!(
|
||||
conflict
|
||||
.logs
|
||||
.get_log_reader()
|
||||
.await
|
||||
.try_get_log_entries(3..)
|
||||
.await
|
||||
.expect("discarded conflict")
|
||||
.is_empty(),
|
||||
"RAFT_SM_INSTALL_DROPS_ONLY_UNCOMMITTED_CONFLICT"
|
||||
);
|
||||
conflict.apply(vec![entry(3)]).await;
|
||||
assert_eq!(conflict.sm.applied_state().await.expect("new suffix applied").0, Some(id(3).into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_state_machine_install_restart_continue() {
|
||||
let mut source = Fixture::new(0);
|
||||
source.apply(entries(2)).await;
|
||||
let expected = source.page().await;
|
||||
let mut snapshot = source.build().await;
|
||||
let mut target = Fixture::new(1);
|
||||
install(&mut target.sm, &mut snapshot).await;
|
||||
assert_eq!(target.page().await, expected, "RAFT_SM_INSTALL_REBUILDS_SOURCE_EVENTS");
|
||||
let Fixture {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
} = target;
|
||||
drop(sm);
|
||||
drop(logs);
|
||||
drop(writer);
|
||||
let writer = NativeLogWriter::new(Store::open(directory.path().join("db"), identity).expect("restart installed redb"));
|
||||
let mut logs = NativeRaftLogStore::new(writer.clone());
|
||||
let mut sm = NativeRaftStateMachine::new(writer.clone(), CheckpointLimits::default()).expect("restart installed adapter");
|
||||
assert_eq!(sm.applied_state().await.expect("snapshot applied state").0, Some(id(2).into()));
|
||||
logs.blocking_append(vec![entry(3)])
|
||||
.await
|
||||
.expect("append after install restart");
|
||||
sm.apply(vec![entry(3)]).await.expect("apply after install restart");
|
||||
let all = writer.created_events_page(None, 256).await.expect("retained plus new event");
|
||||
assert_eq!(all.events.len(), 3);
|
||||
assert_eq!(all.events[..2], expected.events);
|
||||
let next = writer
|
||||
.created_events_page(expected.next, 1)
|
||||
.await
|
||||
.expect("snapshot cursor resumes");
|
||||
assert_eq!(next.events[0].event_id, [3; 16], "RAFT_SM_INSTALL_CURSOR_CONTINUITY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_state_machine_empty_snapshot_restart_continue() {
|
||||
let mut source = Fixture::new(0);
|
||||
let mut target = Fixture::new(42);
|
||||
let mut snapshot = source.sm.build_snapshot().await.expect("durable empty baseline");
|
||||
assert_eq!(snapshot.meta.last_log_id, None);
|
||||
let empty_meta = snapshot.meta.clone();
|
||||
let received = receive(&mut target.sm, &snapshot_bytes(&mut snapshot).await).await;
|
||||
target
|
||||
.sm
|
||||
.install_snapshot(&empty_meta, received)
|
||||
.await
|
||||
.expect("install empty frame");
|
||||
let mut target = target.reopen();
|
||||
let current = target
|
||||
.sm
|
||||
.get_current_snapshot()
|
||||
.await
|
||||
.expect("empty after reopen")
|
||||
.expect("durable selection");
|
||||
assert_eq!(current.meta, empty_meta);
|
||||
target
|
||||
.logs
|
||||
.blocking_append(entries(1))
|
||||
.await
|
||||
.expect("real append after empty install");
|
||||
target.sm.apply(entries(1)).await.expect("real apply after empty install");
|
||||
let mut target = target.reopen();
|
||||
assert_eq!(
|
||||
target.sm.applied_state().await.expect("replay empty baseline + suffix").0,
|
||||
Some(id(1).into())
|
||||
);
|
||||
assert_eq!(
|
||||
target
|
||||
.writer
|
||||
.created_events_page(None, 16)
|
||||
.await
|
||||
.expect("event after empty baseline")
|
||||
.events
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let refreshed = target
|
||||
.sm
|
||||
.build_snapshot()
|
||||
.await
|
||||
.expect("nonempty checkpoint supersedes empty");
|
||||
assert_eq!(refreshed.meta.last_log_id, Some(id(1).into()));
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn reopen(self) -> Self {
|
||||
let Self {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
} = self;
|
||||
drop(sm);
|
||||
drop(logs);
|
||||
drop(writer);
|
||||
let writer =
|
||||
NativeLogWriter::new(Store::open(directory.path().join("db"), identity).expect("real empty baseline restart"));
|
||||
let logs = NativeRaftLogStore::new(writer.clone());
|
||||
let sm =
|
||||
NativeRaftStateMachine::new(writer.clone(), CheckpointLimits::default()).expect("reopened empty baseline adapter");
|
||||
Self {
|
||||
directory,
|
||||
writer,
|
||||
logs,
|
||||
sm,
|
||||
identity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raft_state_machine_build_competes_with_initial_apply() {
|
||||
let mut target = Fixture::new(0);
|
||||
target
|
||||
.logs
|
||||
.blocking_append(entries(1))
|
||||
.await
|
||||
.expect("real pending initial batch");
|
||||
let entered = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
let resume = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
*target.sm.build_before_ordering.lock().expect("build pause") = Some((entered.clone(), resume.clone()));
|
||||
let mut builder = target.sm.get_snapshot_builder().await;
|
||||
let task = tokio::spawn(async move { builder.build_snapshot().await });
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
|
||||
.await
|
||||
.expect("builder began before initial apply");
|
||||
target
|
||||
.sm
|
||||
.apply(entries(1))
|
||||
.await
|
||||
.expect("actual initial apply while builder pending");
|
||||
resume.notify_one();
|
||||
let snapshot = tokio::time::timeout(std::time::Duration::from_secs(5), task)
|
||||
.await
|
||||
.expect("build continues")
|
||||
.expect("builder task")
|
||||
.expect("initial apply does not make snapshot build a fatal storage error");
|
||||
assert_eq!(
|
||||
snapshot.meta.last_log_id,
|
||||
Some(id(1).into()),
|
||||
"SNAPSHOT_BUILD_INITIAL_APPLY_RACE_USES_NONEMPTY_CHECKPOINT"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,559 +0,0 @@
|
||||
//! Disposable event ordering index. EVENTS remains the sole event authority.
|
||||
use crate::{
|
||||
CaptureBinding, CreatedEvent, CreatedResult, LogId, NativeLogWriter, NativeWriteError, Store, StoreError, application,
|
||||
decode_id, encode_id,
|
||||
};
|
||||
use redb::{ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition, WriteTransaction};
|
||||
|
||||
pub const MAX_EVENT_PAGE: usize = 256;
|
||||
pub(crate) const EVENT_SEQUENCE: TableDefinition<u64, &[u8]> = TableDefinition::new("event_sequence_cache_v1");
|
||||
|
||||
/// Persist only after every event preceding this cursor has a durable consumer ACK.
|
||||
/// Deserialization does not grant authority: each use checks the stored event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EventCursor {
|
||||
cluster: [u8; 16],
|
||||
term: u64,
|
||||
leader: u64,
|
||||
index: u64,
|
||||
event: [u8; 16],
|
||||
binding_id: [u8; 16],
|
||||
target_id: [u8; 16],
|
||||
revision: u64,
|
||||
}
|
||||
impl EventCursor {
|
||||
pub(crate) fn new(cluster: [u8; 16], event: [u8; 16], value: &CreatedEvent) -> Self {
|
||||
Self {
|
||||
cluster,
|
||||
term: value.decision_log_id.term,
|
||||
leader: value.decision_log_id.leader_node,
|
||||
index: value.decision_log_id.index,
|
||||
event,
|
||||
binding_id: value.binding.binding_id,
|
||||
target_id: value.binding.target_id,
|
||||
revision: value.binding.revision,
|
||||
}
|
||||
}
|
||||
fn matches(&self, cluster: [u8; 16], event: [u8; 16], value: &CreatedEvent) -> bool {
|
||||
self == &Self::new(cluster, event, value)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SourceEvent {
|
||||
pub event_id: [u8; 16],
|
||||
pub captured: CreatedEvent,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EventPage {
|
||||
pub events: Vec<SourceEvent>,
|
||||
pub next: Option<EventCursor>,
|
||||
}
|
||||
fn encoded_index(id: LogId, event: [u8; 16]) -> Vec<u8> {
|
||||
let mut bytes = encode_id(id).to_vec();
|
||||
bytes.extend(event);
|
||||
bytes
|
||||
}
|
||||
fn decode_index(bytes: &[u8]) -> Result<(LogId, [u8; 16]), StoreError> {
|
||||
if bytes.len() != 40 {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let id = decode_id(&bytes[..24])?;
|
||||
let event: [u8; 16] = bytes[24..].try_into().map_err(|_| StoreError::CorruptRecord)?;
|
||||
if event == [0; 16] {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok((id, event))
|
||||
}
|
||||
pub(crate) fn insert_event_index(tx: &WriteTransaction, id: LogId, event: [u8; 16]) -> Result<(), StoreError> {
|
||||
let bytes = encoded_index(id, event);
|
||||
let mut index = tx.open_table(EVENT_SEQUENCE)?;
|
||||
if index.get(id.index)?.is_some() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
index.insert(id.index, bytes.as_slice())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check the event semantics required to derive ordering from checkpoint authority.
|
||||
/// This deliberately does not grant snapshot installation or source authority.
|
||||
fn validated_event(
|
||||
key: &[u8],
|
||||
bytes: &[u8],
|
||||
operations: &impl ReadableTable<&'static [u8], &'static [u8]>,
|
||||
versions: &impl ReadableTable<&'static [u8], &'static [u8]>,
|
||||
schema: u64,
|
||||
binding: &CaptureBinding,
|
||||
applied: Option<LogId>,
|
||||
) -> Result<([u8; 16], CreatedEvent), StoreError> {
|
||||
let event_id: [u8; 16] = key.try_into().map_err(|_| StoreError::CorruptRecord)?;
|
||||
let event = application::decode_event(bytes)?;
|
||||
let cut = applied.ok_or(StoreError::CorruptRecord)?;
|
||||
let operation = operations.get(key)?.ok_or(StoreError::CorruptRecord)?;
|
||||
let first_decision = if matches!(schema, 5 | 6 | 7) {
|
||||
Some(application::decode_provenance(operation.value())?.first_decision_log_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let operation = application::decode_stored_operation(operation.value(), schema)?;
|
||||
let command = crate::DecideCreated::decode(&operation.canonical_command)?;
|
||||
let version_key = application::version_key(&event.object, event.prepared.version_id)?;
|
||||
let version = versions.get(version_key.as_slice())?.ok_or(StoreError::CorruptRecord)?;
|
||||
let version = application::decode_version(version.value())?;
|
||||
if first_decision.is_some_and(|first| first != event.decision_log_id)
|
||||
|| event_id == [0; 16]
|
||||
|| event.decision_log_id.index > cut.index
|
||||
|| (event.decision_log_id.index == cut.index && event.decision_log_id != cut)
|
||||
|| event.binding != *binding
|
||||
|| command.operation_id != event_id
|
||||
|| command.object != event.object
|
||||
|| command.prepared != event.prepared
|
||||
|| command.binding_revision != event.binding.revision
|
||||
|| operation.result
|
||||
!= (CreatedResult::Created {
|
||||
version_id: event.prepared.version_id,
|
||||
event_id,
|
||||
decision_log_id: event.decision_log_id,
|
||||
})
|
||||
|| version
|
||||
!= (crate::CreatedVersion {
|
||||
prepared: event.prepared.clone(),
|
||||
decision_log_id: event.decision_log_id,
|
||||
binding_revision: event.binding.revision,
|
||||
})
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok((event_id, event))
|
||||
}
|
||||
|
||||
/// Rebuild from the same authority rows emitted by the existing four-section checkpoint.
|
||||
/// Callers must independently establish authority and validate all other state before promotion.
|
||||
pub(crate) fn rebuild_from_authority(
|
||||
tx: &WriteTransaction,
|
||||
schema: u64,
|
||||
binding: &CaptureBinding,
|
||||
applied: Option<LogId>,
|
||||
) -> Result<(), StoreError> {
|
||||
let events = tx.open_table(application::EVENTS)?;
|
||||
let operations = tx.open_table(application::OPERATIONS)?;
|
||||
let versions = tx.open_table(application::VERSIONS)?;
|
||||
{
|
||||
let mut index = tx.open_table(EVENT_SEQUENCE)?;
|
||||
index.retain(|_, _| false)?;
|
||||
}
|
||||
for row in events.iter()? {
|
||||
let (key, bytes) = row?;
|
||||
let (event, value) = validated_event(key.value(), bytes.value(), &operations, &versions, schema, binding, applied)?;
|
||||
insert_event_index(tx, value.decision_log_id, event)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
impl Store {
|
||||
pub(crate) fn ensure_event_index(&mut self) -> Result<(), StoreError> {
|
||||
let read = self.database.begin_read()?;
|
||||
let meta = read.open_table(crate::META)?;
|
||||
let schema = crate::number(meta.get("schema")?.ok_or(StoreError::CorruptRecord)?.value())?;
|
||||
if !matches!(schema, 3..=7) {
|
||||
return Ok(());
|
||||
}
|
||||
let binding = application::read_binding(&meta)?;
|
||||
let applied = application::read_applied(&meta)?;
|
||||
let events = read.open_table(application::EVENTS)?;
|
||||
let complete = match read.open_table(EVENT_SEQUENCE) {
|
||||
Ok(index) => {
|
||||
let mut complete = index.len()? == events.len()?;
|
||||
if complete {
|
||||
for row in events.iter()? {
|
||||
let (key, bytes) = row?;
|
||||
let value = application::decode_event(bytes.value())?;
|
||||
let expected =
|
||||
encoded_index(value.decision_log_id, key.value().try_into().map_err(|_| StoreError::CorruptRecord)?);
|
||||
if index
|
||||
.get(value.decision_log_id.index)?
|
||||
.is_none_or(|stored| stored.value() != expected.as_slice())
|
||||
{
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
complete
|
||||
}
|
||||
Err(redb::TableError::TableDoesNotExist(_)) => false,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
drop(events);
|
||||
drop(meta);
|
||||
drop(read);
|
||||
if !complete {
|
||||
let tx = self.write_transaction()?;
|
||||
rebuild_from_authority(&tx, schema, &binding, applied)?;
|
||||
self.commit(tx)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Read a bounded page without changing any delivery ACK or cursor state.
|
||||
pub fn created_events_page(&self, cursor: Option<&EventCursor>, limit: usize) -> Result<EventPage, StoreError> {
|
||||
if !self.writable {
|
||||
return Err(StoreError::WriteDisabled);
|
||||
}
|
||||
if limit == 0 || limit > MAX_EVENT_PAGE {
|
||||
return Err(StoreError::InvalidEventPage);
|
||||
}
|
||||
let tx = self.database.begin_read()?;
|
||||
let meta = tx.open_table(crate::META)?;
|
||||
let schema = crate::number(meta.get("schema")?.ok_or(StoreError::CorruptRecord)?.value())?;
|
||||
if !matches!(schema, 3..=7) {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
let identity = crate::decode_identity(meta.get("identity")?.ok_or(StoreError::CorruptRecord)?.value())?;
|
||||
let events = tx.open_table(application::EVENTS)?;
|
||||
let index = tx.open_table(EVENT_SEQUENCE)?;
|
||||
if index.len()? != events.len()? {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let read_event = |id: u64, bytes: &[u8]| -> Result<SourceEvent, StoreError> {
|
||||
let (log, event_id) = decode_index(bytes)?;
|
||||
let value = events.get(event_id.as_slice())?.ok_or(StoreError::CorruptRecord)?;
|
||||
let captured = application::decode_event(value.value())?;
|
||||
if log.index != id || captured.decision_log_id != log {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(SourceEvent { event_id, captured })
|
||||
};
|
||||
if let Some(cursor) = cursor {
|
||||
let stored = index.get(cursor.index)?.ok_or(StoreError::IdentityMismatch)?;
|
||||
let event = read_event(cursor.index, stored.value())?;
|
||||
if !cursor.matches(identity.cluster_id, event.event_id, &event.captured) {
|
||||
return Err(StoreError::IdentityMismatch);
|
||||
}
|
||||
}
|
||||
let start = cursor.map_or(0, |c| c.index);
|
||||
let range = if cursor.is_some() {
|
||||
(std::ops::Bound::Excluded(start), std::ops::Bound::Unbounded)
|
||||
} else {
|
||||
(std::ops::Bound::Included(start), std::ops::Bound::Unbounded)
|
||||
};
|
||||
let mut page = EventPage {
|
||||
events: Vec::with_capacity(limit),
|
||||
next: cursor.cloned(),
|
||||
};
|
||||
for row in index.range(range)?.take(limit) {
|
||||
let (id, bytes) = row?;
|
||||
let event = read_event(id.value(), bytes.value())?;
|
||||
page.next = Some(EventCursor::new(identity.cluster_id, event.event_id, &event.captured));
|
||||
page.events.push(event);
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
}
|
||||
impl NativeLogWriter {
|
||||
pub async fn created_events_page(&self, cursor: Option<EventCursor>, limit: usize) -> Result<EventPage, NativeWriteError> {
|
||||
let owner = self.store.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
owner
|
||||
.lock()
|
||||
.map_err(|_| NativeWriteError::Poisoned)?
|
||||
.created_events_page(cursor.as_ref(), limit)
|
||||
.map_err(NativeWriteError::Store)
|
||||
})
|
||||
.await
|
||||
.map_err(NativeWriteError::Worker)?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{CaptureCommand, CaptureRaftEntry, DecideCreated, NodeIdentity, ObjectIdentity, PreparedIdentity, snapshot};
|
||||
use std::io;
|
||||
fn identity() -> NodeIdentity {
|
||||
NodeIdentity {
|
||||
cluster_id: [7; 16],
|
||||
node_id: 0,
|
||||
}
|
||||
}
|
||||
fn binding() -> CaptureBinding {
|
||||
CaptureBinding {
|
||||
revision: 9,
|
||||
binding_id: [8; 16],
|
||||
target_id: [9; 16],
|
||||
}
|
||||
}
|
||||
fn id(index: u64) -> LogId {
|
||||
LogId {
|
||||
term: 8,
|
||||
leader_node: 0,
|
||||
index,
|
||||
}
|
||||
}
|
||||
fn command(event: u8) -> DecideCreated {
|
||||
DecideCreated {
|
||||
operation_id: [event; 16],
|
||||
object: ObjectIdentity {
|
||||
bucket_incarnation: [2; 16],
|
||||
key: vec![event],
|
||||
},
|
||||
expected_head: None,
|
||||
prepared: PreparedIdentity {
|
||||
version_id: [event; 16],
|
||||
preparation_id: [4; 16],
|
||||
content_digest: [5; 32],
|
||||
content_length: 123,
|
||||
},
|
||||
binding_revision: 9,
|
||||
}
|
||||
}
|
||||
fn apply(store: &mut Store, index: u64, command: DecideCreated) -> CreatedResult {
|
||||
store
|
||||
.append_native(&[CaptureRaftEntry {
|
||||
log_id: id(index).into(),
|
||||
payload: openraft::EntryPayload::Normal(CaptureCommand::try_created(command).expect("command")),
|
||||
}])
|
||||
.expect("append native");
|
||||
store.save_committed(id(index)).expect("commit native");
|
||||
store
|
||||
.apply_next_native(id(index))
|
||||
.expect("apply native")
|
||||
.created_result()
|
||||
.expect("created response")
|
||||
.clone()
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, Store) {
|
||||
let dir = tempfile::tempdir().expect("directory");
|
||||
let mut store = Store::initialize(dir.path().join("source.redb"), identity()).expect("initialize");
|
||||
store.pin_capture_binding(binding()).expect("pin");
|
||||
store.enable_native_empty().expect("native");
|
||||
apply(&mut store, 0, command(9));
|
||||
apply(&mut store, 1, command(7));
|
||||
(dir, store)
|
||||
}
|
||||
#[test]
|
||||
fn committed_events_page_in_decision_order() {
|
||||
let (_dir, store) = fixture();
|
||||
let first = store.created_events_page(None, 1).expect("first");
|
||||
assert_eq!(
|
||||
first.events.iter().map(|e| e.event_id).collect::<Vec<_>>(),
|
||||
vec![[9; 16]],
|
||||
"SOURCE_PAGE_ORDER_BOUND"
|
||||
);
|
||||
let next = store.created_events_page(first.next.as_ref(), 1).expect("second");
|
||||
assert_eq!(next.events[0].event_id, [7; 16]);
|
||||
assert_eq!(next.events[0].captured.binding, binding());
|
||||
assert!(
|
||||
store
|
||||
.created_events_page(next.next.as_ref(), 1)
|
||||
.expect("end")
|
||||
.events
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn replay_and_rejections_do_not_duplicate_events() {
|
||||
let (_dir, mut store) = fixture();
|
||||
apply(&mut store, 2, command(9));
|
||||
let mut rejected = command(3);
|
||||
rejected.object = command(9).object;
|
||||
assert!(matches!(apply(&mut store, 3, rejected), CreatedResult::HeadMismatch { .. }));
|
||||
let page = store.created_events_page(None, 256).expect("page");
|
||||
assert_eq!(page.events.len(), 2, "SOURCE_PAGE_REPLAY_BOUND");
|
||||
}
|
||||
#[test]
|
||||
fn reopen_rebuilds_missing_or_stale_index() {
|
||||
let (dir, mut store) = fixture();
|
||||
apply(&mut store, 2, command(1));
|
||||
let expected = store.created_events_page(None, 256).expect("before");
|
||||
let tx = store.write_transaction().expect("remove cache");
|
||||
tx.delete_table(EVENT_SEQUENCE).expect("delete cache");
|
||||
store.commit(tx).expect("cache deletion");
|
||||
drop(store);
|
||||
let mut store = Store::open(dir.path().join("source.redb"), identity()).expect("rebuild missing");
|
||||
assert_eq!(store.created_events_page(None, 256).expect("rebuilt"), expected);
|
||||
let tx = store.write_transaction().expect("stale cache");
|
||||
{
|
||||
let mut index = tx.open_table(EVENT_SEQUENCE).expect("cache");
|
||||
index.remove(1).expect("omit middle item");
|
||||
index.insert(200, b"bad".as_slice()).expect("same-count stale item");
|
||||
}
|
||||
store.commit(tx).expect("cache damage");
|
||||
drop(store);
|
||||
let store = Store::open(dir.path().join("source.redb"), identity()).expect("rebuild stale");
|
||||
assert_eq!(store.created_events_page(None, 256).expect("rebuilt stale"), expected);
|
||||
}
|
||||
#[test]
|
||||
fn cursor_binds_cluster_term_leader_and_event() {
|
||||
let (_dir, store) = fixture();
|
||||
let cursor = store.created_events_page(None, 1).expect("page").next.expect("cursor");
|
||||
let mut bad = Vec::new();
|
||||
let mut c = cursor.clone();
|
||||
c.cluster = [6; 16];
|
||||
bad.push(c);
|
||||
let mut c = cursor.clone();
|
||||
c.term += 1;
|
||||
bad.push(c);
|
||||
let mut c = cursor.clone();
|
||||
c.leader += 1;
|
||||
bad.push(c);
|
||||
let mut c = cursor.clone();
|
||||
c.event = [7; 16];
|
||||
bad.push(c);
|
||||
let mut c = cursor.clone();
|
||||
c.revision += 1;
|
||||
bad.push(c);
|
||||
let mut c = cursor.clone();
|
||||
c.binding_id = [6; 16];
|
||||
bad.push(c);
|
||||
let mut c = cursor.clone();
|
||||
c.target_id = [6; 16];
|
||||
bad.push(c);
|
||||
for cursor in bad {
|
||||
assert_eq!(
|
||||
store.created_events_page(Some(&cursor), 1).err().map(|e| e.code()),
|
||||
Some("identity_mismatch"),
|
||||
"SOURCE_PAGE_CURSOR_BOUND"
|
||||
);
|
||||
}
|
||||
let encoded = serde_json::to_vec(&cursor).expect("cursor bytes");
|
||||
let decoded: EventCursor = serde_json::from_slice(&encoded).expect("cursor decode");
|
||||
assert_eq!(store.created_events_page(Some(&decoded), 1).expect("resume").events[0].event_id, [7; 16]);
|
||||
}
|
||||
#[test]
|
||||
fn page_is_bounded_and_new_lower_uuid_is_not_lost() {
|
||||
let (_dir, mut store) = fixture();
|
||||
let page = store.created_events_page(None, 1).expect("one");
|
||||
assert_eq!(page.events.len(), 1);
|
||||
apply(&mut store, 2, command(1));
|
||||
let next = store.created_events_page(page.next.as_ref(), 256).expect("later");
|
||||
assert_eq!(next.events.iter().map(|e| e.event_id).collect::<Vec<_>>(), vec![[7; 16], [1; 16]]);
|
||||
for limit in [0, MAX_EVENT_PAGE + 1, usize::MAX] {
|
||||
assert_eq!(store.created_events_page(None, limit).err().map(|e| e.code()), Some("invalid_event_page"));
|
||||
}
|
||||
store.writable = false;
|
||||
assert_eq!(store.created_events_page(None, 1).err().map(|e| e.code()), Some("write_disabled"));
|
||||
store.writable = true;
|
||||
let mut max = page.next.expect("cursor");
|
||||
max.index = u64::MAX;
|
||||
assert_eq!(
|
||||
store.created_events_page(Some(&max), 1).err().map(|e| e.code()),
|
||||
Some("identity_mismatch")
|
||||
);
|
||||
}
|
||||
struct Staging<'a>(&'a WriteTransaction);
|
||||
impl snapshot::StagingSink for Staging<'_> {
|
||||
fn row(&mut self, section: snapshot::Section, key: &[u8], value: &[u8]) -> io::Result<()> {
|
||||
let table = match section {
|
||||
snapshot::Section::Heads => application::HEADS,
|
||||
snapshot::Section::Versions => application::VERSIONS,
|
||||
snapshot::Section::Events => application::EVENTS,
|
||||
snapshot::Section::Operations => application::OPERATIONS,
|
||||
};
|
||||
self.0
|
||||
.open_table(table)
|
||||
.map_err(io::Error::other)?
|
||||
.insert(key, value)
|
||||
.map_err(io::Error::other)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn limits() -> snapshot::Limits {
|
||||
snapshot::Limits {
|
||||
total_bytes: 1_048_576,
|
||||
membership_bytes: 4096,
|
||||
key_bytes: 4096,
|
||||
value_bytes: 8192,
|
||||
records: 1024,
|
||||
}
|
||||
}
|
||||
async fn artifact(store: Store) -> (snapshot::CapabilityArtifact<Vec<u8>>, EventPage) {
|
||||
let writer = NativeLogWriter::new(store);
|
||||
let before = writer.created_events_page(None, 256).await.expect("async page");
|
||||
let artifact = writer
|
||||
.snapshot_view()
|
||||
.await
|
||||
.expect("snapshot view")
|
||||
.stream_capability_artifact(Vec::new(), limits())
|
||||
.expect("capability export");
|
||||
(artifact, before)
|
||||
}
|
||||
fn stage(artifact: snapshot::CapabilityArtifact<Vec<u8>>, damage: bool) -> Result<(tempfile::TempDir, Store), StoreError> {
|
||||
let dir = tempfile::tempdir().expect("stage directory");
|
||||
let database = redb::Database::create(dir.path().join("stage.redb")).expect("isolated database");
|
||||
let tx = database.begin_write()?;
|
||||
let header = snapshot::decode_capability_artifact(
|
||||
snapshot::CapabilityArtifact {
|
||||
manifest: artifact.manifest,
|
||||
payload: std::io::Cursor::new(artifact.payload),
|
||||
},
|
||||
&mut Staging(&tx),
|
||||
limits(),
|
||||
)
|
||||
.expect("real capability decoder");
|
||||
if damage {
|
||||
let mut events = tx.open_table(application::EVENTS)?;
|
||||
let value = events.get([9; 16].as_slice())?.expect("event").value().to_vec();
|
||||
events.insert([6; 16].as_slice(), value.as_slice())?;
|
||||
events.remove([9; 16].as_slice())?;
|
||||
}
|
||||
rebuild_from_authority(&tx, 6, &header.binding, header.applied)?;
|
||||
{
|
||||
let mut meta = tx.open_table(crate::META)?;
|
||||
meta.insert("schema", 6u64.to_be_bytes().as_slice())?;
|
||||
meta.insert(
|
||||
"identity",
|
||||
crate::encode_identity(NodeIdentity {
|
||||
cluster_id: header.cluster_id,
|
||||
..identity()
|
||||
})
|
||||
.as_slice(),
|
||||
)?;
|
||||
meta.insert("capture_binding", crate::created::binding_bytes(&header.binding)?.as_slice())?;
|
||||
let mut applied = vec![1];
|
||||
applied.extend(encode_id(header.applied.expect("cut")));
|
||||
meta.insert("last_applied", applied.as_slice())?;
|
||||
}
|
||||
tx.commit().expect("stage commit");
|
||||
// A test-only staging handle, not a production installer or authority grant.
|
||||
Ok((
|
||||
dir,
|
||||
Store {
|
||||
database,
|
||||
writable: true,
|
||||
},
|
||||
))
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn snapshot_authority_rebuild_matches_source() {
|
||||
let (_dir, mut store) = fixture();
|
||||
apply(&mut store, 2, command(9));
|
||||
let mut rejected = command(3);
|
||||
rejected.object = command(9).object;
|
||||
apply(&mut store, 3, rejected);
|
||||
store.promote_created_provenance().expect("schema6");
|
||||
let (artifact, before) = artifact(store).await;
|
||||
let (_stage, restored) = stage(artifact, false).expect("staged authority index");
|
||||
assert_eq!(
|
||||
restored.created_events_page(None, 256).expect("restored page"),
|
||||
before,
|
||||
"SOURCE_PAGE_SNAPSHOT_BOUND"
|
||||
);
|
||||
let first = restored.created_events_page(None, 1).expect("first");
|
||||
assert_eq!(
|
||||
restored.created_events_page(first.next.as_ref(), 1).expect("resume").events[0],
|
||||
before.events[1]
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn snapshot_event_operation_mismatch_rejected() {
|
||||
let (_dir, mut store) = fixture();
|
||||
store.promote_created_provenance().expect("schema6");
|
||||
let (artifact, _) = artifact(store).await;
|
||||
assert_eq!(
|
||||
stage(artifact, true).err().map(|e| e.code()),
|
||||
Some("corrupt_record"),
|
||||
"SOURCE_PAGE_SNAPSHOT_BOUND"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
//! Same-UID local reads from an owner's live native store. No ACK or cursor writes.
|
||||
use crate::{EventCursor, EventPage, NativeLogWriter, NativeWriteError, NodeIdentity, StoreError};
|
||||
use redb::ReadableDatabase;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs, io,
|
||||
os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{UnixListener, UnixStream},
|
||||
sync::{OwnedSemaphorePermit, watch},
|
||||
task::JoinSet,
|
||||
};
|
||||
|
||||
pub const WIRE_VERSION: u16 = 1;
|
||||
pub const MAX_REQUEST_BYTES: usize = 4096;
|
||||
pub const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
|
||||
pub const MAX_CONNECTIONS: usize = 4;
|
||||
pub const IO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReadPage {
|
||||
pub version: u16,
|
||||
pub expected_cluster: [u8; 16],
|
||||
pub cursor: Option<EventCursor>,
|
||||
pub limit: usize,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReadPageResponse {
|
||||
pub version: u16,
|
||||
pub identity: NodeIdentity,
|
||||
pub page: EventPage,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Rejection {
|
||||
InvalidRequest,
|
||||
WrongCluster,
|
||||
InvalidCursor,
|
||||
StoreUnavailable,
|
||||
ResponseLimit,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", deny_unknown_fields)]
|
||||
pub enum ReadPageReply {
|
||||
Page { response: ReadPageResponse },
|
||||
Rejected { version: u16, error: Rejection },
|
||||
}
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ReaderError {
|
||||
#[error("SOURCE_READER_IO")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("SOURCE_READER_PERMISSION")]
|
||||
Permission,
|
||||
#[error("SOURCE_READER_PATH_OCCUPIED")]
|
||||
Occupied,
|
||||
#[error("SOURCE_READER_PATH_CHANGED")]
|
||||
PathChanged,
|
||||
#[error("SOURCE_READER_TIMEOUT")]
|
||||
Timeout,
|
||||
#[error("SOURCE_READER_PROTOCOL")]
|
||||
Protocol,
|
||||
#[error("SOURCE_READER_REJECTED: {0:?}")]
|
||||
Rejected(Rejection),
|
||||
#[error("SOURCE_READER_WORKER")]
|
||||
Worker(#[source] tokio::task::JoinError),
|
||||
}
|
||||
|
||||
impl crate::Store {
|
||||
fn durable_source_identity(&self) -> Result<NodeIdentity, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
let meta = tx.open_table(crate::META)?;
|
||||
let identity = meta.get("identity")?.ok_or(StoreError::CorruptRecord)?;
|
||||
crate::decode_identity(identity.value())
|
||||
}
|
||||
}
|
||||
impl NativeLogWriter {
|
||||
/// Read the validated durable identity, never an owner-supplied cluster label.
|
||||
pub async fn durable_identity(&self) -> Result<NodeIdentity, NativeWriteError> {
|
||||
let owner = self.store.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
owner
|
||||
.lock()
|
||||
.map_err(|_| NativeWriteError::Poisoned)?
|
||||
.durable_source_identity()
|
||||
.map_err(NativeWriteError::Store)
|
||||
})
|
||||
.await
|
||||
.map_err(NativeWriteError::Worker)?
|
||||
}
|
||||
}
|
||||
|
||||
fn uid() -> u32 {
|
||||
rustix::process::geteuid().as_raw()
|
||||
}
|
||||
fn fingerprint(meta: &fs::Metadata) -> (u64, u64) {
|
||||
(meta.dev(), meta.ino())
|
||||
}
|
||||
fn private_parent(path: &Path) -> Result<(PathBuf, (u64, u64)), ReaderError> {
|
||||
if !path.is_absolute() || path.file_name().is_none() {
|
||||
return Err(ReaderError::Permission);
|
||||
}
|
||||
let parent = path.parent().ok_or(ReaderError::Permission)?;
|
||||
let meta = fs::symlink_metadata(parent)?;
|
||||
if !meta.is_dir() || meta.uid() != uid() || meta.mode() & 0o077 != 0 {
|
||||
return Err(ReaderError::Permission);
|
||||
}
|
||||
let resolved = fs::canonicalize(parent)?;
|
||||
let resolved_meta = fs::symlink_metadata(&resolved)?;
|
||||
if fingerprint(&meta) != fingerprint(&resolved_meta) {
|
||||
return Err(ReaderError::PathChanged);
|
||||
}
|
||||
Ok((resolved.join(path.file_name().ok_or(ReaderError::Permission)?), fingerprint(&meta)))
|
||||
}
|
||||
fn owned_socket(path: &Path) -> Result<fs::Metadata, ReaderError> {
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if !meta.file_type().is_socket() || meta.uid() != uid() {
|
||||
return Err(ReaderError::Permission);
|
||||
}
|
||||
Ok(meta)
|
||||
}
|
||||
fn socket_metadata(path: &Path) -> Result<(u64, u64), ReaderError> {
|
||||
let meta = owned_socket(path)?;
|
||||
if meta.mode() & 0o077 != 0 {
|
||||
return Err(ReaderError::Permission);
|
||||
}
|
||||
Ok(fingerprint(&meta))
|
||||
}
|
||||
fn peer(stream: &UnixStream) -> Result<(), ReaderError> {
|
||||
if stream.peer_cred()?.uid() != uid() {
|
||||
return Err(ReaderError::Permission);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
struct SocketPath {
|
||||
path: PathBuf,
|
||||
inode: Option<(u64, u64)>,
|
||||
parent: (u64, u64),
|
||||
}
|
||||
impl SocketPath {
|
||||
fn remove(&mut self) -> Result<(), ReaderError> {
|
||||
// One cleanup attempt only: never let Drop re-evaluate a later namespace.
|
||||
let Some(inode) = self.inode.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let (_, parent) = private_parent(&self.path)?;
|
||||
if parent != self.parent || fingerprint(&owned_socket(&self.path)?) != inode {
|
||||
return Err(ReaderError::PathChanged);
|
||||
}
|
||||
fs::remove_file(&self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for SocketPath {
|
||||
fn drop(&mut self) {
|
||||
// Explicit shutdown reports cleanup failure; cancellation preserves detected replacements.
|
||||
let _ = self.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal shutdown, then await the owner's `serve` task to drain workers and clean up.
|
||||
#[derive(Clone)]
|
||||
pub struct Shutdown(watch::Sender<bool>);
|
||||
impl Shutdown {
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.0.send(true);
|
||||
}
|
||||
}
|
||||
pub struct SourceReaderServer {
|
||||
listener: UnixListener,
|
||||
writer: NativeLogWriter,
|
||||
socket: SocketPath,
|
||||
shutdown: watch::Receiver<bool>,
|
||||
}
|
||||
impl SourceReaderServer {
|
||||
pub fn bind(path: impl AsRef<Path>, writer: NativeLogWriter) -> Result<(Self, Shutdown), ReaderError> {
|
||||
let (path, parent) = private_parent(path.as_ref())?;
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(_) => return Err(ReaderError::Occupied),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
// The private parent prevents a foreign UID from observing the initial socket mode.
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
let inode = fingerprint(&fs::symlink_metadata(&path)?);
|
||||
let socket = SocketPath {
|
||||
path,
|
||||
inode: Some(inode),
|
||||
parent,
|
||||
};
|
||||
fs::set_permissions(&socket.path, fs::Permissions::from_mode(0o600))?;
|
||||
if private_parent(&socket.path)?.1 != parent || socket_metadata(&socket.path)? != inode {
|
||||
return Err(ReaderError::PathChanged);
|
||||
}
|
||||
let (sender, shutdown) = watch::channel(false);
|
||||
Ok((
|
||||
Self {
|
||||
listener,
|
||||
writer,
|
||||
socket,
|
||||
shutdown,
|
||||
},
|
||||
Shutdown(sender),
|
||||
))
|
||||
}
|
||||
pub async fn serve(mut self) -> Result<(), ReaderError> {
|
||||
let mut tasks = JoinSet::new();
|
||||
let outcome = async {
|
||||
loop {
|
||||
if *self.shutdown.borrow() {
|
||||
break;
|
||||
}
|
||||
// Admission is shared by every clone of this writer, including re-bound services.
|
||||
let admission = self.writer.source_reader_admission.clone();
|
||||
let permit = tokio::select! {
|
||||
result = admission.acquire_owned() => result.map_err(|_| ReaderError::Protocol)?,
|
||||
_ = self.shutdown.changed() => break,
|
||||
};
|
||||
let stream = tokio::select! {
|
||||
result = self.listener.accept() => result?.0,
|
||||
_ = self.shutdown.changed() => break,
|
||||
};
|
||||
if peer(&stream).is_err() {
|
||||
continue;
|
||||
}
|
||||
let writer = self.writer.clone();
|
||||
tasks.spawn(async move {
|
||||
let _ = connection(stream, writer, permit).await;
|
||||
});
|
||||
while let Some(result) = tasks.try_join_next() {
|
||||
result.map_err(ReaderError::Worker)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
// Ordinary shutdown drains connections and blocking workers, never aborts storage work.
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
result.map_err(ReaderError::Worker)?;
|
||||
}
|
||||
drop(self.listener);
|
||||
self.socket.remove()?;
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
fn reject(error: Rejection) -> ReadPageReply {
|
||||
ReadPageReply::Rejected {
|
||||
version: WIRE_VERSION,
|
||||
error,
|
||||
}
|
||||
}
|
||||
fn read_page(writer: &NativeLogWriter, request: ReadPage) -> ReadPageReply {
|
||||
if request.version != WIRE_VERSION
|
||||
|| request.expected_cluster == [0; 16]
|
||||
|| request.limit == 0
|
||||
|| request.limit > crate::MAX_EVENT_PAGE
|
||||
{
|
||||
return reject(Rejection::InvalidRequest);
|
||||
}
|
||||
// Lock order: connection admission, then Store. Native reads take no writer-ordering lock.
|
||||
let read = || -> Result<ReadPageReply, NativeWriteError> {
|
||||
let store = writer.store.lock().map_err(|_| NativeWriteError::Poisoned)?;
|
||||
let identity = store.durable_source_identity()?;
|
||||
if identity.cluster_id != request.expected_cluster {
|
||||
return Ok(reject(Rejection::WrongCluster));
|
||||
}
|
||||
let page = match store.created_events_page(request.cursor.as_ref(), request.limit) {
|
||||
Ok(page) => page,
|
||||
Err(StoreError::IdentityMismatch) => return Ok(reject(Rejection::InvalidCursor)),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
Ok(ReadPageReply::Page {
|
||||
response: ReadPageResponse {
|
||||
version: WIRE_VERSION,
|
||||
identity,
|
||||
page,
|
||||
},
|
||||
})
|
||||
};
|
||||
read().unwrap_or_else(|_| reject(Rejection::StoreUnavailable))
|
||||
}
|
||||
|
||||
struct BoundedBytes {
|
||||
bytes: Vec<u8>,
|
||||
limit: usize,
|
||||
}
|
||||
impl io::Write for BoundedBytes {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if bytes.len() > self.limit.saturating_sub(self.bytes.len()) {
|
||||
return Err(io::Error::other("source reader frame limit"));
|
||||
}
|
||||
self.bytes.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn encode(value: &impl Serialize, limit: usize) -> Result<Vec<u8>, ReaderError> {
|
||||
let mut buffer = BoundedBytes {
|
||||
bytes: Vec::new(),
|
||||
limit,
|
||||
};
|
||||
serde_json::to_writer(&mut buffer, value).map_err(|_| ReaderError::Protocol)?;
|
||||
Ok(buffer.bytes)
|
||||
}
|
||||
async fn frame_read(stream: &mut UnixStream, limit: usize) -> Result<Vec<u8>, ReaderError> {
|
||||
let len = usize::try_from(stream.read_u32().await?).map_err(|_| ReaderError::Protocol)?;
|
||||
if len == 0 || len > limit {
|
||||
return Err(ReaderError::Protocol);
|
||||
}
|
||||
let mut bytes = vec![0; len];
|
||||
stream.read_exact(&mut bytes).await?;
|
||||
Ok(bytes)
|
||||
}
|
||||
async fn frame_write(stream: &mut UnixStream, bytes: &[u8]) -> Result<(), ReaderError> {
|
||||
stream
|
||||
.write_u32(u32::try_from(bytes.len()).map_err(|_| ReaderError::Protocol)?)
|
||||
.await?;
|
||||
stream.write_all(bytes).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn connection(mut stream: UnixStream, writer: NativeLogWriter, permit: OwnedSemaphorePermit) -> Result<(), ReaderError> {
|
||||
let bytes = tokio::time::timeout(IO_TIMEOUT, frame_read(&mut stream, MAX_REQUEST_BYTES))
|
||||
.await
|
||||
.map_err(|_| ReaderError::Timeout)??;
|
||||
let request: ReadPage = match serde_json::from_slice(&bytes) {
|
||||
Ok(request) => request,
|
||||
Err(_) => {
|
||||
let bytes = encode(&reject(Rejection::InvalidRequest), MAX_RESPONSE_BYTES)?;
|
||||
return tokio::time::timeout(IO_TIMEOUT, frame_write(&mut stream, &bytes))
|
||||
.await
|
||||
.map_err(|_| ReaderError::Timeout)?;
|
||||
}
|
||||
};
|
||||
// The blocking worker owns admission until read + bounded serialization finish, even
|
||||
// if a deadline, shutdown or a dropped serve task cancels its awaiting connection.
|
||||
let (bytes, _permit) = tokio::task::spawn_blocking(move || {
|
||||
let reply = read_page(&writer, request);
|
||||
let bytes = encode(&reply, MAX_RESPONSE_BYTES).or_else(|_| encode(&reject(Rejection::ResponseLimit), MAX_RESPONSE_BYTES));
|
||||
(bytes, permit)
|
||||
})
|
||||
.await
|
||||
.map_err(ReaderError::Worker)?;
|
||||
tokio::time::timeout(IO_TIMEOUT, frame_write(&mut stream, &bytes?))
|
||||
.await
|
||||
.map_err(|_| ReaderError::Timeout)?
|
||||
}
|
||||
|
||||
/// Connect only to a private socket owned by the effective UID, verified at both ends.
|
||||
#[derive(Clone)]
|
||||
pub struct SourceReaderClient {
|
||||
path: PathBuf,
|
||||
}
|
||||
impl SourceReaderClient {
|
||||
pub fn new(path: impl AsRef<Path>) -> Result<Self, ReaderError> {
|
||||
let (path, _) = private_parent(path.as_ref())?;
|
||||
socket_metadata(&path)?;
|
||||
Ok(Self { path })
|
||||
}
|
||||
pub async fn page(
|
||||
&self,
|
||||
expected_cluster: [u8; 16],
|
||||
cursor: Option<EventCursor>,
|
||||
limit: usize,
|
||||
) -> Result<ReadPageResponse, ReaderError> {
|
||||
let request = ReadPage {
|
||||
version: WIRE_VERSION,
|
||||
expected_cluster,
|
||||
cursor,
|
||||
limit,
|
||||
};
|
||||
tokio::time::timeout(IO_TIMEOUT, self.page_inner(request))
|
||||
.await
|
||||
.map_err(|_| ReaderError::Timeout)?
|
||||
}
|
||||
async fn page_inner(&self, request: ReadPage) -> Result<ReadPageResponse, ReaderError> {
|
||||
let (_, parent) = private_parent(&self.path)?;
|
||||
let inode = socket_metadata(&self.path)?;
|
||||
let mut stream = UnixStream::connect(&self.path).await?;
|
||||
peer(&stream)?;
|
||||
if private_parent(&self.path)?.1 != parent || socket_metadata(&self.path)? != inode {
|
||||
return Err(ReaderError::PathChanged);
|
||||
}
|
||||
frame_write(&mut stream, &encode(&request, MAX_REQUEST_BYTES)?).await?;
|
||||
let bytes = frame_read(&mut stream, MAX_RESPONSE_BYTES).await?;
|
||||
match serde_json::from_slice::<ReadPageReply>(&bytes).map_err(|_| ReaderError::Protocol)? {
|
||||
ReadPageReply::Page { response } => {
|
||||
if response.version != WIRE_VERSION
|
||||
|| response.identity.cluster_id != request.expected_cluster
|
||||
|| response.identity.cluster_id == [0; 16]
|
||||
|| response.page.events.len() > request.limit
|
||||
|| request.limit == 0
|
||||
|| request.limit > crate::MAX_EVENT_PAGE
|
||||
{
|
||||
return Err(ReaderError::Protocol);
|
||||
}
|
||||
let next = response
|
||||
.page
|
||||
.events
|
||||
.last()
|
||||
.map(|event| EventCursor::new(response.identity.cluster_id, event.event_id, &event.captured))
|
||||
.or(request.cursor);
|
||||
if response.page.next != next {
|
||||
return Err(ReaderError::Protocol);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
ReadPageReply::Rejected { version, error } if version == WIRE_VERSION => Err(ReaderError::Rejected(error)),
|
||||
_ => Err(ReaderError::Protocol),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cleanup_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn source_reader_cleanup_is_disarmed_before_rebind() {
|
||||
let directory = tempfile::tempdir().expect("private socket owner");
|
||||
fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).expect("private directory mode");
|
||||
let (path, parent) = private_parent(&directory.path().join("reader.sock")).expect("private path");
|
||||
let listener = std::os::unix::net::UnixListener::bind(&path).expect("first real socket");
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("first socket mode");
|
||||
let inode = socket_metadata(&path).expect("first socket identity");
|
||||
let mut guard = SocketPath {
|
||||
path: path.clone(),
|
||||
inode: Some(inode),
|
||||
parent,
|
||||
};
|
||||
drop(listener);
|
||||
guard.remove().expect("explicit owner cleanup");
|
||||
assert!(guard.inode.is_none(), "SOURCE_READER_CLEANUP_DISARMED");
|
||||
let listener = std::os::unix::net::UnixListener::bind(&path).expect("immediate new owner bind");
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("new socket mode");
|
||||
let replacement = socket_metadata(&path).expect("new owner socket");
|
||||
drop(guard);
|
||||
assert_eq!(
|
||||
socket_metadata(&path).expect("new socket preserved"),
|
||||
replacement,
|
||||
"SOURCE_READER_OLD_GUARD_PRESERVES_REBIND"
|
||||
);
|
||||
let _client = std::os::unix::net::UnixStream::connect(&path).expect("new service reachable");
|
||||
let _connection = listener.accept().expect("new owner accepts");
|
||||
}
|
||||
}
|
||||
@@ -1,502 +0,0 @@
|
||||
//! Real NativeLogWriter and socket owner is this test process, not a production S3 owner.
|
||||
use crate::{source_reader::*, *};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
os::unix::fs::{MetadataExt, PermissionsExt},
|
||||
path::PathBuf,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::UnixStream,
|
||||
};
|
||||
fn identity() -> NodeIdentity {
|
||||
NodeIdentity {
|
||||
cluster_id: [7; 16],
|
||||
node_id: 0,
|
||||
}
|
||||
}
|
||||
fn binding() -> CaptureBinding {
|
||||
CaptureBinding {
|
||||
revision: 9,
|
||||
binding_id: [8; 16],
|
||||
target_id: [9; 16],
|
||||
}
|
||||
}
|
||||
fn id(index: u64) -> LogId {
|
||||
LogId {
|
||||
term: 8,
|
||||
leader_node: 0,
|
||||
index,
|
||||
}
|
||||
}
|
||||
fn command(event: u8) -> DecideCreated {
|
||||
DecideCreated {
|
||||
operation_id: [event; 16],
|
||||
object: ObjectIdentity {
|
||||
bucket_incarnation: [2; 16],
|
||||
key: vec![event],
|
||||
},
|
||||
expected_head: None,
|
||||
prepared: PreparedIdentity {
|
||||
version_id: [event; 16],
|
||||
preparation_id: [4; 16],
|
||||
content_digest: [5; 32],
|
||||
content_length: 123,
|
||||
},
|
||||
binding_revision: 9,
|
||||
}
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, NativeLogWriter) {
|
||||
let dir = tempfile::tempdir().expect("real source reader directory");
|
||||
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).expect("private owner directory");
|
||||
let mut store = Store::initialize(dir.path().join("db"), identity()).expect("initialize real redb");
|
||||
store.pin_capture_binding(binding()).expect("binding");
|
||||
store.enable_native_empty().expect("native schema");
|
||||
store.promote_created_provenance().expect("provenance");
|
||||
let membership = CaptureMembership::new(
|
||||
Some(id(0)),
|
||||
vec![BTreeSet::from([0])],
|
||||
BTreeMap::from([(0, CaptureNode::new("http://127.0.0.1:7000".into()).expect("member"))]),
|
||||
)
|
||||
.expect("membership");
|
||||
let membership = openraft::StoredMembership::try_from(&membership).expect("stored membership");
|
||||
store
|
||||
.append_native(&[CaptureRaftEntry {
|
||||
log_id: id(0).into(),
|
||||
payload: openraft::EntryPayload::Membership(membership.membership().clone()),
|
||||
}])
|
||||
.expect("membership log");
|
||||
store.save_committed(id(0)).expect("membership commit");
|
||||
store.apply_next_native(id(0)).expect("membership apply");
|
||||
(dir, NativeLogWriter::new(store))
|
||||
}
|
||||
async fn apply(writer: &NativeLogWriter, index: u64, event: u8) {
|
||||
let entry = CaptureRaftEntry {
|
||||
log_id: id(index).into(),
|
||||
payload: openraft::EntryPayload::Normal(CaptureCommand::try_created(command(event)).expect("canonical native command")),
|
||||
};
|
||||
writer
|
||||
.append_native(vec![entry.clone()])
|
||||
.await
|
||||
.expect("live durable log append");
|
||||
let response = writer.apply_committed(vec![entry]).await.expect("live committed apply");
|
||||
assert!(
|
||||
matches!(response[0].created_result(), Some(CreatedResult::Created { .. })),
|
||||
"SOURCE_READER_REAL_APPLY"
|
||||
);
|
||||
}
|
||||
fn start(
|
||||
dir: &tempfile::TempDir,
|
||||
writer: &NativeLogWriter,
|
||||
) -> (PathBuf, SourceReaderClient, Shutdown, tokio::task::JoinHandle<Result<(), ReaderError>>) {
|
||||
let path = dir.path().join("reader.sock");
|
||||
let (server, shutdown) = SourceReaderServer::bind(&path, writer.clone()).expect("owner socket bind");
|
||||
let client = SourceReaderClient::new(&path).expect("trusted local client");
|
||||
let task = tokio::spawn(server.serve());
|
||||
(path, client, shutdown, task)
|
||||
}
|
||||
async fn stop(path: PathBuf, shutdown: Shutdown, task: tokio::task::JoinHandle<Result<(), ReaderError>>) {
|
||||
shutdown.shutdown();
|
||||
task.await.expect("serve task").expect("clean shutdown");
|
||||
assert!(!path.exists(), "SOURCE_READER_SOCKET_CLEANUP");
|
||||
}
|
||||
async fn raw(path: &std::path::Path, value: serde_json::Value) -> ReadPageReply {
|
||||
let mut stream = UnixStream::connect(path).await.expect("real socket connect");
|
||||
let bytes = serde_json::to_vec(&value).expect("wire request");
|
||||
stream
|
||||
.write_u32(bytes.len().try_into().expect("request length"))
|
||||
.await
|
||||
.expect("frame prefix");
|
||||
stream.write_all(&bytes).await.expect("frame bytes");
|
||||
let len = stream.read_u32().await.expect("reply prefix");
|
||||
let mut bytes = vec![0; usize::try_from(len).expect("reply length")];
|
||||
stream.read_exact(&mut bytes).await.expect("reply bytes");
|
||||
serde_json::from_slice(&bytes).expect("strict native reply")
|
||||
}
|
||||
fn request() -> serde_json::Value {
|
||||
serde_json::to_value(ReadPage {
|
||||
version: WIRE_VERSION,
|
||||
expected_cluster: identity().cluster_id,
|
||||
cursor: None,
|
||||
limit: 2,
|
||||
})
|
||||
.expect("native request")
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn source_reader_live_apply() {
|
||||
let (dir, writer) = fixture();
|
||||
let (path, client, shutdown, task) = start(&dir, &writer);
|
||||
assert_eq!(writer.durable_identity().await.expect("durable META identity"), identity());
|
||||
assert!(
|
||||
client
|
||||
.page(identity().cluster_id, None, 2)
|
||||
.await
|
||||
.expect("empty live page")
|
||||
.page
|
||||
.events
|
||||
.is_empty()
|
||||
);
|
||||
apply(&writer, 1, 9).await;
|
||||
let response = client
|
||||
.page(identity().cluster_id, None, 2)
|
||||
.await
|
||||
.expect("live committed page");
|
||||
assert_eq!(response.identity, identity(), "SOURCE_READER_DURABLE_IDENTITY");
|
||||
assert_eq!(response.page.events[0].event_id, [9; 16], "SOURCE_READER_LIVE_EVENT");
|
||||
assert_eq!(response.page.events[0].captured.binding, binding());
|
||||
{
|
||||
let mut corrupt = writer.store.lock().expect("test owner");
|
||||
let tx = corrupt.write_transaction().expect("corrupt META transaction");
|
||||
tx.open_table(META)
|
||||
.expect("META")
|
||||
.insert("identity", &[0; 24][..])
|
||||
.expect("corrupt identity");
|
||||
corrupt.commit(tx).expect("persist corruption");
|
||||
}
|
||||
assert!(writer.durable_identity().await.is_err(), "SOURCE_READER_META_CORRUPTION_REJECTED");
|
||||
assert!(
|
||||
matches!(
|
||||
client.page(identity().cluster_id, None, 2).await,
|
||||
Err(ReaderError::Rejected(Rejection::StoreUnavailable))
|
||||
),
|
||||
"SOURCE_READER_NO_CALLER_IDENTITY_SUBSTITUTION"
|
||||
);
|
||||
stop(path, shutdown, task).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn source_reader_restart_cursor() {
|
||||
let (dir, writer) = fixture();
|
||||
apply(&writer, 1, 9).await;
|
||||
apply(&writer, 2, 7).await;
|
||||
let (path, client, shutdown, task) = start(&dir, &writer);
|
||||
let first = client.page(identity().cluster_id, None, 1).await.expect("first");
|
||||
stop(path, shutdown, task).await;
|
||||
drop(writer);
|
||||
let writer = NativeLogWriter::new(Store::open(dir.path().join("db"), identity()).expect("restart same durable store"));
|
||||
let (path, client, shutdown, task) = start(&dir, &writer);
|
||||
let next = client
|
||||
.page(identity().cluster_id, first.page.next, 1)
|
||||
.await
|
||||
.expect("resume durable cursor");
|
||||
assert_eq!(next.page.events[0].event_id, [7; 16], "SOURCE_READER_RESTART_CONTINUES");
|
||||
stop(path, shutdown, task).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn source_reader_page_boundaries() {
|
||||
let (dir, writer) = fixture();
|
||||
for (index, event) in [(1, 9), (2, 7), (3, 1)] {
|
||||
apply(&writer, index, event).await;
|
||||
}
|
||||
let (path, client, shutdown, task) = start(&dir, &writer);
|
||||
let first = client.page(identity().cluster_id, None, 2).await.expect("first two");
|
||||
assert_eq!(
|
||||
first.page.events.iter().map(|e| e.event_id).collect::<Vec<_>>(),
|
||||
vec![[9; 16], [7; 16]],
|
||||
"SOURCE_READER_DECISION_ORDER"
|
||||
);
|
||||
let next = client
|
||||
.page(identity().cluster_id, first.page.next, 2)
|
||||
.await
|
||||
.expect("remaining");
|
||||
assert_eq!(next.page.events.len(), 1);
|
||||
let end = client
|
||||
.page(identity().cluster_id, next.page.next.clone(), 2)
|
||||
.await
|
||||
.expect("end");
|
||||
assert!(end.page.events.is_empty());
|
||||
assert_eq!(end.page.next, next.page.next, "SOURCE_READER_CURSOR_RETAINED_AT_END");
|
||||
for limit in [0, MAX_EVENT_PAGE + 1] {
|
||||
assert!(
|
||||
matches!(
|
||||
client.page(identity().cluster_id, None, limit).await,
|
||||
Err(ReaderError::Rejected(Rejection::InvalidRequest))
|
||||
),
|
||||
"SOURCE_READER_LIMIT_REJECTED"
|
||||
);
|
||||
}
|
||||
stop(path, shutdown, task).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn source_reader_wrong_identity() {
|
||||
let (dir, writer) = fixture();
|
||||
apply(&writer, 1, 9).await;
|
||||
let (path, client, shutdown, task) = start(&dir, &writer);
|
||||
let mut wrong = request();
|
||||
wrong["expected_cluster"] = serde_json::to_value([6; 16]).expect("wrong cluster");
|
||||
assert!(
|
||||
matches!(
|
||||
raw(&path, wrong).await,
|
||||
ReadPageReply::Rejected {
|
||||
error: Rejection::WrongCluster,
|
||||
..
|
||||
}
|
||||
),
|
||||
"SOURCE_READER_CLUSTER: reject before reading or returning foreign events"
|
||||
);
|
||||
let first = client.page(identity().cluster_id, None, 1).await.expect("real cursor");
|
||||
let mut wrong = serde_json::to_value(first.page.next.expect("cursor")).expect("cursor wire");
|
||||
wrong["event"] = serde_json::to_value([1; 16]).expect("wrong event");
|
||||
let wrong: EventCursor = serde_json::from_value(wrong).expect("syntactically valid foreign cursor");
|
||||
assert!(
|
||||
matches!(
|
||||
client.page(identity().cluster_id, Some(wrong), 1).await,
|
||||
Err(ReaderError::Rejected(Rejection::InvalidCursor))
|
||||
),
|
||||
"SOURCE_READER_CURSOR_BOUND"
|
||||
);
|
||||
for (field, value) in [
|
||||
("version", serde_json::json!(2)),
|
||||
("unknown", serde_json::json!(true)),
|
||||
("cursor", serde_json::json!({"unknown":true})),
|
||||
] {
|
||||
let mut wrong = request();
|
||||
wrong[field] = value;
|
||||
assert!(
|
||||
matches!(
|
||||
raw(&path, wrong).await,
|
||||
ReadPageReply::Rejected {
|
||||
error: Rejection::InvalidRequest,
|
||||
..
|
||||
}
|
||||
),
|
||||
"SOURCE_READER_STRICT_REQUEST"
|
||||
);
|
||||
}
|
||||
stop(path, shutdown, task).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn source_reader_socket_ownership() {
|
||||
let (dir, writer) = fixture();
|
||||
let path = dir.path().join("reader.sock");
|
||||
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o750)).expect("insecure directory");
|
||||
assert!(
|
||||
matches!(SourceReaderServer::bind(&path, writer.clone()), Err(ReaderError::Permission)),
|
||||
"SOURCE_READER_PRIVATE_DIRECTORY"
|
||||
);
|
||||
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).expect("restore private directory");
|
||||
std::fs::write(&path, b"preserve me").expect("occupied file");
|
||||
assert!(
|
||||
matches!(SourceReaderServer::bind(&path, writer.clone()), Err(ReaderError::Occupied)),
|
||||
"SOURCE_READER_OCCUPIED_PATH"
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).expect("unchanged file"), b"preserve me");
|
||||
std::fs::remove_file(&path).expect("remove own fixture");
|
||||
std::os::unix::fs::symlink(dir.path().join("missing"), &path).expect("unknown symlink");
|
||||
assert!(matches!(SourceReaderServer::bind(&path, writer.clone()), Err(ReaderError::Occupied)));
|
||||
assert!(SourceReaderClient::new(&path).is_err());
|
||||
std::fs::remove_file(&path).expect("remove own symlink");
|
||||
let long = dir.path().join("x".repeat(300));
|
||||
assert!(SourceReaderServer::bind(&long, writer.clone()).is_err(), "SOURCE_READER_LONG_SOCKET_PATH");
|
||||
let parent_link = dir.path().join("parent-link");
|
||||
std::os::unix::fs::symlink(dir.path(), &parent_link).expect("parent symlink");
|
||||
assert!(
|
||||
matches!(
|
||||
SourceReaderServer::bind(parent_link.join("socket"), writer.clone()),
|
||||
Err(ReaderError::Permission)
|
||||
),
|
||||
"SOURCE_READER_PARENT_SYMLINK_DENIED"
|
||||
);
|
||||
let (path, client, shutdown, task) = start(&dir, &writer);
|
||||
let meta = std::fs::symlink_metadata(&path).expect("socket metadata");
|
||||
assert_eq!(meta.mode() & 0o777, 0o600);
|
||||
assert_eq!(meta.uid(), rustix::process::geteuid().as_raw());
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o660)).expect("insecure socket");
|
||||
assert!(
|
||||
matches!(client.page(identity().cluster_id, None, 1).await, Err(ReaderError::Permission)),
|
||||
"SOURCE_READER_CLIENT_VALIDATES_SOCKET"
|
||||
);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("restore socket");
|
||||
std::fs::remove_file(&path).expect("test owner replaces socket");
|
||||
std::fs::write(&path, b"replacement").expect("replacement file");
|
||||
shutdown.shutdown();
|
||||
assert!(task.await.expect("serve exit").is_err(), "SOURCE_READER_REPLACEMENT_CLEANUP_REJECTED");
|
||||
assert_eq!(std::fs::read(&path).expect("replacement preserved"), b"replacement");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn source_reader_transport_bounds() {
|
||||
let (dir, writer) = fixture();
|
||||
let (path, client, shutdown, task) = start(&dir, &writer);
|
||||
let mut stream = UnixStream::connect(&path).await.expect("oversized input peer");
|
||||
stream
|
||||
.write_u32(u32::try_from(MAX_REQUEST_BYTES + 1).expect("length"))
|
||||
.await
|
||||
.expect("overlong prefix");
|
||||
let mut byte = [0];
|
||||
assert_eq!(
|
||||
stream.read(&mut byte).await.expect("bounded rejection closes"),
|
||||
0,
|
||||
"SOURCE_READER_INPUT_BOUND"
|
||||
);
|
||||
let mut idle = UnixStream::connect(&path).await.expect("idle connection");
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(
|
||||
idle.read(&mut byte).await.expect("deadline closes connection"),
|
||||
0,
|
||||
"SOURCE_READER_READ_DEADLINE"
|
||||
);
|
||||
assert!(start.elapsed() >= IO_TIMEOUT, "SOURCE_READER_REAL_READ_DEADLINE");
|
||||
assert!(
|
||||
client.page(identity().cluster_id, None, 1).await.is_ok(),
|
||||
"SOURCE_READER_TIMEOUT_RELEASES_CONNECTION"
|
||||
);
|
||||
stop(path.clone(), shutdown, task).await;
|
||||
// A same-UID peer still cannot supply unbounded responses to the client.
|
||||
let listener = tokio::net::UnixListener::bind(&path).expect("controlled response peer");
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("private response socket");
|
||||
let client = SourceReaderClient::new(&path).expect("response client");
|
||||
let peer = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("client connects");
|
||||
stream
|
||||
.write_u32(u32::try_from(MAX_RESPONSE_BYTES + 1).expect("oversized response"))
|
||||
.await
|
||||
.expect("response prefix");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
});
|
||||
assert!(
|
||||
matches!(client.page(identity().cluster_id, None, 1).await, Err(ReaderError::Protocol)),
|
||||
"SOURCE_READER_RESPONSE_BOUND"
|
||||
);
|
||||
peer.await.expect("peer task");
|
||||
}
|
||||
fn hold_store(writer: &NativeLogWriter) -> (std::sync::mpsc::Sender<()>, std::thread::JoinHandle<()>) {
|
||||
let owner = writer.clone();
|
||||
let (locked_tx, locked_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let thread = std::thread::spawn(move || {
|
||||
let _store = owner.store.lock().expect("actual Store held by test owner");
|
||||
locked_tx.send(()).expect("lock visible");
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
locked_rx.recv().expect("store locked");
|
||||
(release_tx, thread)
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn source_reader_shutdown() {
|
||||
let (dir, writer) = fixture();
|
||||
apply(&writer, 1, 9).await;
|
||||
let (path, _client, shutdown, task) = start(&dir, &writer);
|
||||
let (release_tx, blocker) = hold_store(&writer);
|
||||
let mut streams = Vec::new();
|
||||
for _ in 0..MAX_CONNECTIONS {
|
||||
let mut stream = UnixStream::connect(&path).await.expect("active read socket");
|
||||
let bytes = serde_json::to_vec(&request()).expect("request");
|
||||
stream
|
||||
.write_u32(bytes.len().try_into().expect("length"))
|
||||
.await
|
||||
.expect("prefix");
|
||||
stream.write_all(&bytes).await.expect("request bytes");
|
||||
streams.push(stream);
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
while writer.source_reader_admission.available_permits() != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("all admissions active");
|
||||
drop(streams);
|
||||
tokio::time::sleep(IO_TIMEOUT + std::time::Duration::from_millis(100)).await;
|
||||
assert_eq!(
|
||||
writer.source_reader_admission.available_permits(),
|
||||
0,
|
||||
"SOURCE_READER_CLIENT_TIMEOUT_KEEPS_WORKER_ADMISSION"
|
||||
);
|
||||
shutdown.shutdown();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(!task.is_finished(), "SOURCE_READER_SHUTDOWN_DRAINS_READERS");
|
||||
assert_eq!(
|
||||
writer.source_reader_admission.available_permits(),
|
||||
0,
|
||||
"SOURCE_READER_CANCELLED_CLIENTS_KEEP_ADMISSION"
|
||||
);
|
||||
release_tx.send(()).expect("release real blocking reads");
|
||||
blocker.join().expect("test lock owner exits");
|
||||
task.await.expect("serve completes").expect("shutdown clean");
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
writer.source_reader_admission.available_permits(),
|
||||
MAX_CONNECTIONS,
|
||||
"SOURCE_READER_ADMISSION_RELEASED_AFTER_WORKERS"
|
||||
);
|
||||
let (release, blocker) = hold_store(&writer);
|
||||
let (path, _, _shutdown, task) = start(&dir, &writer);
|
||||
let bytes = serde_json::to_vec(&request()).expect("native request");
|
||||
let mut sockets = Vec::new();
|
||||
for _ in 0..MAX_CONNECTIONS {
|
||||
let mut stream = UnixStream::connect(&path).await.expect("cancelled service peer");
|
||||
stream
|
||||
.write_u32(bytes.len().try_into().expect("length"))
|
||||
.await
|
||||
.expect("prefix");
|
||||
stream.write_all(&bytes).await.expect("request");
|
||||
sockets.push(stream);
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
while writer.source_reader_admission.available_permits() != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cancelled service admits bounded workers");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("serve cancellation").is_cancelled());
|
||||
drop(sockets);
|
||||
assert_eq!(
|
||||
writer.source_reader_admission.available_permits(),
|
||||
0,
|
||||
"SOURCE_READER_CANCELLED_SERVE_RETAINS_WORKER_ADMISSION"
|
||||
);
|
||||
let (path, client, shutdown, task) = start(&dir, &writer);
|
||||
let pending = tokio::spawn(async move { client.page(identity().cluster_id, None, 1).await });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(!pending.is_finished(), "SOURCE_READER_REBIND_CANNOT_BYPASS_ADMISSION");
|
||||
assert_eq!(writer.source_reader_admission.available_permits(), 0);
|
||||
release.send(()).expect("release cancelled worker store lock");
|
||||
blocker.join().expect("lock owner completes");
|
||||
assert_eq!(
|
||||
pending
|
||||
.await
|
||||
.expect("new service client")
|
||||
.expect("new service waits for old workers")
|
||||
.page
|
||||
.events
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
stop(path, shutdown, task).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn source_reader_checkpoint_replay() {
|
||||
let (dir, writer) = fixture();
|
||||
apply(&writer, 1, 9).await;
|
||||
apply(&writer, 2, 7).await;
|
||||
let (path, client, shutdown, task) = start(&dir, &writer);
|
||||
let first = client
|
||||
.page(identity().cluster_id, None, 1)
|
||||
.await
|
||||
.expect("pre-checkpoint cursor");
|
||||
let checkpoint = writer
|
||||
.checkpoint_local(CheckpointLimits::default())
|
||||
.await
|
||||
.expect("real checkpoint");
|
||||
assert_eq!(checkpoint.cut(), id(2));
|
||||
let next = client
|
||||
.page(identity().cluster_id, first.page.next.clone(), 1)
|
||||
.await
|
||||
.expect("checkpoint retained events");
|
||||
assert_eq!(next.page.events[0].event_id, [7; 16], "SOURCE_READER_CHECKPOINT_REPLAY");
|
||||
stop(path, shutdown, task).await;
|
||||
drop(writer);
|
||||
let writer = NativeLogWriter::new(Store::open(dir.path().join("db"), identity()).expect("reopen checkpointed store"));
|
||||
let (path, client, shutdown, task) = start(&dir, &writer);
|
||||
assert_eq!(
|
||||
client
|
||||
.page(identity().cluster_id, first.page.next, 1)
|
||||
.await
|
||||
.expect("replay after restart"),
|
||||
next,
|
||||
"SOURCE_READER_CHECKPOINT_RESTART"
|
||||
);
|
||||
stop(path, shutdown, task).await;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use crate::{LOGS, META, Store, StoreError, decode_id, decode_log, encode_id};
|
||||
use redb::ReadableTable;
|
||||
|
||||
impl Store {
|
||||
/// Atomically remove every log entry at or after `from`.
|
||||
pub fn truncate_suffix(&mut self, from: u64) -> Result<(), StoreError> {
|
||||
let tx = self.write_transaction()?;
|
||||
{
|
||||
let mut meta = tx.open_table(META)?;
|
||||
let logs = tx.open_table(LOGS)?;
|
||||
if crate::committed::validate_committed(&meta, &logs)?.is_some_and(|id| from <= id.index) {
|
||||
return Err(StoreError::CommittedLogProtected);
|
||||
}
|
||||
drop(logs);
|
||||
let tail = meta.get("tail")?.map(|v| decode_id(v.value())).transpose()?;
|
||||
if tail.is_none_or(|id| from > id.index) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut logs = tx.open_table(LOGS)?;
|
||||
let new_tail = if from == 0 {
|
||||
None
|
||||
} else {
|
||||
let index = from - 1;
|
||||
let id = match logs.get(index)? {
|
||||
Some(record) => decode_log(record.value())?.id,
|
||||
None => crate::checkpoint_baseline::read_purged(&meta)?
|
||||
.filter(|id| id.index == index)
|
||||
.ok_or(StoreError::InvalidLogSequence)?,
|
||||
};
|
||||
if id.index != index {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Some(id)
|
||||
};
|
||||
logs.retain(|index, _| index < from)?;
|
||||
match new_tail {
|
||||
Some(id) => {
|
||||
meta.insert("tail", encode_id(id).as_slice())?;
|
||||
}
|
||||
None => {
|
||||
meta.remove("tail")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.commit(tx)
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,13 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-util = []
|
||||
hotpath = ["hotpath/hotpath", "hotpath/tokio"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||
|
||||
[dependencies]
|
||||
arc-swap.workspace = true
|
||||
hotpath.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::RwLock;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
@@ -27,6 +28,42 @@ pub static GLOBAL_ROOT_CERT: LazyLock<RwLock<Option<Vec<u8>>>> = LazyLock::new(|
|
||||
pub static GLOBAL_MTLS_IDENTITY: LazyLock<RwLock<Option<MtlsIdentityPem>>> = LazyLock::new(|| RwLock::new(None));
|
||||
pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock<AtomicU64> = LazyLock::new(|| AtomicU64::new(0));
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct OutboundTlsSnapshot {
|
||||
pub generation: u64,
|
||||
pub root_ca_pem: Option<Vec<u8>>,
|
||||
pub mtls_identity: Option<MtlsIdentityPem>,
|
||||
}
|
||||
|
||||
static GLOBAL_OUTBOUND_TLS_STATE: LazyLock<ArcSwap<OutboundTlsSnapshot>> =
|
||||
LazyLock::new(|| ArcSwap::from_pointee(OutboundTlsSnapshot::default()));
|
||||
|
||||
pub fn get_global_outbound_tls_snapshot() -> Arc<OutboundTlsSnapshot> {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.load_full()
|
||||
}
|
||||
|
||||
/// Publish identity, trust and generation together; the individual globals are legacy views.
|
||||
pub async fn publish_global_outbound_tls_snapshot(snapshot: OutboundTlsSnapshot) -> u64 {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
|
||||
if current.generation != 0
|
||||
&& current.root_ca_pem == snapshot.root_ca_pem
|
||||
&& current.mtls_identity == snapshot.mtls_identity
|
||||
{
|
||||
return Arc::clone(current);
|
||||
}
|
||||
Arc::new(OutboundTlsSnapshot {
|
||||
generation: snapshot.generation.max(current.generation.saturating_add(1)),
|
||||
root_ca_pem: snapshot.root_ca_pem.clone(),
|
||||
mtls_identity: snapshot.mtls_identity.clone(),
|
||||
})
|
||||
});
|
||||
let accepted = get_global_outbound_tls_snapshot();
|
||||
*GLOBAL_ROOT_CERT.write().await = accepted.root_ca_pem.clone();
|
||||
*GLOBAL_MTLS_IDENTITY.write().await = accepted.mtls_identity.clone();
|
||||
GLOBAL_OUTBOUND_TLS_GENERATION.store(accepted.generation, Ordering::Relaxed);
|
||||
accepted.generation
|
||||
}
|
||||
|
||||
/// Log level to use when reporting cached gRPC connection eviction.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ConnectionEvictionLogLevel {
|
||||
@@ -60,6 +97,45 @@ pub fn try_get_global_local_node_name() -> Option<String> {
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
/// Run a synchronous test callback while publishing a name under its writer lock.
|
||||
/// Call from a blocking worker; returning or unwinding releases the writer.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[doc(hidden)]
|
||||
pub fn with_local_node_name_write_lock_for_test<T>(name: &str, work: impl FnOnce() -> T) -> T {
|
||||
let mut writer = GLOBAL_LOCAL_NODE_NAME.blocking_write();
|
||||
*writer = name.to_string();
|
||||
let result = work();
|
||||
drop(writer);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod node_identity_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_node_name_nonblocking_read_recovers_after_writer_release() {
|
||||
let previous = get_global_local_node_name().await;
|
||||
let (unavailable, published, panicked) = tokio::task::spawn_blocking(|| {
|
||||
let unavailable = with_local_node_name_write_lock_for_test("published-node", try_get_global_local_node_name);
|
||||
let published = try_get_global_local_node_name();
|
||||
let panicked = std::panic::catch_unwind(|| {
|
||||
with_local_node_name_write_lock_for_test("unwound-node", || panic!("identity callback failed"));
|
||||
});
|
||||
(unavailable, published, panicked)
|
||||
})
|
||||
.await
|
||||
.expect("identity writer worker must complete");
|
||||
let after_unwind = try_get_global_local_node_name();
|
||||
set_global_local_node_name(&previous).await;
|
||||
|
||||
assert!(unavailable.is_none(), "a held writer must make the nonblocking getter unavailable");
|
||||
assert_eq!(published.as_deref(), Some("published-node"));
|
||||
assert!(panicked.is_err(), "the callback panic must propagate after releasing the lock");
|
||||
assert_eq!(after_unwind.as_deref(), Some("unwound-node"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the global RustFS address used for gRPC connections.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -85,17 +161,27 @@ pub async fn get_global_addr() -> String {
|
||||
/// # Arguments
|
||||
/// * `cert` - A vector of bytes representing the PEM-encoded root CA certificate.
|
||||
pub async fn set_global_root_cert(cert: Vec<u8>) {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
|
||||
let mut next = (**current).clone();
|
||||
next.root_ca_pem = Some(cert.clone());
|
||||
Arc::new(next)
|
||||
});
|
||||
*GLOBAL_ROOT_CERT.write().await = Some(cert);
|
||||
}
|
||||
|
||||
/// Clear the global root CA certificate for outbound gRPC clients.
|
||||
pub async fn clear_global_root_cert() {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
|
||||
let mut next = (**current).clone();
|
||||
next.root_ca_pem = None;
|
||||
Arc::new(next)
|
||||
});
|
||||
*GLOBAL_ROOT_CERT.write().await = None;
|
||||
}
|
||||
|
||||
/// Get the global root CA certificate for outbound gRPC clients.
|
||||
pub async fn get_global_root_cert() -> Option<Vec<u8>> {
|
||||
GLOBAL_ROOT_CERT.read().await.clone()
|
||||
get_global_outbound_tls_snapshot().root_ca_pem.clone()
|
||||
}
|
||||
|
||||
/// Set the global mTLS identity (cert+key PEM) for outbound gRPC clients.
|
||||
@@ -105,22 +191,32 @@ pub async fn get_global_root_cert() -> Option<Vec<u8>> {
|
||||
/// # Arguments
|
||||
/// * `identity` - An optional MtlsIdentityPem struct containing the cert and key PEM.
|
||||
pub async fn set_global_mtls_identity(identity: Option<MtlsIdentityPem>) {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
|
||||
let mut next = (**current).clone();
|
||||
next.mtls_identity = identity.clone();
|
||||
Arc::new(next)
|
||||
});
|
||||
*GLOBAL_MTLS_IDENTITY.write().await = identity;
|
||||
}
|
||||
|
||||
/// Get the global mTLS identity for outbound gRPC clients.
|
||||
pub async fn get_global_mtls_identity() -> Option<MtlsIdentityPem> {
|
||||
GLOBAL_MTLS_IDENTITY.read().await.clone()
|
||||
get_global_outbound_tls_snapshot().mtls_identity.clone()
|
||||
}
|
||||
|
||||
/// Set the global outbound TLS generation.
|
||||
pub fn set_global_outbound_tls_generation(generation: u64) {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
|
||||
let mut next = (**current).clone();
|
||||
next.generation = generation;
|
||||
Arc::new(next)
|
||||
});
|
||||
GLOBAL_OUTBOUND_TLS_GENERATION.store(generation, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Get the global outbound TLS generation.
|
||||
pub fn get_global_outbound_tls_generation() -> u64 {
|
||||
GLOBAL_OUTBOUND_TLS_GENERATION.load(Ordering::Relaxed)
|
||||
get_global_outbound_tls_snapshot().generation
|
||||
}
|
||||
|
||||
/// Evict a stale/dead connection from the global connection cache.
|
||||
@@ -194,8 +290,77 @@ pub async fn clear_all_connections() {
|
||||
///
|
||||
/// When present, gRPC clients will present this identity to servers requesting/requiring mTLS.
|
||||
/// When absent, clients proceed with standard server-authenticated TLS.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct MtlsIdentityPem {
|
||||
pub cert_pem: Vec<u8>,
|
||||
pub key_pem: Vec<u8>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MtlsIdentityPem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("MtlsIdentityPem")
|
||||
.field("cert_bytes", &self.cert_pem.len())
|
||||
.field("key_pem", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tls_snapshot_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_snapshot_publication_is_coherent_and_skips_unchanged_material() {
|
||||
let start = get_global_outbound_tls_generation().saturating_add(1);
|
||||
let snapshot = OutboundTlsSnapshot {
|
||||
generation: start,
|
||||
root_ca_pem: Some(vec![1]),
|
||||
mtls_identity: Some(MtlsIdentityPem {
|
||||
cert_pem: vec![1],
|
||||
key_pem: vec![1],
|
||||
}),
|
||||
};
|
||||
let first = publish_global_outbound_tls_snapshot(snapshot.clone()).await;
|
||||
assert_eq!(
|
||||
publish_global_outbound_tls_snapshot(OutboundTlsSnapshot {
|
||||
generation: first.saturating_add(1),
|
||||
..snapshot
|
||||
})
|
||||
.await,
|
||||
first
|
||||
);
|
||||
let writer = tokio::spawn(async move {
|
||||
for tag in 2_u8..=255 {
|
||||
publish_global_outbound_tls_snapshot(OutboundTlsSnapshot {
|
||||
generation: start.saturating_add(u64::from(tag)),
|
||||
root_ca_pem: Some(vec![tag]),
|
||||
mtls_identity: Some(MtlsIdentityPem {
|
||||
cert_pem: vec![tag],
|
||||
key_pem: vec![tag],
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
});
|
||||
while !writer.is_finished() {
|
||||
let state = get_global_outbound_tls_snapshot();
|
||||
let identity = state.mtls_identity.as_ref().expect("identity in complete snapshot");
|
||||
assert_eq!(state.root_ca_pem.as_ref().expect("roots in complete snapshot"), &identity.cert_pem);
|
||||
assert_eq!(identity.cert_pem, identity.key_pem);
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
writer.await.expect("writer completes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtls_identity_debug_redacts_private_key() {
|
||||
let identity = MtlsIdentityPem {
|
||||
cert_pem: b"public certificate".to_vec(),
|
||||
key_pem: b"PRIVATE-KEY-MARKER".to_vec(),
|
||||
};
|
||||
let debug = format!("{identity:?}");
|
||||
assert!(!debug.contains("PRIVATE-KEY-MARKER"));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,11 +172,41 @@ pub struct MrfUnverifiedLegacyEvent {
|
||||
pub anchor: MrfDurableRepairAnchor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ResolvedMrfRepairIdentity {
|
||||
version_id: Option<[u8; 16]>,
|
||||
scope: Option<MrfScope>,
|
||||
delete_marker_purge: Option<MrfDeleteMarkerPurgeIdentity>,
|
||||
lease: MrfIngressLease,
|
||||
bucket_incarnation_id: Uuid,
|
||||
}
|
||||
|
||||
impl MrfDurableRepairAnchor {
|
||||
/// Build a dischargeable anchor only when the caller supplies the storage
|
||||
/// incarnation and the original ingress lease. Legacy replay records lack
|
||||
/// both pieces and therefore remain fail-closed.
|
||||
pub fn from_intent(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<Self> {
|
||||
let identity = Self::resolve_identity(intent, bucket_incarnation_id)?;
|
||||
Some(Self {
|
||||
kind: intent.kind,
|
||||
bucket: intent.bucket.clone(),
|
||||
object: intent.object.clone(),
|
||||
version_id: identity.version_id,
|
||||
scope: identity.scope,
|
||||
delete_marker_purge: identity.delete_marker_purge,
|
||||
lease: identity.lease,
|
||||
bucket_incarnation_id: identity.bucket_incarnation_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether the intent can form an anchor for this exact source,
|
||||
/// without retaining its bucket/object handles. This checks identity shape,
|
||||
/// not storage verification or retirement authority.
|
||||
pub fn is_eligible_for_source(intent: &MrfIntent, source: Uuid) -> bool {
|
||||
Self::resolve_identity(intent, source).is_some_and(|identity| identity.bucket_incarnation_id == source)
|
||||
}
|
||||
|
||||
fn resolve_identity(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<ResolvedMrfRepairIdentity> {
|
||||
let lease = intent.lease?;
|
||||
let (version_id, scope) = canonical_identity(intent.kind, intent.version_id, intent.scope);
|
||||
let delete_marker_purge = canonical_delete_marker_purge_identity(intent)?;
|
||||
@@ -186,10 +216,7 @@ impl MrfDurableRepairAnchor {
|
||||
if bucket_incarnation_id.is_nil() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
kind: intent.kind,
|
||||
bucket: intent.bucket.clone(),
|
||||
object: intent.object.clone(),
|
||||
Some(ResolvedMrfRepairIdentity {
|
||||
version_id,
|
||||
scope,
|
||||
delete_marker_purge,
|
||||
@@ -233,24 +260,32 @@ pub fn consume_recorded_verified_mrf_repair_events_for(bucket: &str, anchors: &m
|
||||
let Ok(mut events) = registry.lock() else {
|
||||
return 0;
|
||||
};
|
||||
consume_verified_mrf_repair_events_from_queue(bucket, anchors, &mut events)
|
||||
}
|
||||
|
||||
fn consume_verified_mrf_repair_events_from_queue(
|
||||
bucket: &str,
|
||||
anchors: &mut Vec<MrfDurableRepairAnchor>,
|
||||
events: &mut std::collections::VecDeque<MrfVerifiedRepairEvent>,
|
||||
) -> usize {
|
||||
let before = anchors.len();
|
||||
let mut retained = std::collections::VecDeque::with_capacity(events.len());
|
||||
while let Some(event) = events.pop_front() {
|
||||
let previous_event_count = events.len();
|
||||
events.retain(|event| {
|
||||
if event.bucket.as_ref() != bucket {
|
||||
retained.push_back(event);
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
let mut matched = false;
|
||||
anchors.retain(|anchor| {
|
||||
let proven = anchor.is_proven_by(&event);
|
||||
let proven = anchor.is_proven_by(event);
|
||||
matched |= proven;
|
||||
!proven
|
||||
});
|
||||
if !matched {
|
||||
retained.push_back(event);
|
||||
}
|
||||
}
|
||||
*events = retained;
|
||||
!matched
|
||||
});
|
||||
// The old queue rebuild reserved the pre-consumption event count. Reuse
|
||||
// that buffer when possible without extending its capacity lifetime:
|
||||
// an emptied queue releases its backing storage on the next consumption.
|
||||
events.shrink_to(previous_event_count);
|
||||
before.saturating_sub(anchors.len())
|
||||
}
|
||||
|
||||
@@ -821,6 +856,21 @@ static MRF_VERIFIED_REPAIR_EVENTS: OnceLock<std::sync::Mutex<std::collections::V
|
||||
static MRF_UNVERIFIED_LEGACY_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfUnverifiedLegacyEvent>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Whether durable repair events may need consumption. This is only a work
|
||||
/// hint: either queue can receive an event after its empty observation, and a
|
||||
/// bounded queue can evict that event before the next consumer invocation.
|
||||
/// An unavailable lock is unknown, so callers must keep their normal path.
|
||||
/// An allocated empty verified queue also needs the normal consumer pass to
|
||||
/// release its buffer; this hint must not extend that buffer's lifetime.
|
||||
pub fn mrf_durable_repair_events_may_be_pending() -> bool {
|
||||
MRF_VERIFIED_REPAIR_EVENTS
|
||||
.get()
|
||||
.is_some_and(|registry| registry.try_lock().map_or(true, |events| events.capacity() != 0))
|
||||
|| MRF_UNVERIFIED_LEGACY_EVENTS
|
||||
.get()
|
||||
.is_some_and(|registry| registry.try_lock().map_or(true, |events| !events.is_empty()))
|
||||
}
|
||||
|
||||
/// Record a legacy notification for compatibility. This is not an
|
||||
/// acknowledgement of storage verification or durable repair completion.
|
||||
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
||||
@@ -935,6 +985,15 @@ pub fn take_mrf_unverified_legacy_events_for(bucket: &str) -> Vec<MrfUnverifiedL
|
||||
taken
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod event_presence_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod proof_queue_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod repair_identity_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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::*;
|
||||
|
||||
fn isolated(name: &str) -> bool {
|
||||
const CHILD: &str = "RUSTFS_MRF_EVENT_PRESENCE_TEST";
|
||||
let test = format!("mrf_channel::event_presence_tests::{name}");
|
||||
if let Ok(current) = std::env::var(CHILD) {
|
||||
assert_eq!(current, test, "only the selected registry test may run in the child");
|
||||
return false;
|
||||
}
|
||||
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args([&test, "--exact", "--test-threads=1"])
|
||||
.env(CHILD, &test)
|
||||
.output()
|
||||
.expect("run isolated global registry test");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("1 passed; 0 failed"),
|
||||
"registry child must execute exactly once: {stdout}{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_presence_covers_both_queues_and_contention_without_consuming() {
|
||||
if isolated("event_presence_covers_both_queues_and_contention_without_consuming") {
|
||||
return;
|
||||
}
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let anchor = MrfDurableRepairAnchor {
|
||||
kind: MrfKind::PartialWrite,
|
||||
bucket: Arc::from("presence-test"),
|
||||
object: Arc::from("object"),
|
||||
version_id: Some([7; 16]),
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 0,
|
||||
set_index: 0,
|
||||
}),
|
||||
delete_marker_purge: None,
|
||||
lease: MrfIngressLease::new(21),
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
};
|
||||
let event = MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket.clone(),
|
||||
object: anchor.object.clone(),
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
delete_marker_purge: None,
|
||||
lease: Some(anchor.lease),
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
disposition: MrfVerifiedRepairDisposition::VerifiedHealthy,
|
||||
};
|
||||
note_mrf_verified_repair(event.clone());
|
||||
assert!(mrf_durable_repair_events_may_be_pending());
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "the hint must not consume a proof");
|
||||
assert_eq!(take_mrf_verified_repair_events_for(&anchor.bucket), vec![event]);
|
||||
assert!(
|
||||
mrf_durable_repair_events_may_be_pending(),
|
||||
"the empty allocated verified buffer still needs its original maintenance pass"
|
||||
);
|
||||
let mut retained = vec![anchor.clone()];
|
||||
assert_eq!(consume_recorded_verified_mrf_repair_events_for(&anchor.bucket, &mut retained), 0);
|
||||
assert_eq!(retained, vec![anchor.clone()], "buffer maintenance cannot discharge an anchor");
|
||||
assert_eq!(
|
||||
MRF_VERIFIED_REPAIR_EVENTS
|
||||
.get()
|
||||
.expect("verified registry")
|
||||
.lock()
|
||||
.expect("verified storage")
|
||||
.capacity(),
|
||||
0,
|
||||
"the actual consumer releases empty proof storage before subsequent idle passes can skip it"
|
||||
);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let legacy = MrfUnverifiedLegacyEvent { anchor };
|
||||
note_mrf_unverified_legacy(legacy.clone());
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "legacy-only work must not be skipped");
|
||||
assert_eq!(take_mrf_unverified_legacy_events_for(&legacy.anchor.bucket), vec![legacy]);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let verified = MRF_VERIFIED_REPAIR_EVENTS.get().expect("verified registry initialized");
|
||||
let guard = verified.lock().expect("hold verified registry");
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "contended verified is unknown");
|
||||
drop(guard);
|
||||
let legacy = MRF_UNVERIFIED_LEGACY_EVENTS.get().expect("legacy registry initialized");
|
||||
let guard = legacy.lock().expect("hold legacy registry");
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "contended legacy is unknown");
|
||||
drop(guard);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_presence_keeps_poisoned_verified_unknown() {
|
||||
if isolated("event_presence_keeps_poisoned_verified_unknown") {
|
||||
return;
|
||||
}
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let registry = MRF_VERIFIED_REPAIR_EVENTS.get_or_init(|| Mutex::new(Default::default()));
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
let _guard = registry.lock().expect("unpoisoned registry");
|
||||
panic!("poison the isolated verified registry");
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(mrf_durable_repair_events_may_be_pending());
|
||||
assert!(registry.is_poisoned(), "the hint must not clear or recover poisoned state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_presence_keeps_poisoned_legacy_unknown() {
|
||||
if isolated("event_presence_keeps_poisoned_legacy_unknown") {
|
||||
return;
|
||||
}
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let registry = MRF_UNVERIFIED_LEGACY_EVENTS.get_or_init(|| Mutex::new(Default::default()));
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
let _guard = registry.lock().expect("unpoisoned registry");
|
||||
panic!("poison the isolated legacy registry");
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(mrf_durable_repair_events_may_be_pending());
|
||||
assert!(registry.is_poisoned(), "the hint must not clear or recover poisoned state");
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// 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 std::collections::VecDeque;
|
||||
|
||||
fn proof_fixture() -> (MrfDurableRepairAnchor, MrfVerifiedRepairEvent) {
|
||||
let anchor = MrfDurableRepairAnchor {
|
||||
kind: MrfKind::PartialWrite,
|
||||
bucket: Arc::from("proof-queue"),
|
||||
object: Arc::from("matched"),
|
||||
version_id: Some([7; 16]),
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 1,
|
||||
set_index: 2,
|
||||
}),
|
||||
delete_marker_purge: None,
|
||||
lease: MrfIngressLease::new(21),
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
};
|
||||
let event = MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket.clone(),
|
||||
object: anchor.object.clone(),
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
delete_marker_purge: anchor.delete_marker_purge,
|
||||
lease: Some(anchor.lease),
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||
};
|
||||
(anchor, event)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_reuses_unmatched_storage_in_contiguous_and_wrapped_layouts() {
|
||||
for wrapped in [false, true] {
|
||||
for foreign in [false, true] {
|
||||
let (anchor, mut event) = proof_fixture();
|
||||
if foreign {
|
||||
event.bucket = Arc::from("another-bucket");
|
||||
} else {
|
||||
event.lease = None;
|
||||
}
|
||||
let mut events = VecDeque::with_capacity(8);
|
||||
events.extend(std::iter::repeat_n(event, events.capacity()));
|
||||
if wrapped {
|
||||
let first = events.pop_front().expect("full local proof queue");
|
||||
events.push_back(first);
|
||||
assert!(!events.as_slices().1.is_empty(), "exercise a wrapped producer queue");
|
||||
}
|
||||
let capacity = events.capacity();
|
||||
let storage = events
|
||||
.iter()
|
||||
.map(std::ptr::from_ref)
|
||||
.min()
|
||||
.expect("nonempty local proof storage");
|
||||
let expected = events.clone();
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
for _ in 0..4 {
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(events, expected);
|
||||
assert_eq!(anchors, vec![anchor.clone()]);
|
||||
assert_eq!(events.capacity(), capacity);
|
||||
assert_eq!(
|
||||
events.iter().map(std::ptr::from_ref).min(),
|
||||
Some(storage),
|
||||
"a full unmatched queue must reuse its buffer instead of rebuilding it for every bucket"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_consumes_all_exact_anchor_copies_and_preserves_duplicate_proof_order() {
|
||||
let (anchor, event) = proof_fixture();
|
||||
let waiting = MrfDurableRepairAnchor {
|
||||
object: Arc::from("waiting"),
|
||||
..anchor.clone()
|
||||
};
|
||||
let foreign = MrfVerifiedRepairEvent {
|
||||
bucket: Arc::from("foreign"),
|
||||
..event.clone()
|
||||
};
|
||||
let unleased = MrfVerifiedRepairEvent {
|
||||
lease: None,
|
||||
..event.clone()
|
||||
};
|
||||
let future = MrfVerifiedRepairEvent {
|
||||
object: Arc::from("future"),
|
||||
..event.clone()
|
||||
};
|
||||
let mut events = VecDeque::from(vec![
|
||||
foreign.clone(),
|
||||
event.clone(),
|
||||
unleased.clone(),
|
||||
event.clone(),
|
||||
future.clone(),
|
||||
]);
|
||||
let first = events.pop_front().expect("foreign proof starts the queue");
|
||||
events.push_back(first);
|
||||
assert!(!events.as_slices().1.is_empty(), "match and retain proofs across the wrap boundary");
|
||||
let mut anchors = vec![anchor.clone(), waiting.clone(), anchor.clone()];
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
2
|
||||
);
|
||||
assert_eq!(anchors, vec![waiting]);
|
||||
assert_eq!(
|
||||
events,
|
||||
VecDeque::from(vec![unleased, event, future, foreign]),
|
||||
"the later duplicate proof remains available after the first proof consumes every exact anchor"
|
||||
);
|
||||
let retained = events.clone();
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(events, retained, "a later consumer must still be able to match the retained proofs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_keeps_every_incomplete_or_mismatched_identity() {
|
||||
let (anchor, event) = proof_fixture();
|
||||
let different_purge = MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_identity: [3; 32],
|
||||
};
|
||||
for rejected in [
|
||||
MrfVerifiedRepairEvent {
|
||||
kind: MrfKind::DecodeFailure,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
bucket: Arc::from("foreign"),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
object: Arc::from("other"),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
version_id: None,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
version_id: Some([8; 16]),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
scope: None,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 2,
|
||||
set_index: 2,
|
||||
}),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 1,
|
||||
set_index: 3,
|
||||
}),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
lease: None,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
lease: Some(MrfIngressLease::new(22)),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
delete_marker_purge: Some(different_purge),
|
||||
..event
|
||||
},
|
||||
] {
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
let mut events = VecDeque::from([rejected.clone()]);
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(anchors, vec![anchor.clone()]);
|
||||
assert_eq!(events, VecDeque::from([rejected]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_requires_the_complete_delete_marker_purge_identity() {
|
||||
let (mut anchor, mut event) = proof_fixture();
|
||||
let purge = MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_identity: [3; 32],
|
||||
};
|
||||
anchor.kind = MrfKind::DeleteMarkerPurge;
|
||||
anchor.delete_marker_purge = Some(purge);
|
||||
event.kind = anchor.kind;
|
||||
event.delete_marker_purge = anchor.delete_marker_purge;
|
||||
for rejected in [
|
||||
None,
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
..purge
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
marker_incarnation_id: Uuid::new_v4(),
|
||||
..purge
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
marker_identity: [4; 32],
|
||||
..purge
|
||||
}),
|
||||
] {
|
||||
let rejected = MrfVerifiedRepairEvent {
|
||||
delete_marker_purge: rejected,
|
||||
..event.clone()
|
||||
};
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
let mut events = VecDeque::from([rejected.clone(), event.clone()]);
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
1
|
||||
);
|
||||
assert!(anchors.is_empty());
|
||||
assert_eq!(events, VecDeque::from([rejected]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_capacity_follows_the_preconsumption_length_and_releases_on_the_next_empty_call() {
|
||||
let (anchor, event) = proof_fixture();
|
||||
let future_events = (0..3)
|
||||
.map(|index| MrfVerifiedRepairEvent {
|
||||
object: Arc::from(format!("future-{index}")),
|
||||
..event.clone()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut events = VecDeque::with_capacity(64);
|
||||
events.push_back(event);
|
||||
events.extend(future_events.iter().cloned());
|
||||
let previous_event_count = events.len();
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
1
|
||||
);
|
||||
assert_eq!(events.iter().cloned().collect::<Vec<_>>(), future_events);
|
||||
assert_eq!(
|
||||
events.capacity(),
|
||||
VecDeque::<MrfVerifiedRepairEvent>::with_capacity(previous_event_count).capacity(),
|
||||
"preserve the former queue rebuild's capacity lifetime instead of retaining the producer high water"
|
||||
);
|
||||
let previous_event_count = events.len();
|
||||
let mut anchors = future_events
|
||||
.iter()
|
||||
.map(|event| MrfDurableRepairAnchor {
|
||||
object: event.object.clone(),
|
||||
..anchor.clone()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
3
|
||||
);
|
||||
assert!(events.is_empty());
|
||||
assert_eq!(
|
||||
events.capacity(),
|
||||
VecDeque::<MrfVerifiedRepairEvent>::with_capacity(previous_event_count).capacity()
|
||||
);
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
events.capacity(),
|
||||
0,
|
||||
"the next empty consumption releases backing storage just as a rebuild did"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// 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::*;
|
||||
|
||||
// Compatibility reference from a47649fb60c0492cc0fb02bff28439c5e357f8e4.
|
||||
// Keep this independent of the shared resolver and its canonical helpers.
|
||||
fn original_anchor(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<MrfDurableRepairAnchor> {
|
||||
let lease = intent.lease?;
|
||||
let version_id = intent.version_id.filter(|bytes| *bytes != [0; 16]);
|
||||
let (version_id, scope) = match intent.kind {
|
||||
MrfKind::MetadataCorruption => (None, None),
|
||||
MrfKind::DecodeFailure | MrfKind::PartialWrite | MrfKind::DeleteMarkerPurge => (version_id, intent.scope),
|
||||
};
|
||||
let delete_marker_purge = match intent.kind {
|
||||
MrfKind::DeleteMarkerPurge => intent
|
||||
.delete_marker_purge
|
||||
.as_ref()
|
||||
.filter(|payload| {
|
||||
!payload.bucket_incarnation_id.is_nil()
|
||||
&& payload.marker_incarnation_id == payload.bucket_incarnation_id
|
||||
&& !payload.marker.is_empty()
|
||||
&& payload.marker.len() <= MRF_MAX_DELETE_MARKER_BYTES
|
||||
})
|
||||
.map(|payload| {
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: payload.bucket_incarnation_id,
|
||||
marker_incarnation_id: payload.marker_incarnation_id,
|
||||
marker_identity: payload.marker_identity,
|
||||
})
|
||||
})?,
|
||||
_ if intent.delete_marker_purge.is_none() => None,
|
||||
_ => return None,
|
||||
};
|
||||
let bucket_incarnation_id = delete_marker_purge
|
||||
.map(|identity| identity.bucket_incarnation_id)
|
||||
.unwrap_or(bucket_incarnation_id);
|
||||
if bucket_incarnation_id.is_nil() {
|
||||
return None;
|
||||
}
|
||||
Some(MrfDurableRepairAnchor {
|
||||
kind: intent.kind,
|
||||
bucket: intent.bucket.clone(),
|
||||
object: intent.object.clone(),
|
||||
version_id,
|
||||
scope,
|
||||
delete_marker_purge,
|
||||
lease,
|
||||
bucket_incarnation_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_repair_identity_matches_the_original_constructor_and_source_filter() {
|
||||
let source = Uuid::from_u128(1);
|
||||
let foreign = Uuid::from_u128(2);
|
||||
let purge = MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: source,
|
||||
marker_incarnation_id: source,
|
||||
marker_identity: [9; 32],
|
||||
marker: Arc::from([7_u8]),
|
||||
};
|
||||
let payloads = [
|
||||
None,
|
||||
Some(purge.clone()),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: foreign,
|
||||
marker_incarnation_id: foreign,
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker: Arc::from([]),
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker: Arc::from(vec![7; MRF_MAX_DELETE_MARKER_BYTES]),
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker: Arc::from(vec![7; MRF_MAX_DELETE_MARKER_BYTES + 1]),
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker_incarnation_id: foreign,
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: Uuid::nil(),
|
||||
marker_incarnation_id: Uuid::nil(),
|
||||
..purge
|
||||
}),
|
||||
];
|
||||
let mut cases = 0;
|
||||
for kind in [
|
||||
MrfKind::DecodeFailure,
|
||||
MrfKind::MetadataCorruption,
|
||||
MrfKind::PartialWrite,
|
||||
MrfKind::DeleteMarkerPurge,
|
||||
] {
|
||||
for lease in [None, Some(MrfIngressLease::new(0)), Some(MrfIngressLease::new(7))] {
|
||||
for version_id in [None, Some([0; 16]), Some([7; 16])] {
|
||||
for scope in [
|
||||
None,
|
||||
Some(MrfScope {
|
||||
pool_index: u32::MAX,
|
||||
set_index: 3,
|
||||
}),
|
||||
] {
|
||||
for input_source in [Uuid::nil(), source, foreign] {
|
||||
for (payload_index, payload) in payloads.iter().enumerate() {
|
||||
let intent = MrfIntent {
|
||||
bucket: Arc::from("source-shape"),
|
||||
object: Arc::from("object"),
|
||||
version_id,
|
||||
kind,
|
||||
delete_marker_purge: payload.clone(),
|
||||
scope,
|
||||
lease,
|
||||
enqueued_at_ms: 0,
|
||||
attempts: 0,
|
||||
};
|
||||
let expected = original_anchor(&intent, input_source);
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, input_source),
|
||||
expected,
|
||||
"kind={kind:?}, lease={lease:?}, version={version_id:?}, scope={scope:?}, source={input_source}, payload={payload_index}"
|
||||
);
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::is_eligible_for_source(&intent, input_source),
|
||||
expected
|
||||
.as_ref()
|
||||
.is_some_and(|anchor| anchor.bucket_incarnation_id == input_source),
|
||||
"source eligibility must preserve the exact source: kind={kind:?}, lease={lease:?}, version={version_id:?}, scope={scope:?}, source={input_source}, payload={payload_index}"
|
||||
);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(cases, 1728, "exercise the complete explicit identity-shape matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_identity_preserves_literal_purge_source_lease_and_metadata_boundaries() {
|
||||
let source = Uuid::from_u128(1);
|
||||
let purge_source = Uuid::from_u128(2);
|
||||
let mut intent = MrfIntent {
|
||||
bucket: Arc::from("literal-bucket"),
|
||||
object: Arc::from("marker"),
|
||||
version_id: None,
|
||||
kind: MrfKind::DeleteMarkerPurge,
|
||||
delete_marker_purge: Some(MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: purge_source,
|
||||
marker_incarnation_id: purge_source,
|
||||
marker_identity: [9; 32],
|
||||
marker: Arc::from([7_u8]),
|
||||
}),
|
||||
scope: None,
|
||||
lease: Some(MrfIngressLease::new(17)),
|
||||
enqueued_at_ms: 0,
|
||||
attempts: 0,
|
||||
};
|
||||
assert!(
|
||||
!valid_intent_payload(&intent),
|
||||
"constructor compatibility is broader than durable admission"
|
||||
);
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()),
|
||||
Some(MrfDurableRepairAnchor {
|
||||
kind: MrfKind::DeleteMarkerPurge,
|
||||
bucket: Arc::from("literal-bucket"),
|
||||
object: Arc::from("marker"),
|
||||
version_id: None,
|
||||
scope: None,
|
||||
delete_marker_purge: Some(MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: purge_source,
|
||||
marker_incarnation_id: purge_source,
|
||||
marker_identity: [9; 32],
|
||||
}),
|
||||
lease: MrfIngressLease::new(17),
|
||||
bucket_incarnation_id: purge_source,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!MrfDurableRepairAnchor::is_eligible_for_source(&intent, source),
|
||||
"a purge cannot borrow a conflicting entry source"
|
||||
);
|
||||
assert!(!MrfDurableRepairAnchor::is_eligible_for_source(&intent, Uuid::nil()));
|
||||
assert!(MrfDurableRepairAnchor::is_eligible_for_source(&intent, purge_source));
|
||||
intent.lease = None;
|
||||
assert!(MrfDurableRepairAnchor::from_intent(&intent, purge_source).is_none());
|
||||
assert!(!MrfDurableRepairAnchor::is_eligible_for_source(&intent, purge_source));
|
||||
intent.lease = Some(MrfIngressLease::new(17));
|
||||
intent
|
||||
.delete_marker_purge
|
||||
.as_mut()
|
||||
.expect("purge fixture")
|
||||
.marker_incarnation_id = Uuid::nil();
|
||||
assert!(MrfDurableRepairAnchor::from_intent(&intent, purge_source).is_none());
|
||||
intent
|
||||
.delete_marker_purge
|
||||
.as_mut()
|
||||
.expect("purge fixture")
|
||||
.bucket_incarnation_id = Uuid::nil();
|
||||
assert!(
|
||||
!MrfDurableRepairAnchor::is_eligible_for_source(&intent, source),
|
||||
"nil marker identity cannot borrow a non-nil source"
|
||||
);
|
||||
intent.kind = MrfKind::PartialWrite;
|
||||
assert!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, source).is_none(),
|
||||
"ordinary intents cannot carry a purge payload"
|
||||
);
|
||||
intent.delete_marker_purge = None;
|
||||
intent.kind = MrfKind::MetadataCorruption;
|
||||
intent.version_id = Some([7; 16]);
|
||||
intent.scope = Some(MrfScope {
|
||||
pool_index: 8,
|
||||
set_index: 9,
|
||||
});
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, source),
|
||||
Some(MrfDurableRepairAnchor {
|
||||
kind: MrfKind::MetadataCorruption,
|
||||
bucket: Arc::from("literal-bucket"),
|
||||
object: Arc::from("marker"),
|
||||
version_id: None,
|
||||
scope: None,
|
||||
delete_marker_purge: None,
|
||||
lease: MrfIngressLease::new(17),
|
||||
bucket_incarnation_id: source,
|
||||
})
|
||||
);
|
||||
assert!(MrfDurableRepairAnchor::is_eligible_for_source(&intent, source));
|
||||
assert!(MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()).is_none());
|
||||
intent.lease = Some(MrfIngressLease::new(0));
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, source)
|
||||
.expect("preserve the existing opaque lease shape")
|
||||
.lease,
|
||||
MrfIngressLease::new(0)
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user