@harperfast/harper 5.3.0-beta.4 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/backup.ts +6 -1
- package/components/Application.ts +486 -115
- package/components/DESIGN.md +109 -21
- package/components/deploymentProvenance.ts +28 -0
- package/components/operations.js +14 -7
- package/components/packageComponent.ts +5 -1
- package/config-root.schema.json +1 -1
- package/dataLayer/DESIGN.md +35 -7
- package/dataLayer/blobBackup.ts +71 -30
- package/dataLayer/harperBridge/ResourceBridge.ts +16 -2
- package/dataLayer/rocksdbBackup.ts +88 -34
- package/dataLayer/schemaDescribe.ts +45 -1
- package/dist/bin/backup.js +1 -1
- package/dist/bin/backup.js.map +1 -1
- package/dist/components/Application.d.ts +11 -21
- package/dist/components/Application.js +436 -105
- package/dist/components/Application.js.map +1 -1
- package/dist/components/deploymentProvenance.d.ts +4 -0
- package/dist/components/deploymentProvenance.js +32 -0
- package/dist/components/deploymentProvenance.js.map +1 -0
- package/dist/components/operations.js +14 -7
- package/dist/components/operations.js.map +1 -1
- package/dist/components/packageComponent.js +6 -1
- package/dist/components/packageComponent.js.map +1 -1
- package/dist/dataLayer/blobBackup.d.ts +30 -3
- package/dist/dataLayer/blobBackup.js +73 -36
- package/dist/dataLayer/blobBackup.js.map +1 -1
- package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +1 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js +15 -3
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/rocksdbBackup.d.ts +4 -1
- package/dist/dataLayer/rocksdbBackup.js +74 -36
- package/dist/dataLayer/rocksdbBackup.js.map +1 -1
- package/dist/dataLayer/schemaDescribe.js +45 -1
- package/dist/dataLayer/schemaDescribe.js.map +1 -1
- package/dist/resources/DatabaseTransaction.d.ts +1 -0
- package/dist/resources/DatabaseTransaction.js +33 -8
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +7 -3
- package/dist/resources/ResourceInterface.js +2 -0
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/Table.d.ts +151 -445
- package/dist/resources/Table.js +209 -8
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.d.ts +11 -0
- package/dist/resources/analytics/write.js +3 -0
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/blob.d.ts +30 -0
- package/dist/resources/blob.js +189 -1
- package/dist/resources/blob.js.map +1 -1
- package/dist/resources/databases.d.ts +1 -0
- package/dist/resources/databases.js +80 -15
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/derivedIndexRuntime.d.ts +13 -0
- package/dist/resources/derivedIndexRuntime.js +57 -5
- package/dist/resources/derivedIndexRuntime.js.map +1 -1
- package/dist/resources/derivedIndexes.d.ts +1 -0
- package/dist/resources/derivedIndexes.js +116 -15
- package/dist/resources/derivedIndexes.js.map +1 -1
- package/dist/resources/fullTextSchema.d.ts +5 -3
- package/dist/resources/fullTextSchema.js +48 -5
- package/dist/resources/fullTextSchema.js.map +1 -1
- package/dist/resources/fullTextSchemaLifecycle.d.ts +2 -0
- package/dist/resources/fullTextSchemaLifecycle.js +33 -0
- package/dist/resources/fullTextSchemaLifecycle.js.map +1 -1
- package/dist/resources/graphql.js +30 -12
- package/dist/resources/graphql.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +21 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +114 -5
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/indexes/fullTextDerivedIndex.d.ts +11 -2
- package/dist/resources/indexes/fullTextDerivedIndex.js +187 -24
- package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -1
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +99 -1
- package/dist/resources/indexes/fullTextNativeBinding.js +35 -1
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
- package/dist/resources/indexes/fullTextQueryIndex.d.ts +58 -0
- package/dist/resources/indexes/fullTextQueryIndex.js +943 -0
- package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -0
- package/dist/resources/indexes/fullTextQueryProtocol.d.ts +6 -0
- package/dist/resources/indexes/fullTextQueryProtocol.js +32 -0
- package/dist/resources/indexes/fullTextQueryProtocol.js.map +1 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +4 -1
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +30 -7
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -1
- package/dist/resources/search.d.ts +41 -0
- package/dist/resources/search.js +468 -26
- package/dist/resources/search.js.map +1 -1
- package/dist/resources/transactionBroadcast.js +101 -50
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/resources/typeParity.d.ts +9 -0
- package/dist/resources/typeParity.js +3 -0
- package/dist/resources/typeParity.js.map +1 -0
- package/dist/server/REST.js.map +1 -1
- package/dist/server/itc/serverHandlers.js +48 -8
- package/dist/server/itc/serverHandlers.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.d.ts +7 -0
- package/dist/server/serverHelpers/contentTypes.js +36 -23
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +28 -5
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/threads/itc.js +19 -0
- package/dist/server/threads/itc.js.map +1 -1
- package/dist/server/threads/manageThreads.d.ts +3 -0
- package/dist/server/threads/manageThreads.js +90 -23
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/utility/operation_authorization.js +25 -7
- package/dist/utility/operation_authorization.js.map +1 -1
- package/dist/utility/signalling.js +3 -0
- package/dist/utility/signalling.js.map +1 -1
- package/dist/validation/searchValidator.js +22 -2
- package/dist/validation/searchValidator.js.map +1 -1
- package/npm-shrinkwrap.json +22 -22
- package/package.json +2 -4
- package/resources/DESIGN.md +19 -2
- package/resources/DatabaseTransaction.ts +40 -10
- package/resources/FULL_TEXT.md +97 -0
- package/resources/ResourceInterface.ts +8 -2
- package/resources/Table.ts +767 -17
- package/resources/analytics/write.ts +3 -3
- package/resources/blob.ts +183 -1
- package/resources/databases.ts +126 -14
- package/resources/derivedIndexRuntime.ts +79 -6
- package/resources/derivedIndexes.ts +142 -18
- package/resources/fullTextSchema.ts +55 -9
- package/resources/fullTextSchemaLifecycle.ts +45 -0
- package/resources/graphql.ts +33 -12
- package/resources/indexes/DESIGN.md +167 -15
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +125 -5
- package/resources/indexes/fullTextDerivedIndex.ts +216 -32
- package/resources/indexes/fullTextNativeBinding.ts +133 -3
- package/resources/indexes/fullTextQueryIndex.ts +1120 -0
- package/resources/indexes/fullTextQueryProtocol.ts +35 -0
- package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +28 -8
- package/resources/search.ts +513 -26
- package/resources/transactionBroadcast.ts +86 -47
- package/resources/typeParity.ts +20 -0
- package/schema.graphql +18 -5
- package/server/DESIGN.md +18 -0
- package/server/REST.ts +1 -1
- package/server/itc/serverHandlers.js +61 -8
- package/server/serverHelpers/contentTypes.ts +31 -26
- package/server/serverHelpers/uwsServer.ts +35 -7
- package/server/threads/itc.js +21 -0
- package/server/threads/manageThreads.js +95 -21
- package/studio/web/assets/{Chat-Bq60AO-g.js → Chat-CNEKMllJ.js} +13 -13
- package/studio/web/assets/FloatingChat-CEnWhHeR.js +23 -0
- package/studio/web/assets/SearchDialog-CwhJdEdY.js +1 -0
- package/studio/web/assets/{abnfDiagram-O67JEVCF-DpPZWMOf.js → abnfDiagram-O67JEVCF-raXE027J.js} +1 -1
- package/studio/web/assets/alertDialog-BbWD9Xxa.js +1 -0
- package/studio/web/assets/apiToken-DC5vsaaO.js +1 -0
- package/studio/web/assets/applications-DQZxDy0x.js +296 -0
- package/studio/web/assets/architecture-7GRP2DOG-DDmz3hpI.js +1 -0
- package/studio/web/assets/{architectureDiagram-NJMV4G6O-OmgLraID.js → architectureDiagram-NJMV4G6O-C8_Eet7w.js} +1 -1
- package/studio/web/assets/arrow-up-D2g_57vT.js +1 -0
- package/studio/web/assets/authStore-CJCaADw9.js +3 -0
- package/studio/web/assets/{blockDiagram-BEXU5L5S-Cv7CD3Ai.js → blockDiagram-BEXU5L5S-DR6S5-1D.js} +1 -1
- package/studio/web/assets/{button-MFP9n8Ck.js → button-DWwfgGWI.js} +2 -2
- package/studio/web/assets/{c4Diagram-YGBWAQC7-Ds1vKYnN.js → c4Diagram-YGBWAQC7-BAfZ1G01.js} +1 -1
- package/studio/web/assets/channel-D0AkPjJ8.js +1 -0
- package/studio/web/assets/{chevron-up-CFe8x5Bp.js → chevron-up-CFxbMGv2.js} +1 -1
- package/studio/web/assets/{chunk-3FUC2YCW-DpnBPH8B.js → chunk-3FUC2YCW-DuRp-j19.js} +1 -1
- package/studio/web/assets/{chunk-5DYCD2WN-LLLCQSzg.js → chunk-5DYCD2WN-BceBfIHT.js} +1 -1
- package/studio/web/assets/{chunk-742MDFTN-BL8J_pIO.js → chunk-742MDFTN-CIkt8ijt.js} +1 -1
- package/studio/web/assets/{chunk-7INBJB4K-C-hJHiiK.js → chunk-7INBJB4K-BpeAXyOi.js} +1 -1
- package/studio/web/assets/{chunk-7M6MHVWA-C6ZqkFbA.js → chunk-7M6MHVWA-h6WlaTzO.js} +1 -1
- package/studio/web/assets/{chunk-7PRAP22T-CUXM8BG6.js → chunk-7PRAP22T-KyqX6x3f.js} +1 -1
- package/studio/web/assets/{chunk-GTNCS2PH-DJ2BFvS1.js → chunk-GTNCS2PH-DGHLXyow.js} +1 -1
- package/studio/web/assets/{chunk-GWA4HPMP-C42bSqLL.js → chunk-GWA4HPMP-DsejB6gA.js} +1 -1
- package/studio/web/assets/{chunk-MBY4JIJT-C-5jz6Lo.js → chunk-MBY4JIJT-BwSGweQP.js} +1 -1
- package/studio/web/assets/{chunk-NETBCI7D-D-bzlN4r.js → chunk-NETBCI7D-C8UnOw_f.js} +1 -1
- package/studio/web/assets/{chunk-O7XYJQB3-ep14zA23.js → chunk-O7XYJQB3-CBS3nZYV.js} +1 -1
- package/studio/web/assets/{chunk-UA2S7LBM-BMRWRHED.js → chunk-UA2S7LBM-BHpEdH8D.js} +1 -1
- package/studio/web/assets/{chunk-WEXAMYUT-DdLYVdX1.js → chunk-WEXAMYUT-BAIhTwyh.js} +1 -1
- package/studio/web/assets/{chunk-Z7XXMR3K-BmKEDjcP.js → chunk-Z7XXMR3K-DSHbImdf.js} +1 -1
- package/studio/web/assets/{chunk-ZIGJFQKS-DLZgdNxA.js → chunk-ZIGJFQKS-Cs8fUn7y.js} +1 -1
- package/studio/web/assets/{classDiagram-v2-NBCMYWYE-DOI5vgKT.js → classDiagram-v2-NBCMYWYE-DmDPkt2u.js} +1 -1
- package/studio/web/assets/cn-6T8VranJ.js +1 -0
- package/studio/web/assets/{codicon-Brq4_Ui5.ttf → codicon-DF1abBS2.ttf} +0 -0
- package/studio/web/assets/{createLucideIcon-bziYxJli.js → createLucideIcon-fO5dL7D5.js} +1 -1
- package/studio/web/assets/{css.worker-l1psR_wG.js → css.worker-DoT-OOzx.js} +36 -36
- package/studio/web/assets/{cssMode-DJyc9mHj.js → cssMode-Dz2DxF4e.js} +1 -1
- package/studio/web/assets/{cynefin-OW5HDTMX-BSW-DB7S.js → cynefin-OW5HDTMX-DIvbXLSj.js} +1 -1
- package/studio/web/assets/{cynefinDiagram-VND7K2PF-CbAHEwxt.js → cynefinDiagram-VND7K2PF-BdtaXiCE.js} +1 -1
- package/studio/web/assets/{dagre-6A5THRUB-Csxewgs4.js → dagre-6A5THRUB-C7RUWHyz.js} +1 -1
- package/studio/web/assets/{diagram-22UHCM2B-a0rt8tY4.js → diagram-22UHCM2B-DJCvcaWP.js} +1 -1
- package/studio/web/assets/{diagram-3UASUU5V-lEj2SVNh.js → diagram-3UASUU5V-Du6VKWln.js} +1 -1
- package/studio/web/assets/{diagram-ATOU4E4O-HcrAeB-q.js → diagram-ATOU4E4O-dxRfeG4B.js} +1 -1
- package/studio/web/assets/{diagram-CDSNMT55-CYxiuyEx.js → diagram-CDSNMT55-l5Ga0u-W.js} +1 -1
- package/studio/web/assets/{diagram-MLGK6HIB-ChbCJTgJ.js → diagram-MLGK6HIB-CNQY47LA.js} +1 -1
- package/studio/web/assets/{diagram-MPIPVDR6-DaX9Y-N3.js → diagram-MPIPVDR6-7prveD1B.js} +1 -1
- package/studio/web/assets/{dist-Cz2Y6-6G.js → dist-BrRuMUzt.js} +1 -1
- package/studio/web/assets/{download-CtostjYn.js → download-Cn6tPG4L.js} +1 -1
- package/studio/web/assets/{ebnfDiagram-ZINNZB2B-CwAsENB1.js → ebnfDiagram-ZINNZB2B-5OnkTpfi.js} +1 -1
- package/studio/web/assets/{editor-DDIsQfj6.js → editor-B3mjd3tz.js} +1 -1
- package/studio/web/assets/editor.worker-Yd1NR0QT.js +33 -0
- package/studio/web/assets/{elk-276RUBZZ-Hexg9E4D.js → elk-276RUBZZ-DxDQPjQT.js} +1 -1
- package/studio/web/assets/{erDiagram-OPXOYQCR-CeC-wwO1.js → erDiagram-OPXOYQCR-CQlCX84D.js} +1 -1
- package/studio/web/assets/eventmodeling-NTZA5JFV-DONQf5aG.js +1 -0
- package/studio/web/assets/flowDiagram-KWPJA3E3-BoZ8_QR1.js +1 -0
- package/studio/web/assets/{ganttDiagram-FUAMR5RP-C2_Hhl1-.js → ganttDiagram-FUAMR5RP-bloX3Voy.js} +1 -1
- package/studio/web/assets/{getAnalytics-CjthMQqF.js → getAnalytics-C0FHjtdQ.js} +1 -1
- package/studio/web/assets/{gitGraph-4MIJSDKK-xIk5Vuew.js → gitGraph-4MIJSDKK-BrXWmJt2.js} +1 -1
- package/studio/web/assets/{gitGraphDiagram-X574FWY7-CMxJ34up.js → gitGraphDiagram-X574FWY7-C2KnnZeY.js} +1 -1
- package/studio/web/assets/{html-g1BJHxLy.js → html-BsRGM7tp.js} +1 -1
- package/studio/web/assets/{html.worker-DdwM4IrA.js → html.worker-B1dwY0Ay.js} +27 -27
- package/studio/web/assets/{htmlMode-Vh5IglCQ.js → htmlMode-tUKJaA3o.js} +1 -1
- package/studio/web/assets/index-72cEH474.css +1 -0
- package/studio/web/assets/index-BuqFjN5K.js +828 -0
- package/studio/web/assets/index.lazy-CcpfQFY_.js +2 -0
- package/studio/web/assets/{info-A6RAGUB7-o49TEbj-.js → info-A6RAGUB7-BhzBHe4U.js} +1 -1
- package/studio/web/assets/{infoDiagram-VRGFBTTK-Cjhh7yzZ.js → infoDiagram-VRGFBTTK-B0J1132p.js} +1 -1
- package/studio/web/assets/{ishikawaDiagram-OU5B5YK6-C63aXRmh.js → ishikawaDiagram-OU5B5YK6-BNk0tuQx.js} +1 -1
- package/studio/web/assets/{javascript-5VIolqf7.js → javascript-BZ_uRGHl.js} +1 -1
- package/studio/web/assets/{journeyDiagram-ZHPQQLJL-D_o0P5Mu.js → journeyDiagram-ZHPQQLJL-Be79RMQM.js} +1 -1
- package/studio/web/assets/json.worker-BQqXGI3X.js +65 -0
- package/studio/web/assets/{jsonMode-DG4zQraI.js → jsonMode-DgB-aFMj.js} +1 -1
- package/studio/web/assets/{kanban-definition-PNTS6WVX-CTI9SSxY.js → kanban-definition-PNTS6WVX-4uuoQP9W.js} +1 -1
- package/studio/web/assets/{languageServices-t5FBpX-i.js → languageServices-BleuBw17.js} +1 -1
- package/studio/web/assets/localStorageKeys-etzFY_xV.js +1 -0
- package/studio/web/assets/{lspLanguageFeatures-DgVnNPh-.js → lspLanguageFeatures-ByVYLSsQ.js} +1 -1
- package/studio/web/assets/{mermaid-parser.core-C2lcH_jP.js → mermaid-parser.core-V1UYMeXV.js} +3 -3
- package/studio/web/assets/{mermaid.core-BG5bjfIG.js → mermaid.core-1PX6R49U.js} +4 -4
- package/studio/web/assets/{mindmap-definition-NLK3R4M7-BK7ho6JD.js → mindmap-definition-NLK3R4M7-C70Ctm0J.js} +1 -1
- package/studio/web/assets/notifications-D1Nf-Edn.js +1 -0
- package/studio/web/assets/notifications-TuzN0IMi.js +1 -0
- package/studio/web/assets/{packet-AYTQ26CC-BHPy6KBV.js → packet-AYTQ26CC-DWbXHx5g.js} +1 -1
- package/studio/web/assets/{pegDiagram-GJSIUBJH-DzjwG3jO.js → pegDiagram-GJSIUBJH-d6z1cc7W.js} +1 -1
- package/studio/web/assets/{pie-WAS4IAKB-D0cP-DTU.js → pie-WAS4IAKB-DjoTm3yr.js} +1 -1
- package/studio/web/assets/{pieDiagram-5QR66LMP-C_hOP9Lv.js → pieDiagram-5QR66LMP-Dz3RHpDA.js} +1 -1
- package/studio/web/assets/profile-D7C7Lupz.js +1 -0
- package/studio/web/assets/{quadrantDiagram-O4NWA36T-CZW8PbV1.js → quadrantDiagram-O4NWA36T-CIF5Qkou.js} +1 -1
- package/studio/web/assets/{radar-RG4KPBEZ-C8fk0U81.js → radar-RG4KPBEZ-CVBgjCOj.js} +1 -1
- package/studio/web/assets/{railroad-74A4TZTK-B60FWm17.js → railroad-74A4TZTK-BdhXw10Q.js} +1 -1
- package/studio/web/assets/railroad-abnf-HS5TGJTU-BcoQPN39.js +1 -0
- package/studio/web/assets/railroad-ebnf-LZEXJU2U-B1Lke2JN.js +1 -0
- package/studio/web/assets/railroad-peg-WCYAUIDC-D-oJFkiV.js +1 -0
- package/studio/web/assets/{railroadDiagram-XR7U4H2S-DyyNmgbU.js → railroadDiagram-XR7U4H2S-y3Jv2U6N.js} +1 -1
- package/studio/web/assets/regions-nGQCm-v8.js +1 -0
- package/studio/web/assets/register-C3x9Ze6P.js +909 -0
- package/studio/web/assets/register-DjpsE8to.css +1 -0
- package/studio/web/assets/{requirementDiagram-PLB6GJNP-C4H-mLKu.js → requirementDiagram-PLB6GJNP-ChINZEmu.js} +1 -1
- package/studio/web/assets/restartGate-nHAFLDh-.js +1 -0
- package/studio/web/assets/{sankeyDiagram-IPEJSGJF-DHDyga4c.js → sankeyDiagram-IPEJSGJF-D9AyLce2.js} +1 -1
- package/studio/web/assets/{sequenceDiagram-PO4LG4MO-CdNcHXRI.js → sequenceDiagram-PO4LG4MO-hfk4aIEm.js} +1 -1
- package/studio/web/assets/{setComponentFile-z4-qJCbA.js → setComponentFile-DpjrRqeG.js} +1 -1
- package/studio/web/assets/{setup-CS50uJ4x.js → setup-CGiKLZ9e.js} +2 -2
- package/studio/web/assets/{stateDiagram-v2-GCMORJYK-CpKuXq15.js → stateDiagram-v2-GCMORJYK-Bs8RIb9K.js} +1 -1
- package/studio/web/assets/status-CGJwrNPH.js +61 -0
- package/studio/web/assets/{swimlanes-2SLR337P-NRHa2D8I.js → swimlanes-2SLR337P-B52xrf6t.js} +1 -1
- package/studio/web/assets/swimlanesDiagram-TC7HE7FX-DfN7BAP5.js +8 -0
- package/studio/web/assets/tabs-BCTQzoQK.js +1 -0
- package/studio/web/assets/{timeline-definition-EJHVYXUP-D39Y-xpq.js → timeline-definition-EJHVYXUP-DJSITpQX.js} +1 -1
- package/studio/web/assets/toggleHighContrast-C-Ne6P7T.js +51 -0
- package/studio/web/assets/toggleHighContrast-n7clI0si.css +1 -0
- package/studio/web/assets/{treeView-Q6P3EWNA-CRWR9OqD.js → treeView-Q6P3EWNA-C-epdxRo.js} +1 -1
- package/studio/web/assets/{treemap-WGGIJYW6-ZfXyJgLO.js → treemap-WGGIJYW6-DOVyp_zw.js} +1 -1
- package/studio/web/assets/{ts.worker-DDMUrpqh.js → ts.worker-Clm12uY6.js} +131 -131
- package/studio/web/assets/{tsMode-D83Gg3cq.js → tsMode-CSreYTys.js} +1 -1
- package/studio/web/assets/{typescript-Dfp-GrNq.js → typescript-MnxBk89W.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-BKetlINf.js → useEntityRestURL-TNrh77-s.js} +1 -1
- package/studio/web/assets/{usecaseDiagram-POWQR4AR-jf-zIDZ_.js → usecaseDiagram-POWQR4AR-B6252cbq.js} +1 -1
- package/studio/web/assets/{vendor-core-BdkdbUek.js → vendor-core-6LhfJlRC.js} +1 -1
- package/studio/web/assets/vendor-datadog-ozKf-pts.js +6 -0
- package/studio/web/assets/{vendor-react-izWchTRL.js → vendor-react-BOliS0BL.js} +1 -1
- package/studio/web/assets/vendor-tanstack-Bntfka-M.js +2 -0
- package/studio/web/assets/vendor-ui-9EYYDgoN.js +41 -0
- package/studio/web/assets/{vennDiagram-UO4OBE2U-BKYAZUh_.js → vennDiagram-UO4OBE2U-DZTsDA4k.js} +1 -1
- package/studio/web/assets/{wardley-WFR3VGLG-BO7f36ci.js → wardley-WFR3VGLG-Cfqoe6z4.js} +1 -1
- package/studio/web/assets/{wardleyDiagram-VNRHLVJA-Djb0JDQc.js → wardleyDiagram-VNRHLVJA-qu_f7Tvl.js} +1 -1
- package/studio/web/assets/{workers-CCvmDLH3.js → workers-BfZlUNVI.js} +3 -3
- package/studio/web/assets/{xml-06pZp00U.js → xml-BHd6qGH7.js} +1 -1
- package/studio/web/assets/{xychartDiagram-PMCCYNJV-BQh9ubvb.js → xychartDiagram-PMCCYNJV-UrOKRsmR.js} +1 -1
- package/studio/web/assets/{yaml-iSnWMVlV.js → yaml-C53R8OQq.js} +1 -1
- package/studio/web/assets/{yaml.worker-DAK2o_H1.js → yaml.worker-D9JekGdp.js} +183 -183
- package/studio/web/index.html +15 -15
- package/utility/operation_authorization.ts +27 -7
- package/utility/signalling.ts +3 -0
- package/validation/searchValidator.ts +22 -13
- package/studio/web/assets/FloatingChat-D4560T0p.js +0 -23
- package/studio/web/assets/alertDialog-UrbiMgQt.js +0 -1
- package/studio/web/assets/apiToken-BEvbFPMc.js +0 -1
- package/studio/web/assets/applications-CSUVTH4E.js +0 -296
- package/studio/web/assets/architecture-7GRP2DOG-11l4Ny1l.js +0 -1
- package/studio/web/assets/authStore-5jSS5pTW.js +0 -3
- package/studio/web/assets/channel-BDtouPd6.js +0 -1
- package/studio/web/assets/dialog-DTepachC.js +0 -1
- package/studio/web/assets/editor.worker-CkkvdXvO.js +0 -33
- package/studio/web/assets/eventmodeling-NTZA5JFV-CFb03e0o.js +0 -1
- package/studio/web/assets/flowDiagram-KWPJA3E3-Cidi00_Y.js +0 -1
- package/studio/web/assets/index-CPoDzQTe.css +0 -1
- package/studio/web/assets/index-DGRAzvO4.js +0 -824
- package/studio/web/assets/index.lazy-vgSic7et.js +0 -2
- package/studio/web/assets/json.worker-CSi2hF-a.js +0 -65
- package/studio/web/assets/notifications-BeWcX04m.js +0 -1
- package/studio/web/assets/notifications-DUGWrWoO.js +0 -1
- package/studio/web/assets/profile-BvUEDBzW.js +0 -1
- package/studio/web/assets/railroad-abnf-HS5TGJTU-CLMVZvH0.js +0 -1
- package/studio/web/assets/railroad-ebnf-LZEXJU2U--tlwFoVB.js +0 -1
- package/studio/web/assets/railroad-peg-WCYAUIDC-Dpuslblm.js +0 -1
- package/studio/web/assets/regions-CB-_2m7M.js +0 -1
- package/studio/web/assets/register-1w1Zr3b2.js +0 -848
- package/studio/web/assets/register-jjEx9u7D.css +0 -1
- package/studio/web/assets/setLocalStorage-D_kflv4U.js +0 -1
- package/studio/web/assets/status-CI0wOdOE.js +0 -61
- package/studio/web/assets/swimlanesDiagram-TC7HE7FX-BBk4t4hO.js +0 -8
- package/studio/web/assets/tabs-CnQ_zenf.js +0 -1
- package/studio/web/assets/toggleHighContrast--ixms01u.css +0 -1
- package/studio/web/assets/toggleHighContrast-BsAE4jPe.js +0 -49
- package/studio/web/assets/useLocalStorage-BYoZF60G.js +0 -1
- package/studio/web/assets/vendor-datadog-fCQhBHGk.js +0 -6
- package/studio/web/assets/vendor-tanstack-CQ1aRjVa.js +0 -2
- package/studio/web/assets/vendor-ui-aE1tHcW7.js +0 -41
- package/studio/web/assets/x-vYntCxm4.js +0 -1
|
@@ -32,6 +32,11 @@ import {
|
|
|
32
32
|
} from './gitCredentialServer.ts';
|
|
33
33
|
import { getSecretDecryptor } from '../resources/secretDecryptor.ts';
|
|
34
34
|
import { ENV_ENCRYPTED_PREFIX } from '../utility/envFile.ts';
|
|
35
|
+
import {
|
|
36
|
+
DEPLOYMENT_PROVENANCE_FILE,
|
|
37
|
+
formatDeploymentProvenance,
|
|
38
|
+
parseDeploymentProvenance,
|
|
39
|
+
} from './deploymentProvenance.ts';
|
|
35
40
|
|
|
36
41
|
import { basename, dirname, extname, isAbsolute, join, relative, sep, win32 } from 'node:path';
|
|
37
42
|
import {
|
|
@@ -52,6 +57,7 @@ import {
|
|
|
52
57
|
rm,
|
|
53
58
|
stat,
|
|
54
59
|
symlink,
|
|
60
|
+
utimes,
|
|
55
61
|
writeFile,
|
|
56
62
|
} from 'node:fs/promises';
|
|
57
63
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
@@ -60,7 +66,7 @@ import {
|
|
|
60
66
|
type WindowsProcessTreeIdentity,
|
|
61
67
|
} from '../server/threads/windowsProcessTree.ts';
|
|
62
68
|
import { tmpdir } from 'node:os';
|
|
63
|
-
import { randomUUID } from 'node:crypto';
|
|
69
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
64
70
|
import { chmodSync, createReadStream, existsSync, lstatSync, renameSync } from 'node:fs';
|
|
65
71
|
import { Readable } from 'node:stream';
|
|
66
72
|
import { pipeline } from 'node:stream/promises';
|
|
@@ -1087,6 +1093,11 @@ const ACTIVATION_JOURNAL_VERSION = 2;
|
|
|
1087
1093
|
const LEGACY_ACTIVATION_JOURNAL_VERSION = 1;
|
|
1088
1094
|
const ARTIFACT_DESCRIPTOR_VERSION = 1;
|
|
1089
1095
|
const DEFAULT_STAGING_RETENTION_MAX_COUNT = 5;
|
|
1096
|
+
// A digest of the component, not its name, follows the uuid in a claim's name: a long name would pass the filename
|
|
1097
|
+
// limit.
|
|
1098
|
+
const CLAIMING_PREFIX = '.claiming-';
|
|
1099
|
+
const CLAIMING_UUID_LENGTH = 36;
|
|
1100
|
+
const CLAIM_OWNER_DIGEST_LENGTH = 16;
|
|
1090
1101
|
|
|
1091
1102
|
/** `deployment_stagingRetention_maxCount`; 0 keeps none. Only a number or numeric string counts, so `true`/`[]`/blank cannot become "keep nothing". */
|
|
1092
1103
|
export function getStagingRetentionMaxCount(): number {
|
|
@@ -1103,6 +1114,10 @@ type DormantBuild = {
|
|
|
1103
1114
|
completedAt: number;
|
|
1104
1115
|
};
|
|
1105
1116
|
|
|
1117
|
+
function isDeploymentDirectoryEntry(entry: import('node:fs').Dirent): boolean {
|
|
1118
|
+
return entry.isDirectory() && !entry.name.startsWith('.');
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1106
1121
|
async function presentOrAbsent(path: string): Promise<import('node:fs').Stats | undefined> {
|
|
1107
1122
|
return lstat(path).catch((error: NodeJS.ErrnoException) => {
|
|
1108
1123
|
if (error?.code === 'ENOENT') return undefined;
|
|
@@ -1131,20 +1146,24 @@ async function dormantBuildAt(deploymentDirPath: string, owner: string): Promise
|
|
|
1131
1146
|
* cannot hold or miss a slot. Never throws: a failure must neither fail a component closed nor replace a
|
|
1132
1147
|
* deploy's own error.
|
|
1133
1148
|
*
|
|
1134
|
-
* `
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
* the eviction tail leaves
|
|
1138
|
-
* not pin it
|
|
1149
|
+
* A `pinned` id is never evicted. A delayed activation runs this preamble under the same lock it is about to
|
|
1150
|
+
* activate under, so without the pin retention would delete the artifact the request named — immediately, when the
|
|
1151
|
+
* knob is `0` — and a settlement's pins keep the releases it just put back. Pins are applied after the kept set is
|
|
1152
|
+
* chosen, so each pinned build in the eviction tail leaves one more than `maxCount` on disk until a pass that does
|
|
1153
|
+
* not pin it.
|
|
1139
1154
|
*/
|
|
1140
1155
|
export async function pruneDormantBuilds(
|
|
1141
1156
|
componentName: string,
|
|
1142
1157
|
builds: DormantBuild[],
|
|
1143
1158
|
maxCount: number,
|
|
1144
|
-
|
|
1159
|
+
pinned?: string | readonly string[]
|
|
1145
1160
|
): Promise<void> {
|
|
1161
|
+
const pins = new Set(typeof pinned === 'string' ? [pinned] : (pinned ?? []));
|
|
1146
1162
|
const current: DormantBuild[] = [];
|
|
1163
|
+
const seen = new Set<string>();
|
|
1147
1164
|
for (const build of builds) {
|
|
1165
|
+
if (seen.has(build.deploymentDirPath)) continue;
|
|
1166
|
+
seen.add(build.deploymentDirPath);
|
|
1148
1167
|
try {
|
|
1149
1168
|
const fresh = await dormantBuildAt(build.deploymentDirPath, componentName);
|
|
1150
1169
|
if (fresh && !(await presentOrAbsent(join(build.deploymentDirPath, ACTIVATION_JOURNAL)))) current.push(fresh);
|
|
@@ -1162,7 +1181,7 @@ export async function pruneDormantBuilds(
|
|
|
1162
1181
|
(left.deploymentId < right.deploymentId ? -1 : left.deploymentId > right.deploymentId ? 1 : 0)
|
|
1163
1182
|
)
|
|
1164
1183
|
.slice(Math.max(0, maxCount))
|
|
1165
|
-
.filter((build) => build.deploymentId
|
|
1184
|
+
.filter((build) => !pins.has(build.deploymentId));
|
|
1166
1185
|
for (const build of evictions) {
|
|
1167
1186
|
try {
|
|
1168
1187
|
await rm(build.deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
@@ -1191,7 +1210,7 @@ async function dormantBuildsOf(componentsRootDirPath: string, componentName: str
|
|
|
1191
1210
|
}
|
|
1192
1211
|
const builds: DormantBuild[] = [];
|
|
1193
1212
|
for (const deployment of deployments) {
|
|
1194
|
-
if (!deployment
|
|
1213
|
+
if (!isDeploymentDirectoryEntry(deployment)) continue;
|
|
1195
1214
|
const deploymentDirPath = join(stagingRoot, deployment.name);
|
|
1196
1215
|
try {
|
|
1197
1216
|
if ((await candidateComponentName(deploymentDirPath)) !== componentName) continue;
|
|
@@ -1522,6 +1541,162 @@ async function writeArtifactDescriptor(
|
|
|
1522
1541
|
}
|
|
1523
1542
|
}
|
|
1524
1543
|
|
|
1544
|
+
async function writeDeploymentProvenance(
|
|
1545
|
+
candidateDirPath: string,
|
|
1546
|
+
componentName: string,
|
|
1547
|
+
deploymentId: string
|
|
1548
|
+
): Promise<void> {
|
|
1549
|
+
const markerPath = join(candidateDirPath, DEPLOYMENT_PROVENANCE_FILE);
|
|
1550
|
+
await rm(markerPath, { recursive: true, force: true });
|
|
1551
|
+
await writeControlFileDurably(markerPath, formatDeploymentProvenance(componentName, deploymentId));
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* A link carries no provenance, whatever its target holds. A read that fails propagates rather than reading as
|
|
1556
|
+
* absent.
|
|
1557
|
+
*/
|
|
1558
|
+
async function readDeploymentProvenance(treePath: string, componentName: string): Promise<string | undefined> {
|
|
1559
|
+
const tree = await presentOrAbsent(treePath);
|
|
1560
|
+
if (!tree?.isDirectory()) return undefined;
|
|
1561
|
+
const raw = await readFile(join(treePath, DEPLOYMENT_PROVENANCE_FILE), 'utf8').catch(
|
|
1562
|
+
(error: NodeJS.ErrnoException) => {
|
|
1563
|
+
if (error?.code === 'ENOENT' || error?.code === 'EISDIR') return undefined;
|
|
1564
|
+
throw error;
|
|
1565
|
+
}
|
|
1566
|
+
);
|
|
1567
|
+
return raw === undefined ? undefined : parseDeploymentProvenance(raw, componentName);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
async function isDeploymentRecord(deploymentDirPath: string, componentName: string): Promise<boolean> {
|
|
1571
|
+
if ((await candidateComponentName(deploymentDirPath)) !== componentName) return false;
|
|
1572
|
+
if (!(await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER)))) return false;
|
|
1573
|
+
for (const unexpected of [ACTIVATION_JOURNAL, UNSETTLED_MARKER, componentName]) {
|
|
1574
|
+
if (await presentOrAbsent(join(deploymentDirPath, unexpected))) return false;
|
|
1575
|
+
}
|
|
1576
|
+
try {
|
|
1577
|
+
return (await readArtifactDescriptor(deploymentDirPath, componentName)) !== undefined;
|
|
1578
|
+
} catch (error) {
|
|
1579
|
+
if (error instanceof ClientError) return false;
|
|
1580
|
+
throw error;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* "Not live" does not make a record stale: a crash between the commit and the re-home leaves the displaced tree in
|
|
1586
|
+
* `.deploy-aside`, retired or not, and a scan can reach the record before the journal that re-homes into it.
|
|
1587
|
+
*/
|
|
1588
|
+
async function deploymentRecordIsReferenced(
|
|
1589
|
+
componentsRootDirPath: string,
|
|
1590
|
+
componentName: string,
|
|
1591
|
+
deploymentId: string
|
|
1592
|
+
): Promise<boolean> {
|
|
1593
|
+
const liveDirPath = join(componentsRootDirPath, componentName);
|
|
1594
|
+
if ((await readDeploymentProvenance(liveDirPath, componentName)) === deploymentId) return true;
|
|
1595
|
+
const asideStagingDir = extractionStagingDirectory(liveDirPath);
|
|
1596
|
+
const entries = await readdir(asideStagingDir, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
|
1597
|
+
if (error?.code === 'ENOENT') return [];
|
|
1598
|
+
throw error;
|
|
1599
|
+
});
|
|
1600
|
+
for (const entry of entries) {
|
|
1601
|
+
if (!entry.isDirectory() || !entry.name.startsWith(IN_PROGRESS_ASIDE_PREFIX)) continue;
|
|
1602
|
+
if ((await readDeploymentProvenance(join(asideStagingDir, entry.name), componentName)) === deploymentId) {
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
return false;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
async function isReferencedDeploymentRecord(
|
|
1610
|
+
componentsRootDirPath: string,
|
|
1611
|
+
deploymentDirPath: string,
|
|
1612
|
+
owner: string
|
|
1613
|
+
): Promise<boolean> {
|
|
1614
|
+
for (const expected of [CANDIDATE_COMPLETE_MARKER, CANDIDATE_ARTIFACT_FILE]) {
|
|
1615
|
+
if (!(await presentOrAbsent(join(deploymentDirPath, expected)))) return false;
|
|
1616
|
+
}
|
|
1617
|
+
if (await presentOrAbsent(join(deploymentDirPath, owner))) return false;
|
|
1618
|
+
return deploymentRecordIsReferenced(componentsRootDirPath, owner, basename(deploymentDirPath));
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/** A failed read keeps the directory: a leftover record costs a few bytes, a lost one costs the release. */
|
|
1622
|
+
async function keepsDeploymentRecord(deploymentDirPath: string): Promise<boolean> {
|
|
1623
|
+
return presentOrAbsent(join(deploymentDirPath, CANDIDATE_ARTIFACT_FILE)).then(Boolean, () => true);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/** Retention orders by `.complete`'s mtime, and a kept release has to outrank stages nobody activated. */
|
|
1627
|
+
async function refreshCompletedAt(deploymentDirPath: string, componentName: string, deploymentId: string) {
|
|
1628
|
+
for (let attempt = 1; ; attempt++) {
|
|
1629
|
+
const now = new Date();
|
|
1630
|
+
try {
|
|
1631
|
+
await utimes(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER), now, now);
|
|
1632
|
+
return;
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
if (attempt >= 3) {
|
|
1635
|
+
logger.warn(
|
|
1636
|
+
`Kept deployment ${deploymentId} of ${componentName}, but retention may evict it ahead of older ` +
|
|
1637
|
+
`builds: ${errorMessage(error)}`
|
|
1638
|
+
);
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
await delay(100 * attempt);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
type DisplacedReleaseRetention = { verdict: 'kept'; deploymentId: string } | { verdict: 'ineligible' | 'failed' };
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Only an explicit retention decision deletes a displaced tree: `failed` leaves it for the next preparation to retry.
|
|
1650
|
+
* A failure after the rename propagates, as the aside syncs around it do.
|
|
1651
|
+
*/
|
|
1652
|
+
async function retainDisplacedRelease(
|
|
1653
|
+
componentsRootDirPath: string,
|
|
1654
|
+
componentName: string,
|
|
1655
|
+
displacedTreePath: string
|
|
1656
|
+
): Promise<DisplacedReleaseRetention> {
|
|
1657
|
+
const keepNone = getStagingRetentionMaxCount() === 0;
|
|
1658
|
+
let record: { deploymentId: string; recordDirPath: string } | undefined;
|
|
1659
|
+
try {
|
|
1660
|
+
const deploymentId = await readDeploymentProvenance(displacedTreePath, componentName);
|
|
1661
|
+
if (deploymentId !== undefined) {
|
|
1662
|
+
const recordDirPath = join(componentsRootDirPath, DEPLOY_STAGING_DIR, deploymentId);
|
|
1663
|
+
if (await isDeploymentRecord(recordDirPath, componentName)) record = { deploymentId, recordDirPath };
|
|
1664
|
+
}
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
if (keepNone) return { verdict: 'ineligible' };
|
|
1667
|
+
logger.warn(
|
|
1668
|
+
`Could not tell whether the previous release of ${componentName} at ${displacedTreePath} can be kept; ` +
|
|
1669
|
+
`leaving it for its next deploy:`,
|
|
1670
|
+
errorForLog(error)
|
|
1671
|
+
);
|
|
1672
|
+
return { verdict: 'failed' };
|
|
1673
|
+
}
|
|
1674
|
+
if (!record) return { verdict: 'ineligible' };
|
|
1675
|
+
const { deploymentId, recordDirPath } = record;
|
|
1676
|
+
if (keepNone) {
|
|
1677
|
+
// Nothing will ever put the tree back into its record, so the record goes with it.
|
|
1678
|
+
await rm(recordDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
|
|
1679
|
+
logger.warn(`Could not remove the record of deployment ${deploymentId} of ${componentName}:`, errorForLog(error))
|
|
1680
|
+
);
|
|
1681
|
+
return { verdict: 'ineligible' };
|
|
1682
|
+
}
|
|
1683
|
+
const keptTreePath = join(recordDirPath, componentName);
|
|
1684
|
+
try {
|
|
1685
|
+
await renameThroughTransientHolder(displacedTreePath, keptTreePath);
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
logger.warn(
|
|
1688
|
+
`Could not keep the previous release of ${componentName} as deployment ${deploymentId}; leaving it for ` +
|
|
1689
|
+
`its next deploy:`,
|
|
1690
|
+
errorForLog(error)
|
|
1691
|
+
);
|
|
1692
|
+
return { verdict: 'failed' };
|
|
1693
|
+
}
|
|
1694
|
+
await syncRenameParents(displacedTreePath, keptTreePath);
|
|
1695
|
+
await refreshCompletedAt(recordDirPath, componentName, deploymentId);
|
|
1696
|
+
logger.trace?.(`Kept the previous release of ${componentName} as dormant deployment ${deploymentId}`);
|
|
1697
|
+
return { verdict: 'kept', deploymentId };
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1525
1700
|
/**
|
|
1526
1701
|
* A component name safe to join onto the components root: no separator, no traversal, not dot-prefixed.
|
|
1527
1702
|
* Applied to EVERY source of the name — the journal and the sidecar — because validating one and trusting
|
|
@@ -1707,103 +1882,127 @@ async function ensureSecureStagingDirectory(stagingDir: string): Promise<void> {
|
|
|
1707
1882
|
}
|
|
1708
1883
|
|
|
1709
1884
|
/**
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
* directory would let a replayed stage rewrite the bytes under an existing `.complete` and descriptor —
|
|
1713
|
-
* and a crash mid-rebuild would leave a partial tree that still reads as certified.
|
|
1714
|
-
*
|
|
1715
|
-
* The caller holds the component's preparation lock, which is what makes the EEXIST verdicts sound: no
|
|
1716
|
-
* other preparation of THIS component is running, and a directory belonging to another component is not
|
|
1717
|
-
* this lock's to touch.
|
|
1885
|
+
* Exclusive, because an operator can repeat a public id; built aside and renamed onto it, so it never appears
|
|
1886
|
+
* unattributed. The caller's preparation lock is what makes the verdicts on an existing directory sound.
|
|
1718
1887
|
*/
|
|
1719
1888
|
async function claimDeploymentDirectory(deploymentDirPath: string, componentName: string): Promise<void> {
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1889
|
+
const claimDirPath = join(
|
|
1890
|
+
dirname(deploymentDirPath),
|
|
1891
|
+
`${CLAIMING_PREFIX}${randomUUID()}-${claimOwnerDigest(componentName)}`
|
|
1892
|
+
);
|
|
1893
|
+
await mkdir(claimDirPath, { mode: 0o700 });
|
|
1723
1894
|
try {
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
if (
|
|
1729
|
-
|
|
1730
|
-
`
|
|
1731
|
-
`names one artifact for its lifetime`
|
|
1895
|
+
const claimed = await lstat(claimDirPath);
|
|
1896
|
+
if (!claimed.isDirectory() || claimed.isSymbolicLink()) {
|
|
1897
|
+
throw new Error(`Component deploy staging path is not a directory: ${claimDirPath}`);
|
|
1898
|
+
}
|
|
1899
|
+
if (process.platform !== 'win32' && (claimed.mode & 0o777) !== 0o700) {
|
|
1900
|
+
await chmod(claimDirPath, 0o700).catch((error) =>
|
|
1901
|
+
logger.warn(`Could not restrict component deploy staging permissions for ${claimDirPath}:`, errorForLog(error))
|
|
1732
1902
|
);
|
|
1733
1903
|
}
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
throw taken(
|
|
1753
|
-
`Deployment id ${basename(deploymentDirPath)} already holds a completed build of '${componentName}'; ` +
|
|
1754
|
-
`deploy it with deployment_id, or deploy again to build a new one`
|
|
1904
|
+
await publishClaimOwnership(claimDirPath, componentName);
|
|
1905
|
+
for (let attempt = 1; ; attempt++) {
|
|
1906
|
+
// Checked first: Windows refuses a rename onto an existing directory with a transient holder's EPERM.
|
|
1907
|
+
if (!(await presentOrAbsent(deploymentDirPath))) {
|
|
1908
|
+
try {
|
|
1909
|
+
await renameThroughTransientHolder(claimDirPath, deploymentDirPath);
|
|
1910
|
+
return;
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
// Only this component's lock can remove the claim, so a vanished source means the rename landed.
|
|
1913
|
+
if (!(await presentOrAbsent(claimDirPath))) return;
|
|
1914
|
+
if (!(await presentOrAbsent(deploymentDirPath))) throw error;
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
if (attempt >= MAX_CLAIM_ATTEMPTS) {
|
|
1918
|
+
throw new ClientError(
|
|
1919
|
+
`Deployment id ${basename(deploymentDirPath)} was claimed again each time it was freed; deploying ` +
|
|
1920
|
+
`again without a deployment_id mints a fresh id`,
|
|
1921
|
+
409
|
|
1755
1922
|
);
|
|
1756
1923
|
}
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1924
|
+
await makeRoomForClaim(deploymentDirPath, componentName);
|
|
1925
|
+
}
|
|
1926
|
+
} finally {
|
|
1927
|
+
// A claim that did not land names no id, so it is nobody else's.
|
|
1928
|
+
await rm(claimDirPath, { recursive: true, force: true }).catch(() => {});
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const MAX_CLAIM_ATTEMPTS = 3;
|
|
1933
|
+
|
|
1934
|
+
/** Every refusal is a conflict the caller resolves by naming another id: a 409, not a server fault. */
|
|
1935
|
+
async function makeRoomForClaim(deploymentDirPath: string, componentName: string): Promise<void> {
|
|
1936
|
+
const taken = (message: string) => new ClientError(message, 409);
|
|
1937
|
+
const deploymentId = basename(deploymentDirPath);
|
|
1938
|
+
const owner = await candidateComponentName(deploymentDirPath);
|
|
1939
|
+
if (owner !== undefined && owner !== componentName) {
|
|
1940
|
+
throw taken(
|
|
1941
|
+
`Deployment id ${deploymentId} already holds a build of '${owner}'; a deployment id names one artifact for ` +
|
|
1942
|
+
`its lifetime`
|
|
1943
|
+
);
|
|
1944
|
+
}
|
|
1945
|
+
if (owner === undefined) {
|
|
1946
|
+
// Claims appear already attributed, so an EMPTY directory is only ever one a build created before naming it,
|
|
1947
|
+
// abandoned; `rmdir` removes nothing else.
|
|
1948
|
+
try {
|
|
1949
|
+
await rmdir(deploymentDirPath);
|
|
1950
|
+
return;
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
1953
|
+
if (code === 'ENOENT') return;
|
|
1954
|
+
if (code !== 'ENOTEMPTY' && code !== 'EEXIST') throw error;
|
|
1761
1955
|
}
|
|
1956
|
+
throw taken(
|
|
1957
|
+
`Deployment id ${deploymentId} is held by a directory that names no component. Remove it once you have ` +
|
|
1958
|
+
`determined it holds nothing you need; deploying again without a deployment_id mints a fresh id`
|
|
1959
|
+
);
|
|
1762
1960
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1961
|
+
if (await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER))) {
|
|
1962
|
+
throw taken(
|
|
1963
|
+
`Deployment id ${deploymentId} already holds a completed build of '${componentName}'; deploy it with ` +
|
|
1964
|
+
`deployment_id, or deploy again to build a new one`
|
|
1965
|
+
);
|
|
1766
1966
|
}
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1967
|
+
// Nothing certified it, and this component's lock serializes its claims.
|
|
1968
|
+
await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
function claimOwnerDigest(componentName: string): string {
|
|
1972
|
+
return createHash('sha256').update(componentName).digest('hex').slice(0, CLAIM_OWNER_DIGEST_LENGTH);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
function isClaimOf(entryName: string, componentName: string): boolean {
|
|
1976
|
+
return (
|
|
1977
|
+
entryName.startsWith(CLAIMING_PREFIX) &&
|
|
1978
|
+
entryName.slice(CLAIMING_PREFIX.length + CLAIMING_UUID_LENGTH + 1) === claimOwnerDigest(componentName)
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/** The caller holds the component's preparation lock, so none of its claims is in flight. */
|
|
1983
|
+
async function sweepAbandonedClaims(stagingRoot: string, entries: import('node:fs').Dirent[], componentName: string) {
|
|
1984
|
+
for (const entry of entries) {
|
|
1985
|
+
if (!entry.isDirectory() || !isClaimOf(entry.name, componentName)) continue;
|
|
1986
|
+
await rm(join(stagingRoot, entry.name), { recursive: true, force: true }).catch((error) =>
|
|
1987
|
+
logger.warn(`Could not remove the abandoned deploy claim ${entry.name}:`, errorForLog(error))
|
|
1773
1988
|
);
|
|
1774
1989
|
}
|
|
1775
|
-
await publishClaimOwnership(deploymentDirPath, componentName);
|
|
1776
1990
|
}
|
|
1777
1991
|
|
|
1778
1992
|
/**
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
1781
|
-
*
|
|
1782
|
-
* Ownership is published as part of the claim rather than at certification, because resolving and packing can
|
|
1783
|
-
* take minutes and until a tree exists to infer an owner from, a directory answering to nobody is one another
|
|
1784
|
-
* component will take for abandoned. Unattributed is a permanent refusal, though, so a failure here — a full
|
|
1785
|
-
* disk, an EIO on the temp write or its sync — would burn this deployment id for good, every retry refused by
|
|
1786
|
-
* its own wreckage. The removal is scoped to the directory this invocation made, and deliberately not
|
|
1787
|
-
* broadened to one found by EEXIST: that directory may be another claimant's, and removing it is the race the
|
|
1788
|
-
* refusal exists to prevent. Best-effort, because the claim failure is what the caller needs to see.
|
|
1789
|
-
*
|
|
1790
|
-
* `write` is a parameter so the failure is testable without a filesystem that can be made to fail on exactly
|
|
1791
|
-
* this write and nothing else.
|
|
1993
|
+
* The claim names no id yet, so a failure burns nothing. `write` is a parameter so the failure is testable without a
|
|
1994
|
+
* filesystem that can be made to fail on exactly this write.
|
|
1792
1995
|
*/
|
|
1793
1996
|
export async function publishClaimOwnership(
|
|
1794
|
-
|
|
1997
|
+
claimDirPath: string,
|
|
1795
1998
|
componentName: string,
|
|
1796
1999
|
write: (filePath: string, contents: string) => Promise<void> = writeControlFileDurably
|
|
1797
2000
|
): Promise<void> {
|
|
1798
2001
|
try {
|
|
1799
|
-
await write(join(
|
|
2002
|
+
await write(join(claimDirPath, CANDIDATE_COMPONENT_FILE), componentName);
|
|
1800
2003
|
} catch (error) {
|
|
1801
|
-
await rm(
|
|
1802
|
-
logger.warn(
|
|
1803
|
-
`Could not remove the deployment directory ${deploymentDirPath} after failing to publish its ` +
|
|
1804
|
-
`ownership; the id stays unusable until it is removed:`,
|
|
1805
|
-
errorForLog(cleanupError)
|
|
1806
|
-
)
|
|
2004
|
+
await rm(claimDirPath, { recursive: true, force: true }).catch((cleanupError) =>
|
|
2005
|
+
logger.warn(`Could not remove the unfinished deploy claim ${claimDirPath}:`, errorForLog(cleanupError))
|
|
1807
2006
|
);
|
|
1808
2007
|
throw error;
|
|
1809
2008
|
}
|
|
@@ -1869,7 +2068,7 @@ async function journaledDeploymentForComponent(
|
|
|
1869
2068
|
throw error;
|
|
1870
2069
|
}
|
|
1871
2070
|
for (const deployment of deployments) {
|
|
1872
|
-
if (!deployment
|
|
2071
|
+
if (!isDeploymentDirectoryEntry(deployment)) continue;
|
|
1873
2072
|
const deploymentDirPath = join(stagingRoot, deployment.name);
|
|
1874
2073
|
const journalPath = join(deploymentDirPath, ACTIVATION_JOURNAL);
|
|
1875
2074
|
// NOTHING is swallowed here. This is the gate that authorizes restoring an old tree over what may be
|
|
@@ -1987,9 +2186,11 @@ async function settleStagingForComponent(
|
|
|
1987
2186
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
1988
2187
|
throw error;
|
|
1989
2188
|
}
|
|
2189
|
+
await sweepAbandonedClaims(stagingRoot, deployments, componentName);
|
|
1990
2190
|
const dormant: DormantBuild[] = [];
|
|
2191
|
+
const kept: string[] = [];
|
|
1991
2192
|
for (const deployment of deployments) {
|
|
1992
|
-
if (!deployment
|
|
2193
|
+
if (!isDeploymentDirectoryEntry(deployment)) continue;
|
|
1993
2194
|
const deploymentDirPath = join(stagingRoot, deployment.name);
|
|
1994
2195
|
// Ownership BEFORE parsing. Reading every journal first meant a truncated journal belonging to another
|
|
1995
2196
|
// component threw here — blocking the deploy of a healthy component because an unrelated one is
|
|
@@ -2034,10 +2235,33 @@ async function settleStagingForComponent(
|
|
|
2034
2235
|
// component's own unsettled activation in place while a new deploy proceeded over it, and an
|
|
2035
2236
|
// activation interrupted before B1 has no rollback record for the restore gate to catch.
|
|
2036
2237
|
if (journal.component !== componentName) continue;
|
|
2037
|
-
await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, journal);
|
|
2238
|
+
kept.push(...(await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, journal)));
|
|
2038
2239
|
}
|
|
2240
|
+
await catalogueKeptReleases(stagingRoot, componentName, kept, dormant);
|
|
2039
2241
|
const maxCount = getStagingRetentionMaxCount();
|
|
2040
|
-
if (dormant.length > maxCount)
|
|
2242
|
+
if (dormant.length > maxCount) {
|
|
2243
|
+
await pruneDormantBuilds(componentName, dormant, maxCount, [
|
|
2244
|
+
...kept,
|
|
2245
|
+
...(pinnedDeploymentId ? [pinnedDeploymentId] : []),
|
|
2246
|
+
]);
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/** Counted and pinned, or the pass a settlement ran inside can miss what it kept, or evict it after a failed refresh. */
|
|
2251
|
+
async function catalogueKeptReleases(
|
|
2252
|
+
stagingRoot: string,
|
|
2253
|
+
componentName: string,
|
|
2254
|
+
kept: string[],
|
|
2255
|
+
into: DormantBuild[]
|
|
2256
|
+
): Promise<void> {
|
|
2257
|
+
const present = new Set(into.map((build) => build.deploymentDirPath));
|
|
2258
|
+
for (const deploymentId of kept) {
|
|
2259
|
+
const build = await dormantBuildAt(join(stagingRoot, deploymentId), componentName).catch(() => undefined);
|
|
2260
|
+
if (build && !present.has(build.deploymentDirPath)) {
|
|
2261
|
+
present.add(build.deploymentDirPath);
|
|
2262
|
+
into.push(build);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2041
2265
|
}
|
|
2042
2266
|
|
|
2043
2267
|
/**
|
|
@@ -2064,7 +2288,7 @@ export async function unsettleableComponentsFromDisk(componentsRootDirPath: stri
|
|
|
2064
2288
|
throw error;
|
|
2065
2289
|
}
|
|
2066
2290
|
for (const deployment of deployments) {
|
|
2067
|
-
if (!deployment
|
|
2291
|
+
if (!isDeploymentDirectoryEntry(deployment)) continue;
|
|
2068
2292
|
const deploymentDirPath = join(stagingRoot, deployment.name);
|
|
2069
2293
|
// Per deployment. `candidateComponentName` propagates every non-ENOENT error now, and this pass runs
|
|
2070
2294
|
// where the CALLER only warns — so one unreadable deployment escaping here would drop the verdict for
|
|
@@ -2152,12 +2376,20 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
2152
2376
|
const dormant = new Map<string, DormantBuild[]>();
|
|
2153
2377
|
const catalogue = (owner: string, build: DormantBuild) => {
|
|
2154
2378
|
const builds = dormant.get(owner);
|
|
2155
|
-
if (builds)
|
|
2156
|
-
else
|
|
2379
|
+
if (!builds) dormant.set(owner, [build]);
|
|
2380
|
+
else if (!builds.some((existing) => existing.deploymentDirPath === build.deploymentDirPath)) builds.push(build);
|
|
2381
|
+
};
|
|
2382
|
+
const keptBy = new Map<string, string[]>();
|
|
2383
|
+
const keptDuring = async (owner: string, kept: string[]) => {
|
|
2384
|
+
if (kept.length === 0) return;
|
|
2385
|
+
keptBy.set(owner, [...(keptBy.get(owner) ?? []), ...kept]);
|
|
2386
|
+
const builds: DormantBuild[] = [];
|
|
2387
|
+
await catalogueKeptReleases(stagingRoot, owner, kept, builds);
|
|
2388
|
+
for (const build of builds) catalogue(owner, build);
|
|
2157
2389
|
};
|
|
2158
2390
|
|
|
2159
2391
|
for (const deployment of deployments) {
|
|
2160
|
-
if (!deployment
|
|
2392
|
+
if (!isDeploymentDirectoryEntry(deployment)) continue;
|
|
2161
2393
|
const deploymentDirPath = join(stagingRoot, deployment.name);
|
|
2162
2394
|
const journalPath = join(deploymentDirPath, ACTIVATION_JOURNAL);
|
|
2163
2395
|
const fail = (component: string, error: unknown) => recordUnsettled(failures, component, error, deploymentDirPath);
|
|
@@ -2214,7 +2446,10 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
2214
2446
|
throw splitAttributionError(deploymentDirPath, appeared.component, splitNames[0]);
|
|
2215
2447
|
}
|
|
2216
2448
|
activationToFail = appeared.component;
|
|
2217
|
-
await
|
|
2449
|
+
await keptDuring(
|
|
2450
|
+
appeared.component,
|
|
2451
|
+
await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, appeared)
|
|
2452
|
+
);
|
|
2218
2453
|
// Settled. Anything that fails after this — releasing the lock, say — is not this
|
|
2219
2454
|
// activation's, and attributing it here would fail a correctly settled component closed.
|
|
2220
2455
|
activationToFail = undefined;
|
|
@@ -2258,6 +2493,8 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
2258
2493
|
// Cleared, and still not a retainable build — an incomplete or treeless staged directory. That
|
|
2259
2494
|
// is residue like any other, so it falls through to the removal below.
|
|
2260
2495
|
}
|
|
2496
|
+
// Re-derived under the lock too: the release may have been re-homed, or displaced, since the scan.
|
|
2497
|
+
if (await isReferencedDeploymentRecord(componentsRootDirPath, deploymentDirPath, owner!)) return;
|
|
2261
2498
|
// Cleanup, not settlement. There was no activation here — this is most often the residue a
|
|
2262
2499
|
// SUCCESSFUL settlement leaves when its own sweep failed — so a sweep that fails again cannot
|
|
2263
2500
|
// make anything unsettled, and recording it would refuse a live component on every worker
|
|
@@ -2299,6 +2536,22 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
2299
2536
|
catalogue(owner, build);
|
|
2300
2537
|
continue;
|
|
2301
2538
|
}
|
|
2539
|
+
// The record of a release that is live, or displaced and not yet put back, is left alone without the
|
|
2540
|
+
// lock for the same reason. A read that fails leaves it in place: deleting it is the irreversible side.
|
|
2541
|
+
try {
|
|
2542
|
+
if (
|
|
2543
|
+
!(await presentOrAbsent(join(deploymentDirPath, UNSETTLED_MARKER))) &&
|
|
2544
|
+
(await isReferencedDeploymentRecord(componentsRootDirPath, deploymentDirPath, owner))
|
|
2545
|
+
) {
|
|
2546
|
+
continue;
|
|
2547
|
+
}
|
|
2548
|
+
} catch (error) {
|
|
2549
|
+
logger.warn(
|
|
2550
|
+
`Leaving deploy staging ${deploymentDirPath} in place; it could not be read:`,
|
|
2551
|
+
errorForLog(error)
|
|
2552
|
+
);
|
|
2553
|
+
continue;
|
|
2554
|
+
}
|
|
2302
2555
|
}
|
|
2303
2556
|
// Scoped to THIS deployment, like the journaled branch below: a lock timeout or an EIO here used to
|
|
2304
2557
|
// abort the entire scan, leaving every later deployment unsettled and unmarked.
|
|
@@ -2352,7 +2605,7 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
2352
2605
|
continue;
|
|
2353
2606
|
}
|
|
2354
2607
|
const settling = journal;
|
|
2355
|
-
await withComponentPreparationLock(
|
|
2608
|
+
const kept = await withComponentPreparationLock(
|
|
2356
2609
|
join(componentsRootDirPath, settling.component),
|
|
2357
2610
|
() => settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, settling),
|
|
2358
2611
|
{
|
|
@@ -2361,6 +2614,7 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
2361
2614
|
isOwnerAlive: (lockOwner) => lockOwner.pid !== process.pid || isThreadRunning(lockOwner.threadId),
|
|
2362
2615
|
}
|
|
2363
2616
|
);
|
|
2617
|
+
await keptDuring(settling.component, kept);
|
|
2364
2618
|
} catch (error) {
|
|
2365
2619
|
// The journal named its component, so attribution is exact however the settle failed.
|
|
2366
2620
|
await fail(journal.component, error);
|
|
@@ -2369,7 +2623,14 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
2369
2623
|
|
|
2370
2624
|
const maxCount = getStagingRetentionMaxCount();
|
|
2371
2625
|
for (const [owner, builds] of dormant) {
|
|
2372
|
-
|
|
2626
|
+
const pinned = keptBy.get(owner) ?? [];
|
|
2627
|
+
for (const [component, error] of await reconcileDormantBuilds(
|
|
2628
|
+
componentsRootDirPath,
|
|
2629
|
+
owner,
|
|
2630
|
+
builds,
|
|
2631
|
+
maxCount,
|
|
2632
|
+
pinned
|
|
2633
|
+
)) {
|
|
2373
2634
|
if (!failures.has(component)) failures.set(component, error);
|
|
2374
2635
|
}
|
|
2375
2636
|
}
|
|
@@ -2409,7 +2670,8 @@ export async function reconcileDormantBuilds(
|
|
|
2409
2670
|
componentsRootDirPath: string,
|
|
2410
2671
|
owner: string,
|
|
2411
2672
|
builds: DormantBuild[],
|
|
2412
|
-
maxCount: number
|
|
2673
|
+
maxCount: number,
|
|
2674
|
+
pinned: readonly string[] = []
|
|
2413
2675
|
): Promise<Map<string, Error>> {
|
|
2414
2676
|
const failures = new Map<string, Error>();
|
|
2415
2677
|
let journaled: DormantBuild | undefined;
|
|
@@ -2430,6 +2692,7 @@ export async function reconcileDormantBuilds(
|
|
|
2430
2692
|
join(componentsRootDirPath, owner),
|
|
2431
2693
|
async () => {
|
|
2432
2694
|
const stillDormant: DormantBuild[] = [];
|
|
2695
|
+
const keptHere: string[] = [];
|
|
2433
2696
|
for (const build of builds) {
|
|
2434
2697
|
let journal: ActivationJournal | undefined;
|
|
2435
2698
|
try {
|
|
@@ -2451,12 +2714,16 @@ export async function reconcileDormantBuilds(
|
|
|
2451
2714
|
continue;
|
|
2452
2715
|
}
|
|
2453
2716
|
try {
|
|
2454
|
-
await settleInterruptedActivation(componentsRootDirPath, build.deploymentDirPath, journal);
|
|
2717
|
+
const kept = await settleInterruptedActivation(componentsRootDirPath, build.deploymentDirPath, journal);
|
|
2718
|
+
keptHere.push(...kept);
|
|
2719
|
+
await catalogueKeptReleases(join(componentsRootDirPath, DEPLOY_STAGING_DIR), owner, kept, stillDormant);
|
|
2455
2720
|
} catch (error) {
|
|
2456
2721
|
await recordUnsettled(failures, journal.component, error, build.deploymentDirPath);
|
|
2457
2722
|
}
|
|
2458
2723
|
}
|
|
2459
|
-
if (stillDormant.length > maxCount)
|
|
2724
|
+
if (stillDormant.length > maxCount) {
|
|
2725
|
+
await pruneDormantBuilds(owner, stillDormant, maxCount, [...pinned, ...keptHere]);
|
|
2726
|
+
}
|
|
2460
2727
|
},
|
|
2461
2728
|
{
|
|
2462
2729
|
purpose: 'activation-recovery',
|
|
@@ -2493,7 +2760,8 @@ async function sweepAsideRecords(
|
|
|
2493
2760
|
componentName: string,
|
|
2494
2761
|
liveDirPath: string,
|
|
2495
2762
|
asideStagingDir: string
|
|
2496
|
-
): Promise<
|
|
2763
|
+
): Promise<string[]> {
|
|
2764
|
+
const kept: string[] = [];
|
|
2497
2765
|
for (const record of records) {
|
|
2498
2766
|
// RETIRING IS CORRECTNESS, not hygiene: the retired marker is what stops the legacy pass treating this
|
|
2499
2767
|
// record as authoritative and restoring the displaced tree over the candidate that was just rolled
|
|
@@ -2501,6 +2769,11 @@ async function sweepAsideRecords(
|
|
|
2501
2769
|
// the inversion this protocol exists to prevent, so a failure here PROPAGATES — the caller keeps the
|
|
2502
2770
|
// journal and the next start retries.
|
|
2503
2771
|
const retiredMarkerPath = await retireExtractionAside(record);
|
|
2772
|
+
// No prune here: settlement can run inside another request's preamble, whose pin it cannot see. A tree that
|
|
2773
|
+
// could not be kept stays retired in the aside for the legacy pass to retry.
|
|
2774
|
+
const retention = await retainDisplacedRelease(dirname(liveDirPath), componentName, record);
|
|
2775
|
+
if (retention.verdict === 'kept') kept.push(retention.deploymentId);
|
|
2776
|
+
if (retention.verdict === 'failed') continue;
|
|
2504
2777
|
// Sweeping the displaced tree is hygiene: it bounds disk, and a failure costs space rather than
|
|
2505
2778
|
// correctness, so it is logged. The retired marker above already makes the record non-authoritative.
|
|
2506
2779
|
await cleanupExtractionPaths(
|
|
@@ -2509,6 +2782,7 @@ async function sweepAsideRecords(
|
|
|
2509
2782
|
new Set([record, retiredMarkerPath])
|
|
2510
2783
|
).catch((error) => logger.warn(`Settled ${componentName} but could not sweep ${record}:`, errorForLog(error)));
|
|
2511
2784
|
}
|
|
2785
|
+
return kept;
|
|
2512
2786
|
}
|
|
2513
2787
|
|
|
2514
2788
|
/**
|
|
@@ -2543,7 +2817,7 @@ async function settleInterruptedActivation(
|
|
|
2543
2817
|
componentsRootDirPath: string,
|
|
2544
2818
|
deploymentDirPath: string,
|
|
2545
2819
|
journal: ActivationJournal
|
|
2546
|
-
): Promise<
|
|
2820
|
+
): Promise<string[]> {
|
|
2547
2821
|
const liveDirPath = join(componentsRootDirPath, journal.component);
|
|
2548
2822
|
const candidateDirPath = join(deploymentDirPath, journal.component);
|
|
2549
2823
|
const asideStagingDir = extractionStagingDirectory(liveDirPath);
|
|
@@ -2562,7 +2836,10 @@ async function settleInterruptedActivation(
|
|
|
2562
2836
|
const candidateComplete = await exists(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER));
|
|
2563
2837
|
const asideRecords = await inProgressAsideRecords(asideStagingDir);
|
|
2564
2838
|
|
|
2839
|
+
let rolledForward = false;
|
|
2840
|
+
let kept: string[] = [];
|
|
2565
2841
|
const rollForward = async () => {
|
|
2842
|
+
rolledForward = true;
|
|
2566
2843
|
if (!liveExists) await renameThroughTransientHolder(candidateDirPath, liveDirPath);
|
|
2567
2844
|
// Unconditional, not only when THIS pass performed the rename: a crash after normal activation
|
|
2568
2845
|
// renamed the candidate but before it repaired the links leaves live present with stale targets, and
|
|
@@ -2577,7 +2854,7 @@ async function settleInterruptedActivation(
|
|
|
2577
2854
|
// this roll-forward just displaced. Failing the component closed and retrying at the next start is
|
|
2578
2855
|
// the cheaper mistake — the journal survives, so the verdict is re-derivable. Only the disk sweep
|
|
2579
2856
|
// inside is best-effort.
|
|
2580
|
-
await sweepAsideRecords(asideRecords, journal.component, liveDirPath, asideStagingDir);
|
|
2857
|
+
kept = await sweepAsideRecords(asideRecords, journal.component, liveDirPath, asideStagingDir);
|
|
2581
2858
|
};
|
|
2582
2859
|
const rollBack = async (restoreFrom?: string) => {
|
|
2583
2860
|
if (restoreFrom) {
|
|
@@ -2666,7 +2943,7 @@ async function settleInterruptedActivation(
|
|
|
2666
2943
|
`Returned the staged build ${basename(deploymentDirPath)} of ${journal.component} to dormant after an ` +
|
|
2667
2944
|
`activation that never moved its live tree aside`
|
|
2668
2945
|
);
|
|
2669
|
-
return;
|
|
2946
|
+
return kept;
|
|
2670
2947
|
}
|
|
2671
2948
|
await rollBack();
|
|
2672
2949
|
} else {
|
|
@@ -2688,7 +2965,7 @@ async function settleInterruptedActivation(
|
|
|
2688
2965
|
`Settled the interrupted activation of ${journal.component} but could not flush its rollback record; ` +
|
|
2689
2966
|
`leaving the journal for the next start: ${errorMessage(error)}`
|
|
2690
2967
|
);
|
|
2691
|
-
return;
|
|
2968
|
+
return kept;
|
|
2692
2969
|
}
|
|
2693
2970
|
// Best-effort, matching the activation path: the activation is settled by this point, so a transient
|
|
2694
2971
|
// EBUSY removing staging must not throw out of the recovery pass and take the other components with it.
|
|
@@ -2699,10 +2976,12 @@ async function settleInterruptedActivation(
|
|
|
2699
2976
|
await rm(journalPath, { force: true }).catch((error) =>
|
|
2700
2977
|
logger.warn(`Settled ${journal.component} but could not remove its activation journal:`, errorForLog(error))
|
|
2701
2978
|
);
|
|
2979
|
+
if (rolledForward && (await keepsDeploymentRecord(deploymentDirPath))) return kept;
|
|
2702
2980
|
await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
|
|
2703
2981
|
logger.warn(`Settled ${journal.component} but could not clean up its staging directory:`, errorForLog(error))
|
|
2704
2982
|
);
|
|
2705
2983
|
await rmdir(dirname(deploymentDirPath)).catch(() => {});
|
|
2984
|
+
return kept;
|
|
2706
2985
|
}
|
|
2707
2986
|
|
|
2708
2987
|
/** Mark a candidate build+validation complete. Idempotent, so a retried activation is not a failure. */
|
|
@@ -3042,14 +3321,20 @@ export async function activateCandidateApplication(
|
|
|
3042
3321
|
}
|
|
3043
3322
|
const settledRecord = asidePath ?? priorAbsentRecordPath!;
|
|
3044
3323
|
let retired = false;
|
|
3324
|
+
let keptDeploymentId: string | undefined;
|
|
3045
3325
|
// Skipped entirely when the swap is not known to be on storage, so the journal below survives.
|
|
3046
3326
|
if (swapDurable) {
|
|
3047
3327
|
try {
|
|
3048
3328
|
const retiredMarkerPath = await retireExtractionAside(settledRecord);
|
|
3049
|
-
//
|
|
3050
|
-
//
|
|
3051
|
-
|
|
3052
|
-
|
|
3329
|
+
// A tree nothing can keep is swept, or the components root grows by a component version per deploy; one
|
|
3330
|
+
// whose keep FAILED stays retired here for a retry.
|
|
3331
|
+
const retention = asidePath
|
|
3332
|
+
? await retainDisplacedRelease(dirname(liveDirPath), application.name, asidePath)
|
|
3333
|
+
: ({ verdict: 'ineligible' } as const);
|
|
3334
|
+
if (retention.verdict === 'kept') keptDeploymentId = retention.deploymentId;
|
|
3335
|
+
if (retention.verdict !== 'failed') {
|
|
3336
|
+
await cleanupExtractionPaths(application, asideStagingDir, new Set([settledRecord, retiredMarkerPath]));
|
|
3337
|
+
}
|
|
3053
3338
|
// Before the journal goes: if the journal's removal persists but the record's does not, startup sees
|
|
3054
3339
|
// an in-progress aside with no journal and the legacy pass restores the old tree over the new one.
|
|
3055
3340
|
await syncDirectory(asideStagingDir);
|
|
@@ -3065,10 +3350,23 @@ export async function activateCandidateApplication(
|
|
|
3065
3350
|
await rm(journalPath, { force: true }).catch((error) =>
|
|
3066
3351
|
application.logger.warn(`Deployed ${application.name} but could not remove its activation journal:`, error)
|
|
3067
3352
|
);
|
|
3068
|
-
await
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3353
|
+
if (!(await keepsDeploymentRecord(deploymentDirPath))) {
|
|
3354
|
+
await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
|
|
3355
|
+
application.logger.warn(`Deployed ${application.name} but could not clean up its staging directory:`, error)
|
|
3356
|
+
);
|
|
3357
|
+
await rmdir(dirname(deploymentDirPath)).catch(() => {});
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
// Only here, where the request's own artifact is already live: a prune inside settlement could evict the
|
|
3361
|
+
// artifact a request's preamble pinned. Best-effort, like every prune.
|
|
3362
|
+
if (keptDeploymentId !== undefined) {
|
|
3363
|
+
const maxCount = getStagingRetentionMaxCount();
|
|
3364
|
+
try {
|
|
3365
|
+
const builds = await dormantBuildsOf(dirname(liveDirPath), application.name);
|
|
3366
|
+
if (builds.length > maxCount) await pruneDormantBuilds(application.name, builds, maxCount, keptDeploymentId);
|
|
3367
|
+
} catch (error) {
|
|
3368
|
+
application.logger.warn(`Deployed ${application.name} but could not bound its kept releases:`, error);
|
|
3369
|
+
}
|
|
3072
3370
|
}
|
|
3073
3371
|
}
|
|
3074
3372
|
|
|
@@ -3276,6 +3574,10 @@ export async function buildCandidateApplication(
|
|
|
3276
3574
|
// deployer's git token. `prepareApplication`'s finally still calls this; it is idempotent.
|
|
3277
3575
|
await application.cleanupGitCredentialSession();
|
|
3278
3576
|
await installApplication(application, candidateDirPath);
|
|
3577
|
+
// After the install, which can rewrite the tree; never through a link, whose target is not the deploy's to write.
|
|
3578
|
+
if (resolved.kind !== 'link') {
|
|
3579
|
+
await writeDeploymentProvenance(candidateDirPath, application.name, deploymentId);
|
|
3580
|
+
}
|
|
3279
3581
|
return candidateDirPath;
|
|
3280
3582
|
} catch (error) {
|
|
3281
3583
|
await discardCandidate(application, deploymentId);
|
|
@@ -3344,6 +3646,22 @@ async function recoverOrCleanupStaleExtractionPaths(
|
|
|
3344
3646
|
);
|
|
3345
3647
|
return;
|
|
3346
3648
|
}
|
|
3649
|
+
// Each retired tree left here is a displaced release: kept if it can be — the retry of a keep that failed — and
|
|
3650
|
+
// swept only when it cannot.
|
|
3651
|
+
for (const entry of entries) {
|
|
3652
|
+
if (!entry.isDirectory() || !entry.name.startsWith(IN_PROGRESS_ASIDE_PREFIX)) continue;
|
|
3653
|
+
const treePath = join(asideStagingDir, entry.name);
|
|
3654
|
+
const retention = await retainDisplacedRelease(dirname(application.dirPath), application.name, treePath).catch(
|
|
3655
|
+
(error) => {
|
|
3656
|
+
application.logger.warn(`Could not keep the previous release at ${treePath}; leaving it in place:`, error);
|
|
3657
|
+
return { verdict: 'failed' } as const;
|
|
3658
|
+
}
|
|
3659
|
+
);
|
|
3660
|
+
if (retention.verdict === 'failed') {
|
|
3661
|
+
paths.delete(treePath);
|
|
3662
|
+
paths.delete(retiredMarkerForAside(treePath));
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3347
3665
|
await cleanupExtractionPaths(application, asideStagingDir, paths);
|
|
3348
3666
|
}
|
|
3349
3667
|
|
|
@@ -3558,9 +3876,11 @@ export async function retireComponentDirectory(
|
|
|
3558
3876
|
asideStagingDir,
|
|
3559
3877
|
new Set([droppedPath])
|
|
3560
3878
|
);
|
|
3561
|
-
// A dropped component has no next deploy to bound its dormant builds
|
|
3879
|
+
// A dropped component has no next deploy to bound its dormant builds, and nothing left for its records to
|
|
3880
|
+
// describe.
|
|
3562
3881
|
try {
|
|
3563
3882
|
await pruneDormantBuilds(componentName, await dormantBuildsOf(dirname(componentDirPath), componentName), 0);
|
|
3883
|
+
await reclaimDeploymentRecordsOf(dirname(componentDirPath), componentName);
|
|
3564
3884
|
} catch (error) {
|
|
3565
3885
|
componentLogger.warn(
|
|
3566
3886
|
`Dropped ${componentName} but could not reclaim its dormant staged builds:`,
|
|
@@ -3571,6 +3891,31 @@ export async function retireComponentDirectory(
|
|
|
3571
3891
|
};
|
|
3572
3892
|
}
|
|
3573
3893
|
|
|
3894
|
+
/** Journaled directories stay: an activation nobody settled is evidence, not litter. */
|
|
3895
|
+
async function reclaimDeploymentRecordsOf(componentsRootDirPath: string, componentName: string): Promise<void> {
|
|
3896
|
+
const stagingRoot = join(componentsRootDirPath, DEPLOY_STAGING_DIR);
|
|
3897
|
+
const entries = await readdir(stagingRoot, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
|
3898
|
+
if (error?.code === 'ENOENT') return [];
|
|
3899
|
+
throw error;
|
|
3900
|
+
});
|
|
3901
|
+
await sweepAbandonedClaims(stagingRoot, entries, componentName);
|
|
3902
|
+
for (const entry of entries) {
|
|
3903
|
+
if (!isDeploymentDirectoryEntry(entry)) continue;
|
|
3904
|
+
const deploymentDirPath = join(stagingRoot, entry.name);
|
|
3905
|
+
try {
|
|
3906
|
+
if ((await candidateComponentName(deploymentDirPath)) !== componentName) continue;
|
|
3907
|
+
if (await presentOrAbsent(join(deploymentDirPath, ACTIVATION_JOURNAL))) continue;
|
|
3908
|
+
if (await presentOrAbsent(join(deploymentDirPath, componentName))) continue;
|
|
3909
|
+
await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
3910
|
+
} catch (error) {
|
|
3911
|
+
logger.warn(
|
|
3912
|
+
`Could not reclaim the deployment record ${deploymentDirPath} of ${componentName}:`,
|
|
3913
|
+
errorForLog(error)
|
|
3914
|
+
);
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3574
3919
|
async function cleanupExtractionPaths(
|
|
3575
3920
|
application: ExtractionContext,
|
|
3576
3921
|
asideStagingDir: string,
|
|
@@ -4188,6 +4533,7 @@ export class Application {
|
|
|
4188
4533
|
isNewComponent: boolean = true;
|
|
4189
4534
|
packageMetadataChanged: boolean = false;
|
|
4190
4535
|
installationIsOpaque: boolean = false;
|
|
4536
|
+
alreadyActive: boolean = false;
|
|
4191
4537
|
|
|
4192
4538
|
constructor({ name, payload, packageIdentifier, install, onInstallLine, credentials }: ApplicationOptions) {
|
|
4193
4539
|
this.name = name;
|
|
@@ -4419,6 +4765,15 @@ export async function prepareApplication(application: Application, options: Prep
|
|
|
4419
4765
|
}
|
|
4420
4766
|
));
|
|
4421
4767
|
if (mode === 'activate') {
|
|
4768
|
+
// A retry of an activation that swapped here and failed later or elsewhere: answered without a swap so
|
|
4769
|
+
// it can go on to the nodes still holding the artifact. Nothing can tell whether the running workers
|
|
4770
|
+
// loaded it, hence the restart request.
|
|
4771
|
+
if ((await readDeploymentProvenance(application.dirPath, application.name)) === artifactId) {
|
|
4772
|
+
application.alreadyActive = true;
|
|
4773
|
+
application.packageMetadataChanged = true;
|
|
4774
|
+
application.logger.debug?.(`Deployment ${artifactId} is already live; nothing to swap`);
|
|
4775
|
+
return;
|
|
4776
|
+
}
|
|
4422
4777
|
await activateStagedArtifact(application, artifactId, previousPackageMetadata, options);
|
|
4423
4778
|
return;
|
|
4424
4779
|
}
|
|
@@ -4466,8 +4821,22 @@ export async function prepareApplication(application: Application, options: Prep
|
|
|
4466
4821
|
await syncArtifactAncestors(candidateDeploymentDirPath(application.dirPath, artifactId));
|
|
4467
4822
|
return;
|
|
4468
4823
|
}
|
|
4469
|
-
await markCandidateComplete(application.dirPath, artifactId, application.name);
|
|
4470
4824
|
const declared = options.describeArtifact?.();
|
|
4825
|
+
// The directory outlives the swap as the record a later displacement puts this release back into;
|
|
4826
|
+
// a link has no bytes of its own to put back.
|
|
4827
|
+
const described = declared && !(await lstat(candidateDirPath)).isSymbolicLink() ? declared : undefined;
|
|
4828
|
+
if (described) {
|
|
4829
|
+
await writeArtifactDescriptor(application.dirPath, artifactId, {
|
|
4830
|
+
v: ARTIFACT_DESCRIPTOR_VERSION,
|
|
4831
|
+
component: application.name,
|
|
4832
|
+
rootConfig: described.rootConfig,
|
|
4833
|
+
installationIsOpaque: application.installationIsOpaque,
|
|
4834
|
+
isolated: described.isolated,
|
|
4835
|
+
});
|
|
4836
|
+
}
|
|
4837
|
+
await markCandidateComplete(application.dirPath, artifactId, application.name);
|
|
4838
|
+
// The record is meant to outlive the swap, so its own entry has to be on storage, as a stage's is.
|
|
4839
|
+
if (described) await syncArtifactAncestors(candidateDeploymentDirPath(application.dirPath, artifactId));
|
|
4471
4840
|
await activateCandidateApplication(application, artifactId, {
|
|
4472
4841
|
rootConfig: declared ? rootConfigEffectFromDeclaration(declared.rootConfig) : { kind: 'keep' },
|
|
4473
4842
|
});
|
|
@@ -4551,11 +4920,13 @@ async function activateStagedArtifact(
|
|
|
4551
4920
|
throw unusable('an activation of it is unsettled');
|
|
4552
4921
|
}
|
|
4553
4922
|
const candidateStat = await presentOrAbsent(candidateDirPath);
|
|
4554
|
-
|
|
4923
|
+
// The record of a release that is neither live nor kept here: nothing on this node answers to the id.
|
|
4924
|
+
if (!candidateStat) throw missing('that release is no longer on this node');
|
|
4925
|
+
if (!candidateStat.isDirectory()) {
|
|
4555
4926
|
// Unlike an immediate deploy, a symlink is refused: a `file:` directory is linked rather than
|
|
4556
4927
|
// copied, so what it points at now is not what was certified. Staging rejects the source for the
|
|
4557
4928
|
// same reason; this is the other end of the same rule, for an artifact staged by an older build.
|
|
4558
|
-
throw unusable('its build tree is
|
|
4929
|
+
throw unusable('its build tree is a link rather than a copy');
|
|
4559
4930
|
}
|
|
4560
4931
|
const descriptor = await readArtifactDescriptor(deploymentDirPath, application.name);
|
|
4561
4932
|
if (!descriptor) throw unusable('it does not record what its build decided');
|