@harperfast/harper 5.3.0 → 5.3.1
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/copyDb.ts +5 -1
- package/bin/deployRenderer.ts +3 -0
- package/components/Application.ts +66 -22
- package/components/DESIGN.md +89 -36
- package/components/componentLoader.ts +2 -0
- package/components/deploymentRecorder.ts +18 -1
- package/components/installFingerprint.ts +123 -0
- package/components/mcp/DESIGN.md +4 -4
- package/components/operations.js +29 -0
- package/config/DESIGN.md +5 -5
- package/dataLayer/DESIGN.md +37 -22
- package/dataLayer/backupRepository.ts +248 -0
- package/dataLayer/restoreMarker.ts +30 -75
- package/dataLayer/rocksdbBackup.ts +253 -110
- package/dist/bin/copyDb.js +3 -0
- package/dist/bin/copyDb.js.map +1 -1
- package/dist/bin/deployRenderer.js +3 -0
- package/dist/bin/deployRenderer.js.map +1 -1
- package/dist/components/Application.d.ts +5 -0
- package/dist/components/Application.js +51 -19
- package/dist/components/Application.js.map +1 -1
- package/dist/components/componentLoader.js +2 -0
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deploymentRecorder.js +18 -1
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/installFingerprint.d.ts +39 -0
- package/dist/components/installFingerprint.js +113 -0
- package/dist/components/installFingerprint.js.map +1 -0
- package/dist/components/operations.d.ts +2 -0
- package/dist/components/operations.js +31 -0
- package/dist/components/operations.js.map +1 -1
- package/dist/dataLayer/backupRepository.d.ts +33 -0
- package/dist/dataLayer/backupRepository.js +232 -0
- package/dist/dataLayer/backupRepository.js.map +1 -0
- package/dist/dataLayer/restoreMarker.d.ts +4 -1
- package/dist/dataLayer/restoreMarker.js +29 -68
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/dataLayer/rocksdbBackup.js +221 -112
- package/dist/dataLayer/rocksdbBackup.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/json/systemSchema.json +14 -0
- package/dist/resources/DatabaseTransaction.d.ts +140 -11
- package/dist/resources/DatabaseTransaction.js +1055 -464
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/IterableEventQueue.d.ts +2 -1
- package/dist/resources/IterableEventQueue.js +16 -8
- package/dist/resources/IterableEventQueue.js.map +1 -1
- package/dist/resources/LMDBTransaction.d.ts +13 -2
- package/dist/resources/LMDBTransaction.js +219 -93
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/RecordEncoder.js +2 -3
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Resource.js +5 -6
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +3 -1
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/RocksIndexStore.d.ts +2 -1
- package/dist/resources/RocksIndexStore.js +3 -0
- package/dist/resources/RocksIndexStore.js.map +1 -1
- package/dist/resources/Table.js +116 -12
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.js +6 -2
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/auditStore.d.ts +71 -17
- package/dist/resources/auditStore.js +248 -64
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/branchDatabase.js +2 -0
- package/dist/resources/branchDatabase.js.map +1 -1
- package/dist/resources/databases.js +8 -2
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
- package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
- package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
- package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
- package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
- package/dist/resources/models/Models.d.ts +11 -0
- package/dist/resources/models/Models.js +95 -4
- package/dist/resources/models/Models.js.map +1 -1
- package/dist/resources/models/backendRegistry.d.ts +8 -0
- package/dist/resources/models/backendRegistry.js +15 -1
- package/dist/resources/models/backendRegistry.js.map +1 -1
- package/dist/resources/models/bootstrap.d.ts +2 -0
- package/dist/resources/models/bootstrap.js +6 -0
- package/dist/resources/models/bootstrap.js.map +1 -1
- package/dist/resources/models/calibration.d.ts +88 -0
- package/dist/resources/models/calibration.js +244 -0
- package/dist/resources/models/calibration.js.map +1 -0
- package/dist/resources/models/calibrationStore.d.ts +163 -0
- package/dist/resources/models/calibrationStore.js +986 -0
- package/dist/resources/models/calibrationStore.js.map +1 -0
- package/dist/resources/models/decisionStore.d.ts +24 -0
- package/dist/resources/models/decisionStore.js +45 -0
- package/dist/resources/models/decisionStore.js.map +1 -1
- package/dist/resources/models/generativeDecision.js +44 -15
- package/dist/resources/models/generativeDecision.js.map +1 -1
- package/dist/resources/models/types.d.ts +7 -0
- package/dist/resources/recordLock.d.ts +8 -5
- package/dist/resources/recordLock.js +6 -4
- package/dist/resources/recordLock.js.map +1 -1
- package/dist/resources/replayLogs.js +8 -1
- package/dist/resources/replayLogs.js.map +1 -1
- package/dist/resources/scheduler/engine.d.ts +4 -0
- package/dist/resources/scheduler/engine.js +30 -2
- package/dist/resources/scheduler/engine.js.map +1 -1
- package/dist/resources/search.d.ts +16 -4
- package/dist/resources/search.js +117 -13
- package/dist/resources/search.js.map +1 -1
- package/dist/resources/transaction.js +85 -5
- package/dist/resources/transaction.js.map +1 -1
- package/dist/resources/transactionBroadcast.d.ts +8 -0
- package/dist/resources/transactionBroadcast.js +65 -0
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/keys.d.ts +1 -1
- package/dist/security/keys.js +170 -120
- package/dist/security/keys.js.map +1 -1
- package/dist/server/http.js +10 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/Request.js +11 -2
- package/dist/server/serverHelpers/Request.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.js +8 -3
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +38 -7
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/threads/manageThreads.d.ts +3 -3
- package/dist/server/threads/manageThreads.js +15 -13
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/threadServer.d.ts +3 -0
- package/dist/server/threads/threadServer.js +24 -6
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/upgrade/directives/5-3-0.js +9 -3
- package/dist/upgrade/directives/5-3-0.js.map +1 -1
- package/dist/utility/durableFile.d.ts +36 -0
- package/dist/utility/durableFile.js +106 -0
- package/dist/utility/durableFile.js.map +1 -0
- package/dist/utility/errors/hdbError.d.ts +4 -0
- package/dist/utility/errors/hdbError.js +10 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/utility/globalSchema.d.ts +9 -0
- package/dist/utility/hdbTerms.d.ts +1 -0
- package/dist/utility/hdbTerms.js +1 -0
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/validation/configValidator.js +29 -0
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +7 -0
- package/json/systemSchema.json +14 -0
- package/npm-shrinkwrap.json +22 -22
- package/package.json +2 -2
- package/resources/DESIGN.md +57 -272
- package/resources/DatabaseTransaction.ts +708 -87
- package/resources/FULL_TEXT.md +6 -2
- package/resources/IterableEventQueue.ts +14 -7
- package/resources/LMDBTransaction.ts +212 -84
- package/resources/RecordEncoder.ts +2 -3
- package/resources/Resource.ts +5 -6
- package/resources/ResourceInterface.ts +3 -1
- package/resources/RocksIndexStore.ts +5 -0
- package/resources/Table.ts +118 -15
- package/resources/analytics/write.ts +6 -2
- package/resources/audit-retention.md +203 -0
- package/resources/auditStore.ts +306 -84
- package/resources/branchDatabase.ts +2 -0
- package/resources/databases.ts +6 -2
- package/resources/indexes/DESIGN.md +120 -177
- package/resources/indexes/fullTextNativeBinding.ts +22 -3
- package/resources/indexes/fullTextQueryIndex.ts +274 -11
- package/resources/models/DESIGN.md +11 -3
- package/resources/models/Models.ts +127 -4
- package/resources/models/backendRegistry.ts +17 -0
- package/resources/models/bootstrap.ts +9 -1
- package/resources/models/calibration.ts +298 -0
- package/resources/models/calibrationStore.ts +1167 -0
- package/resources/models/decisionStore.ts +57 -0
- package/resources/models/generativeDecision.ts +54 -16
- package/resources/models/types.ts +6 -0
- package/resources/record-locks.md +155 -141
- package/resources/recordLock.ts +12 -6
- package/resources/replayLogs.ts +9 -2
- package/resources/scheduler/DESIGN.md +4 -3
- package/resources/scheduler/engine.ts +35 -2
- package/resources/search.ts +154 -16
- package/resources/transaction.ts +50 -5
- package/resources/transactionBroadcast.ts +69 -0
- package/security/DESIGN.md +9 -30
- package/security/keys.ts +175 -127
- package/server/DESIGN.md +70 -36
- package/server/http.ts +10 -0
- package/server/serverHelpers/Request.ts +10 -2
- package/server/serverHelpers/contentTypes.ts +5 -3
- package/server/serverHelpers/uwsServer.ts +34 -7
- package/server/threads/manageThreads.js +16 -14
- package/server/threads/threadServer.js +24 -6
- package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
- package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
- package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
- package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
- package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
- package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
- package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
- package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
- package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
- package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
- package/studio/web/assets/index-QLZw6xrU.css +1 -0
- package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
- package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
- package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
- package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
- package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
- package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
- package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
- package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
- package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
- package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
- package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
- package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
- package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
- package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
- package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
- package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
- package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
- package/studio/web/index.html +2 -2
- package/upgrade/directives/5-3-0.ts +10 -3
- package/utility/durableFile.ts +96 -0
- package/utility/errors/hdbError.ts +9 -0
- package/utility/hdbTerms.ts +1 -0
- package/validation/configValidator.ts +31 -0
- package/studio/web/assets/index-72cEH474.css +0 -1
package/components/operations.js
CHANGED
|
@@ -40,6 +40,7 @@ const {
|
|
|
40
40
|
} = require('./Application.ts');
|
|
41
41
|
const { COMPONENT_PREPARATION_LOCK_DIR, withComponentPreparationLock } = require('./componentPreparationLock.ts');
|
|
42
42
|
const { DEPLOYMENT_PROVENANCE_FILE } = require('./deploymentProvenance.ts');
|
|
43
|
+
const { compareInstallFingerprints, describeInstallDrift } = require('./installFingerprint.ts');
|
|
43
44
|
const {
|
|
44
45
|
applyRootConfigEffect,
|
|
45
46
|
assertRootConfigEffectPublishable,
|
|
@@ -888,9 +889,11 @@ async function deployComponent(req) {
|
|
|
888
889
|
emit('phase', { phase: 'prepare', status: 'done' });
|
|
889
890
|
await validateComponentLoads(candidateDirPath, emit);
|
|
890
891
|
},
|
|
892
|
+
fingerprintInstall: !isActivation,
|
|
891
893
|
});
|
|
892
894
|
// Nothing ran `validateCandidate`, which ends this phase.
|
|
893
895
|
if (application.alreadyActive) emit('phase', { phase: 'prepare', status: 'done' });
|
|
896
|
+
if (recorder && application.installFingerprint) recorder.row.install_fingerprint = application.installFingerprint;
|
|
894
897
|
// The build is certified on disk from here on, so every later failure — a peer result, or a rejection
|
|
895
898
|
// thrown by the replication layer itself — still leaves an artifact this id can activate.
|
|
896
899
|
if (mode === 'stage') stagedOnOrigin = true;
|
|
@@ -933,6 +936,13 @@ async function deployComponent(req) {
|
|
|
933
936
|
timeoutMs: peerDeployAnswerTimeoutMs(req),
|
|
934
937
|
});
|
|
935
938
|
emit('phase', { phase: 'replicate', status: 'done' });
|
|
939
|
+
if (application.installFingerprint) response.install = application.installFingerprint;
|
|
940
|
+
// Marked on the aggregate, which the recorder re-records below, so a peer's per-peer entry is replaced
|
|
941
|
+
// rather than contradicted.
|
|
942
|
+
const installDrift = recorder
|
|
943
|
+
? markInstallComparisons(application.installFingerprint, response?.replicated)
|
|
944
|
+
: undefined;
|
|
945
|
+
if (installDrift) emit('warning', { message: installDrift });
|
|
936
946
|
if (recorder && response?.replicated) {
|
|
937
947
|
// Fallback path for replicators that don't honor onPeerResult: re-record the
|
|
938
948
|
// aggregate. recordPeer's upsert-by-node-name semantics make this idempotent
|
|
@@ -1081,6 +1091,7 @@ async function deployComponent(req) {
|
|
|
1081
1091
|
`Staged: ${application.name}. Deploy it with deploy_component ` + `deployment_id=${recorder.deploymentId}`;
|
|
1082
1092
|
}
|
|
1083
1093
|
}
|
|
1094
|
+
if (installDrift) response.message = `${response.message} ${installDrift}`;
|
|
1084
1095
|
return response;
|
|
1085
1096
|
} catch (err) {
|
|
1086
1097
|
// Pack phase, install output tail, and deployment_id into http_resp_msg so the
|
|
@@ -1145,6 +1156,23 @@ function unconfirmedStagingPeers(replicated) {
|
|
|
1145
1156
|
return replicated.filter((peer) => peer && !confirmed(peer));
|
|
1146
1157
|
}
|
|
1147
1158
|
|
|
1159
|
+
/** The fingerprint is read flat or from a wrapped body, as `unconfirmedStagingPeers` reads its marker. */
|
|
1160
|
+
function markInstallComparisons(ownFingerprint, replicated) {
|
|
1161
|
+
if (!ownFingerprint || !Array.isArray(replicated)) return undefined;
|
|
1162
|
+
const peers = [];
|
|
1163
|
+
for (const peer of replicated) {
|
|
1164
|
+
if (!peer || typeof peer !== 'object') continue;
|
|
1165
|
+
const comparison = compareInstallFingerprints(
|
|
1166
|
+
ownFingerprint,
|
|
1167
|
+
peer.install ?? peer.value?.install ?? peer.body?.install
|
|
1168
|
+
);
|
|
1169
|
+
peer.install_matches = comparison.matches;
|
|
1170
|
+
peer.install_differs = comparison.differs;
|
|
1171
|
+
peers.push({ node: peer.node ?? peer.name ?? peer.hostname ?? null, comparison });
|
|
1172
|
+
}
|
|
1173
|
+
return describeInstallDrift(peers);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1148
1176
|
// Ring buffer of install stdout/stderr lines, capped by both line count and bytes so
|
|
1149
1177
|
// a chatty install can't unbounded-grow the error response. snapshot() reports whether
|
|
1150
1178
|
// the head was dropped so callers can flag truncation.
|
|
@@ -1634,6 +1662,7 @@ exports.dropCustomFunctionProject = dropCustomFunctionProject;
|
|
|
1634
1662
|
exports.packageComponent = packageComponent;
|
|
1635
1663
|
exports.deployComponent = deployComponent;
|
|
1636
1664
|
exports.unconfirmedStagingPeers = unconfirmedStagingPeers;
|
|
1665
|
+
exports.markInstallComparisons = markInstallComparisons;
|
|
1637
1666
|
exports.peerDeployAnswerTimeoutMs = peerDeployAnswerTimeoutMs;
|
|
1638
1667
|
exports.getComponents = getComponents;
|
|
1639
1668
|
exports.getComponentFile = getComponentFile;
|
package/config/DESIGN.md
CHANGED
|
@@ -11,7 +11,7 @@ Index of every design note: [DESIGN.md](../DESIGN.md).
|
|
|
11
11
|
## `set_configuration` replication is opt-in; `replicateOperation` is default-on (`config/configUtils.ts`)
|
|
12
12
|
|
|
13
13
|
`server.replication.replicateOperation` (installed by harper-pro's replicator) fans out whenever
|
|
14
|
-
`req.replicated
|
|
14
|
+
`req.replicated !== false` — absence of the flag means "replicate". That default-on contract is what
|
|
15
15
|
DDL ops rely on (`dropSchema`/`dropTable` call it unconditionally), so a handler that mirrors the
|
|
16
16
|
drop_schema pattern without a guard silently becomes replicate-by-default. `setConfiguration` must
|
|
17
17
|
stay **opt-in** (`if (replicated)` truthy guard) because config bodies routinely carry node-local
|
|
@@ -211,13 +211,13 @@ What a scope does about a config that arrives late is the other half of settling
|
|
|
211
211
|
config of its own to having one, which is both the recreated-config-file path and a scope that
|
|
212
212
|
booted while the file was unreadable. Nothing downstream re-runs on it — `componentLoader` is long
|
|
213
213
|
past its `await scope.ready` — so `Scope` answers a repeat `ready` the same way it answers `remove`,
|
|
214
|
-
by requesting a restart. Without that, one worker keeps serving the defaults while every worker
|
|
214
|
+
by requesting a restart, unless the plugin listens for `ready` itself. Without that, one worker keeps serving the defaults while every worker
|
|
215
215
|
that read the file cleanly serves the operator's config.
|
|
216
216
|
|
|
217
217
|
Arming is a terminal outcome of its own: chokidar reports a scan that found no file by emitting
|
|
218
218
|
`ready` and nothing else, so `RootConfigWatcher` always re-reads when the gate opens rather than
|
|
219
|
-
publishing what an earlier read staged — a missing config file
|
|
220
|
-
|
|
219
|
+
publishing what an earlier read staged — a missing config file settles on the defaults at once (it
|
|
220
|
+
never takes the ladder) instead of holding the barrier open. That fallback must also discard the staged value:
|
|
221
221
|
the arming re-read is authoritative precisely because a write in the unarmed window may have
|
|
222
222
|
superseded it, including by replacing the file with an unusable or missing one. A watcher scan error
|
|
223
223
|
also settles the barrier, but preserves a successfully staged value because no read superseded it.
|
|
@@ -258,7 +258,7 @@ cleanup that frees space needs a started process (#847). Two rules follow.
|
|
|
258
258
|
**Derived boot writes are best-effort; user-requested ones are not.** `persistConfigDuringBoot()`
|
|
259
259
|
swallows exactly ENOSPC/EDQUOT (matching on `errno` as well as `code`, because Linux has no libuv
|
|
260
260
|
mapping for EDQUOT and reports `Unknown system error -122`) and lets the boot proceed on the
|
|
261
|
-
in-memory config. `
|
|
261
|
+
in-memory config. `set_configuration` (`updateConfigValue`), deploy/drop's `applyRootConfigEffect` and the
|
|
262
262
|
install path keep persist-or-throw: a caller who asked to persist must not get a silent success, and
|
|
263
263
|
an install has no last-known-good config to fall back on.
|
|
264
264
|
|
package/dataLayer/DESIGN.md
CHANGED
|
@@ -10,7 +10,7 @@ Index of every design note: [DESIGN.md](../DESIGN.md).
|
|
|
10
10
|
|
|
11
11
|
## Version gate at startup: downgrades prompt, and only the minor direction is confirmable
|
|
12
12
|
|
|
13
|
-
`getVersionUpdateInfo()` (`dataLayer/hdbInfoController.ts`) compares the store's `data_version_num` (latest `system.hdb_info` record) against the binary's `packageJson.version` on every start. Data newer than binary by a **major** version → hard refusal. Newer by a **minor** version → `forceDowngradePrompt()` asks for confirmation; answering yes records the data version back down to the binary's version and boots (upgrade directives are deliberately additive/downgrade-compatible — see
|
|
13
|
+
`getVersionUpdateInfo()` (`dataLayer/hdbInfoController.ts`) compares the store's `data_version_num` (latest `system.hdb_info` record) against the binary's `packageJson.version` on every start. Data newer than binary by a **major** version → hard refusal. Newer by a **minor** version → `forceDowngradePrompt()` asks for confirmation; answering yes records the data version back down to the binary's version and boots (upgrade directives are deliberately additive/downgrade-compatible — see [Struct mode is gated to primary DBIs](../resources/DESIGN.md#struct-mode-is-gated-to-primary-dbis-downgrade-compatibility) and `patchHdbSecretIsHashAttribute` in `upgrade/directives/5-2-0.ts`).
|
|
14
14
|
|
|
15
15
|
- The prompt's answer can be supplied non-interactively via `CONFIRM_DOWNGRADE` — env var or `--CONFIRM_DOWNGRADE` CLI arg; argv wins (`assignCMDENVVariables`). With no override and no TTY on stdin, the prompt throws instead of blocking on stdin forever (#2046 — services/CI hung with nothing in the log; the mismatch is also logged to hdb.log now).
|
|
16
16
|
- Upgrades never prompt (see the rationale comment in `bin/upgrade.js`); only the downgrade direction confirms. `upgradeCertsPrompt()` (4.x upgrade path; currently has no in-core caller) has the same no-TTY guard as `forceDowngradePrompt()` — a `GENERATE_CERTS` override is honored first, and with no TTY and no override it throws instead of blocking.
|
|
@@ -22,9 +22,9 @@ When `migrateOnStart` opens a source LMDB primary store to read records out for
|
|
|
22
22
|
|
|
23
23
|
Harper's normal `databases.ts` path already does this (search for `dbiInit.compression = primaryKeyAttribute.compression`); the migration path in `bin/copyDb.ts` has to match.
|
|
24
24
|
|
|
25
|
-
The persisted `compression` value itself is LMDB-era and loosely shaped: `getDefaultCompression()` historically stored whatever falsy value the config resolved to (`''`, `false`, `null`) when `storage.compression` was disabled, and `{ startingOffset, threshold, dictionary? }` when enabled. lmdb-js interprets falsy as "no compression", but rocksdb-js >= 2.6 validates the option strictly (`''`/booleans throw `Unsupported compression algorithm`) and treats UNSET as "
|
|
25
|
+
The persisted `compression` value itself is LMDB-era and loosely shaped: `getDefaultCompression()` historically stored whatever falsy value the config resolved to (`''`, `false`, `null`) when `storage.compression` was disabled, and `{ startingOffset, threshold, dictionary? }` when enabled. lmdb-js interprets falsy as "no compression", but rocksdb-js >= 2.6 validates the option strictly (`''`/booleans throw `Unsupported compression algorithm`) and treats UNSET as "keep the family's persisted codec, or the build default (lz4) for a new family" — the inverse default of lmdb. Every RocksDB open therefore takes one per-process deployment codec, `getRocksCompression()` (applied inside `openRocksDatabase`; see [The RocksDB codec is a deployment setting, resolved once per process](../resources/DESIGN.md#the-rocksdb-codec-is-a-deployment-setting-resolved-once-per-process)), derived through `toRocksCompression()` in `resources/databases.ts`, which maps defined-falsy → `'none'` and enabled-without-an-algorithm → an explicit lz4 request when available. Don't pass persisted attribute compression to a RocksDB open directly.
|
|
26
26
|
|
|
27
|
-
`bin/copyDb.ts`'s `openRocksDb`
|
|
27
|
+
`bin/copyDb.ts`'s `openRocksDb` applies the same codec (`getRocksCompression() ?? toRocksCompression(...)`), not an exception to it. This is about the bytes migration writes, not about a later failure: `copyDbToRocks()` closes every target handle before the staging directory is renamed, and rocksdb-js permits an explicit codec change across a close/reopen, so the runtime would open the migrated database fine either way. But a migration that ignores the configured codec writes the entire dataset uncompressed, and those SST/blob files then keep their original codec until write traffic rewrites them — a full LMDB→RocksDB migration is the one moment the whole dataset is written at once, so it is exactly when the deployment's codec should apply.
|
|
28
28
|
|
|
29
29
|
## System table bootstrap: `systemSchema.json` + upgrade directive
|
|
30
30
|
|
|
@@ -32,13 +32,13 @@ Adding a new system table (e.g. `hdb_deployment` in #641 Slice A) requires three
|
|
|
32
32
|
|
|
33
33
|
1. **`json/systemSchema.json`** — the table entry. Fresh installs auto-create it via `utility/mount_hdb.ts:createTables()`, which iterates `Object.keys(systemSchema)` on first boot.
|
|
34
34
|
2. **`utility/hdbTerms.ts`** — add the table name to `SYSTEM_TABLE_NAMES`.
|
|
35
|
-
3. **`upgrade/directives/<version>.ts`** — provisions the table on existing installs that already have a system schema. Registered in `upgrade/directives/directivesController.ts` (which is otherwise empty — its `versions` Map gets populated by these imports).
|
|
35
|
+
3. **`upgrade/directives/<version>.ts`** — provisions the table on existing installs that already have a system schema. Registered in `upgrade/directives/directivesController.ts` (which is otherwise empty — its `versions` Map gets populated by these imports). Each directive module default-exports an array of `{ version, sync_functions, async_functions }`; copy `5-1-0.ts` for the canonical pattern (uses `bridge.createTable` to match what `mount_hdb` does on a fresh install).
|
|
36
36
|
|
|
37
37
|
**Version the directive to the first release that ships the dependent code, not a later one.** Directives only run when `current_version < directive_version <= upgrade_version` (`directivesController.getVersionsForUpgrade`). The `hdb_deployment` directive was originally mis-tagged `5.2.0` while the deployment-recorder code shipped in `5.1.0`, so on every `5.0.x -> 5.1.x` upgrade the directive was filtered out (`5.2.0 > 5.1.x`) and the table never got created — breaking replicated `deploy_component` on peer nodes for the entire existing customer base. Caveat: `utility/common_utils.ts:compareVersions` strips trailing `.0` and therefore sorts a pre-release (`5.1.0-beta.1`) _above_ its GA (`5.1.0`), so an install already on a `5.1.0-beta.x` data version will not pick up a `5.1.0` directive when upgrading to GA; those pre-release installs need the table created by other means.
|
|
38
38
|
|
|
39
39
|
System tables replicate by default. To opt out, add the name to `NON_REPLICATING_SYSTEM_TABLES` in `resources/databases.ts`. The check happens after table init and sets `table.replicate = false` per-node.
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
Set `audit = true` on the directive's `CreateTableObject` (for upgrades): a fresh install audits every system table — `utility/mount_hdb.ts:createTables()` sets `createTable.audit = true` regardless of the schema entry — so a directive that leaves it off makes upgraded nodes diverge from fresh ones.
|
|
42
42
|
|
|
43
43
|
**A replicated system table whose owner declares more than a name list** — a table-level `expiration`, an index — needs that declaration on every node, every boot. `hdb_oidc_token_use` is the case: `security/authn/oidc/tokenUseTable.ts` holds its one definition, and `bin/run.ts initialize()` applies it after the upgrade step and before worker threads start (skipped in read-only mode; a failure is logged and retried on the next start), as do the 5.3.0 directive and the exchange path. Each replay row carries its own expiry as record metadata, which replication applies on the receiving node; the table's stored `expiration` is what arms the cleanup scan that removes those rows once a node loads the table. Metadata rather than an `@expiresAt` attribute, because the attribute needs an index and a sweep of its own on every node, while reads and the cleanup scan already honor the metadata. `systemSchema.json` keeps only its primary key; the declaration completes the table on the first boot after install. Every other route to a copy leaves it partial, which is why the declaration cannot live only on the exchange path or in the directive:
|
|
44
44
|
|
|
@@ -76,7 +76,7 @@ Three non-obvious mechanics keep that safe:
|
|
|
76
76
|
be exactly that database's directory). Because the startup scan opens any `CURRENT`+`MANIFEST-`
|
|
77
77
|
directory without re-applying `schemaRegex`, it also explicitly skips the reserved `` `restore` ``
|
|
78
78
|
entry so an out-of-band directory at that name is never loaded as a database. Startup/rescan
|
|
79
|
-
detection (`databasesBlockedByRestore` → `scanBlockedRestores` in `dataLayer/restoreMarker.ts`)
|
|
79
|
+
detection (`databasesBlockedByRestore` in `resources/databases.ts` → `scanBlockedRestores` in `dataLayer/restoreMarker.ts`)
|
|
80
80
|
reads the metadata directory and checks the **marker first**, only probing the lock when the marker exists —
|
|
81
81
|
probes take the flock and are mutually exclusive across threads, so probing the (persistent) lock
|
|
82
82
|
file of every long-ago-restored database on every rescan would make concurrent rescans misclassify
|
|
@@ -127,15 +127,16 @@ Three non-obvious mechanics keep that safe:
|
|
|
127
127
|
- **Online restore is impossible for a database a component holds open — and that failure is
|
|
128
128
|
correct.** rocksdb-js's registry is process-global but records only a per-path refCount, with no
|
|
129
129
|
attribution to a thread or component; Harper keeps no component→database ownership map. So when a
|
|
130
|
-
loaded component
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
loaded component holds its own handle on the target database, `registryStatus()` stays non-zero,
|
|
131
|
+
Harper can neither identify nor force-close that handle, and an in-place purge would corrupt a live
|
|
132
|
+
instance.
|
|
133
133
|
`verifyDatabaseClosed` therefore waits only a short grace period (`DATABASE_CLOSE_WAIT_MS`, for a
|
|
134
134
|
just-finished job worker's own close to drain) and then fails fast with a 409 that points at
|
|
135
135
|
running the operation offline (`harper restore_backup` with the server stopped, where no
|
|
136
136
|
components are loaded and nothing holds the database open). Offline restore is the supported path
|
|
137
137
|
for component-held and `system` databases; online restore serves databases not actively held by a
|
|
138
|
-
component.
|
|
138
|
+
component. `system`, which Harper itself never stops while running, never reaches this check:
|
|
139
|
+
`validateRestoreBackup` refuses it up front with a 400 naming the offline command. The CLI exposes each backup operation under its operation name only (`create_backup`,
|
|
139
140
|
`restore_backup`, …) — no hyphenated alias — and `bin/backup.ts` routes it to a reachable server
|
|
140
141
|
or, when the local server is stopped, to the equivalent offline function.
|
|
141
142
|
- **Job workers must release their RocksDB handles on exit, or the closure check can never pass.**
|
|
@@ -149,6 +150,11 @@ Three non-obvious mechanics keep that safe:
|
|
|
149
150
|
calls `closeLoadedDatabases()` (`resources/databases.ts`) in its `finally`, closing every loaded
|
|
150
151
|
user database on that thread (the non-enumerable `system` DB is intentionally skipped), so an
|
|
151
152
|
exited job worker leaves no residual handle to be mistaken for a live holder.
|
|
153
|
+
- **A restore stamps a new database generation before `completeRestore`.** The restored files carry
|
|
154
|
+
the backup's generation, so both paths open the restored directory privately, stamp it and flush
|
|
155
|
+
(`stampDatabaseDirectory`, [database generation](../resources/DESIGN.md#database-generation-and-resumable-positions))
|
|
156
|
+
inside the destructive section: a failed stamp leaves the marker, and the rerun re-purges and
|
|
157
|
+
re-stamps. The stamp is as durable as the marker protocol it runs inside.
|
|
152
158
|
- **`dropDatabase` and `restore_backup` serialize on the same lock, not a check-then-act probe.**
|
|
153
159
|
A drop's `destroy()` interleaving with a restore's purge-and-copy on the same directory would gut
|
|
154
160
|
a "successful" restore (or vice versa). `dropDatabase` takes the restore lock for every RocksDB or
|
|
@@ -170,22 +176,31 @@ Three non-obvious mechanics keep that safe:
|
|
|
170
176
|
`create_table`/`create_schema` must not resurrect a half-purged directory as a fresh empty DB), but
|
|
171
177
|
the destructive drop path now uses the exclusive lock so the race is closed, not merely narrowed.
|
|
172
178
|
- **The offline restore probes RocksDB's own `LOCK` file, and fails closed.** The offline path runs
|
|
173
|
-
only when the CLI sees no server (a PID heuristic; the PID file is briefly absent
|
|
174
|
-
restart`), and `backups.restore`'s `purgeAllFiles` never takes RocksDB's lock — so
|
|
175
|
-
`restoreBackupOffline` opens the database to probe. It now takes the restore
|
|
176
|
-
probing (so a server that starts afterward sees the marker and refuses to
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
only when the CLI sees no server (a PID heuristic; the PID file is briefly absent
|
|
180
|
+
mid-`harper restart`), and `backups.restore`'s `purgeAllFiles` never takes RocksDB's lock — so
|
|
181
|
+
before purging, `restoreBackupOffline` opens the database to probe. It now takes the restore
|
|
182
|
+
lock+marker _before_ probing (so a server that starts afterward sees the marker and refuses to
|
|
183
|
+
load), and recognizes the rocksdb-js lock error by message (`isRocksDbLockError`; at 2.5.0, when
|
|
184
|
+
this was written, a plain `Error` with no `code`) —
|
|
185
|
+
`IO error: While lock file: <db>/LOCK: Resource temporarily unavailable` — aborting with a 409
|
|
186
|
+
rather than purging a database another process holds open. Any _other_ open failure
|
|
187
|
+
(corrupt/half-restored) is exactly what restore recovers, so only a lock conflict aborts.
|
|
181
188
|
|
|
182
189
|
Known limitation: the flock is process-owned; if the restore job's worker _thread_ dies without
|
|
183
|
-
the process exiting, the lock stays held (restores 409) until Harper restarts.
|
|
184
|
-
native lock signal
|
|
190
|
+
the process exiting, the lock stays held (restores 409) until Harper restarts. rocksdb-js had no typed
|
|
191
|
+
native lock signal at 2.5.0 (the pin is now 2.10.0), so the offline probe relies on message matching; a native
|
|
185
192
|
lock primitive is a rocksdb-js follow-on.
|
|
186
193
|
|
|
187
194
|
## RocksDB managed backups: blob snapshots (`dataLayer/blobBackup.ts`)
|
|
188
195
|
|
|
196
|
+
Repository mutations hold the management lock before entering the engine. Restore admission
|
|
197
|
+
rechecks the source under that lock, then `beginRestore` takes the destination lock and runs a
|
|
198
|
+
synchronous callback that durably writes the source claim before publishing the restoring marker.
|
|
199
|
+
A crash after publication therefore leaves both marker and claim; a crash before publication leaves
|
|
200
|
+
only a claim that lapses when no marker exists. Failed claims preserve any preexisting marker.
|
|
201
|
+
Delete and purge reconcile Harper-managed files even after engine failure: a cleanup failure must
|
|
202
|
+
propagate when the engine succeeded, but remains secondary when the engine already failed.
|
|
203
|
+
|
|
189
204
|
A database's file-backed blobs live in one or more roots _outside_ the RocksDB directory
|
|
190
205
|
(`getBlobPathsForDatabaseName` in `resources/blob.ts` — one per configured `storage.blobPaths`, else
|
|
191
206
|
`<hdb_root>/blobs/<database>`), so the engine's backup does not capture them. `create_backup`,
|
|
@@ -201,7 +216,7 @@ engine-only backup):
|
|
|
201
216
|
before capture, a terminal ERROR (`0xff`) marker preserves its file id. A file reclaimed before its
|
|
202
217
|
parent directory is read is outside the snapshot. This keeps a snapshot inode from changing as a
|
|
203
218
|
live write finishes, while complete blobs remain safe to hard-link because published blob paths are
|
|
204
|
-
write-once. The snapshot is built in a
|
|
219
|
+
write-once. The snapshot is built in a `<backupId>.tmp` sibling and atomically renamed so a failed create
|
|
205
220
|
leaves no partial snapshot. `restore_backup` purges each blob root and rewrites it from the snapshot;
|
|
206
221
|
`delete_backup` / `purge_backups` remove the corresponding snapshot directories.
|
|
207
222
|
- **`get_backup`** appends the blob files to the same tar under `blobs/<rootIndex>/<relpath>`. The
|
|
@@ -228,4 +243,4 @@ flag — not the mere presence of a snapshot dir — to decide whether to restor
|
|
|
228
243
|
backup leaves live blobs untouched, and a manifest that claims blobs but has no snapshot is flagged
|
|
229
244
|
corrupt by verify). This closes the "healthy-looking but incomplete" and concurrent-restore races;
|
|
230
245
|
the remaining engine/blob point-in-time skew (a blob unlinked between the engine cut and the blob
|
|
231
|
-
walk) is
|
|
246
|
+
walk) is best-effort: Harper does not freeze blob writes for a backup.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { fileLockRelease, tryFileLock } from '@harperfast/rocksdb-js';
|
|
7
|
+
import { ClientError } from '../utility/errors/hdbError.ts';
|
|
8
|
+
import logger from '../utility/logging/harper_logger.ts';
|
|
9
|
+
import { restoreMarkerPresent } from './restoreMarker.ts';
|
|
10
|
+
import { fsyncDirectory, removeFileDurably, writeFileDurably } from '../utility/durableFile.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Harper-level coordination for a backup repository: one exclusive management lock, and pins that
|
|
14
|
+
* keep a backup alive while something is depending on it.
|
|
15
|
+
*
|
|
16
|
+
* rocksdb-js has its own `.backup.lock`, but it only covers the engine files. Harper's blob
|
|
17
|
+
* snapshots and completion manifests live beside them and are written and removed by separate
|
|
18
|
+
* Harper code, so a `purge_backups` can still remove a blob snapshot out from under a
|
|
19
|
+
* `create_backup` that is mid-finalization, or out from under a restore that is reading it
|
|
20
|
+
* (harper#2031). Everything that mutates the *Harper-managed* parts of a repository therefore takes
|
|
21
|
+
* this lock, in this order: management lock first, then any engine call that takes `.backup.lock`.
|
|
22
|
+
*
|
|
23
|
+
* A pin is the other half. A restore selects a backup and then acts on it, and a delete or purge in
|
|
24
|
+
* between would leave it with no source. Pins are installed and checked under the same lock, so "is
|
|
25
|
+
* this id pinned?" cannot be answered stale: a delete could otherwise read "unpinned", pause, and
|
|
26
|
+
* resume after a pin landed.
|
|
27
|
+
*
|
|
28
|
+
* A pin holds no owner or expiry. It names the database directory it is protecting, and it counts
|
|
29
|
+
* only while that database still carries a restoring marker — the marker being present is exactly
|
|
30
|
+
* the condition under which a restore is either running or waiting to be rerun, and therefore the
|
|
31
|
+
* condition under which its source must survive. A process killed mid-restore leaves both, and the
|
|
32
|
+
* rerun clears both; a process killed between clearing the marker and releasing the pin leaves a pin
|
|
33
|
+
* that is ignored and swept the next time anything looks. Nothing has to be reaped on a timer, and
|
|
34
|
+
* no pin can outlive what it protects.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const MANAGEMENT_LOCK_FILE = '.management.lock';
|
|
38
|
+
const PINS_DIR = 'pins';
|
|
39
|
+
const PIN_FILE_SUFFIX = '.json';
|
|
40
|
+
/** Filesystem-safe, and short enough to stay well inside NAME_MAX once suffixed. */
|
|
41
|
+
const PIN_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
|
|
42
|
+
|
|
43
|
+
const LOCK_POLL_INTERVAL_MS = 50;
|
|
44
|
+
const LOCK_WAIT_TIMEOUT_MS = 30_000;
|
|
45
|
+
|
|
46
|
+
export function managementLockPath(backupDir: string): string {
|
|
47
|
+
return join(backupDir, MANAGEMENT_LOCK_FILE);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function backupPinsDir(backupDir: string): string {
|
|
51
|
+
return join(backupDir, PINS_DIR);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BackupPin {
|
|
55
|
+
pin_id: string;
|
|
56
|
+
backup_id: number;
|
|
57
|
+
reason: string;
|
|
58
|
+
created_at: number;
|
|
59
|
+
/** Directory of the database whose restore this pin protects; the pin counts while it is marked. */
|
|
60
|
+
database_path?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Codes that mean the repository cannot be written at all, not that this attempt lost a race. */
|
|
64
|
+
const REPOSITORY_NOT_WRITABLE = new Set(['EROFS', 'EACCES', 'EPERM']);
|
|
65
|
+
const { W_OK } = constants;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A repository Harper cannot write is refused by name rather than by whatever errno surfaced first.
|
|
69
|
+
* Every managed operation — restore included, which only reads the backup itself — records a lock
|
|
70
|
+
* and a claim inside the repository, so read-only media (a `ro` snapshot mount, a read-only share)
|
|
71
|
+
* cannot be used in place. Inferring "immutable, so no claim needed" from a failed write would be
|
|
72
|
+
* wrong: a read-only *view* of a repository another writer can still purge looks identical.
|
|
73
|
+
*/
|
|
74
|
+
function assertRepositoryWritable(databaseName: string, error: any): never {
|
|
75
|
+
if (!REPOSITORY_NOT_WRITABLE.has(error?.code)) throw error;
|
|
76
|
+
throw new ClientError(
|
|
77
|
+
`Backup repository for database '${databaseName}' is not writable (${error.code}). Harper records a ` +
|
|
78
|
+
`management lock and a claim inside the repository for every backup operation, including restore, so ` +
|
|
79
|
+
`it cannot be used read-only. Remount it writable, or copy it to writable storage, and retry.`,
|
|
80
|
+
409
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run `operation` holding the repository's management lock.
|
|
86
|
+
*
|
|
87
|
+
* Waits rather than failing immediately: the operations it serializes are short (a manifest write,
|
|
88
|
+
* a snapshot removal) except for a blob snapshot of a large database, and a `delete_backup` that
|
|
89
|
+
* 409s because an unrelated `create_backup` was finalizing is a worse answer than a brief wait. A
|
|
90
|
+
* holder that dies releases the lock with its process, so the wait cannot outlive a crash.
|
|
91
|
+
*/
|
|
92
|
+
export async function withBackupRepositoryLock<T>(
|
|
93
|
+
backupDir: string,
|
|
94
|
+
databaseName: string,
|
|
95
|
+
operation: () => Promise<T>,
|
|
96
|
+
createRepository = false
|
|
97
|
+
): Promise<T> {
|
|
98
|
+
const lockPath = managementLockPath(backupDir);
|
|
99
|
+
const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
|
|
100
|
+
if (!existsSync(backupDir)) {
|
|
101
|
+
// Only a create may bring a repository into existence. Creating it here for a delete or purge
|
|
102
|
+
// would leave an empty directory behind after the 404, and a later list_backups for that name
|
|
103
|
+
// would answer [] instead of the 404 a typo is owed. With nothing on disk there is also
|
|
104
|
+
// nothing to exclude, so the operation runs unlocked and refuses on its own.
|
|
105
|
+
if (!createRepository) return operation();
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(backupDir, { recursive: true });
|
|
108
|
+
} catch (error: any) {
|
|
109
|
+
assertRepositoryWritable(databaseName, error);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// The binding's lock error carries a message but no errno, so writability is checked here.
|
|
113
|
+
try {
|
|
114
|
+
accessSync(backupDir, W_OK);
|
|
115
|
+
} catch (error: any) {
|
|
116
|
+
assertRepositoryWritable(databaseName, error);
|
|
117
|
+
}
|
|
118
|
+
let token = tryFileLock(lockPath);
|
|
119
|
+
while (token === 0) {
|
|
120
|
+
if (Date.now() >= deadline) {
|
|
121
|
+
throw new ClientError(
|
|
122
|
+
`Another backup management operation is in progress for database '${databaseName}'; retry once it finishes`,
|
|
123
|
+
409
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
await delay(LOCK_POLL_INTERVAL_MS);
|
|
127
|
+
token = tryFileLock(lockPath);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return await operation();
|
|
131
|
+
} finally {
|
|
132
|
+
fileLockRelease(token);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pinPath(backupDir: string, pinId: string): string {
|
|
137
|
+
if (!PIN_ID_PATTERN.test(pinId)) throw new ClientError(`Invalid backup pin id '${pinId}'`);
|
|
138
|
+
return join(backupPinsDir(backupDir), pinId + PIN_FILE_SUFFIX);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every pin currently held on a repository. A malformed pin file is reported, never silently ignored. */
|
|
142
|
+
export function readBackupPins(backupDir: string): BackupPin[] {
|
|
143
|
+
const pinsDir = backupPinsDir(backupDir);
|
|
144
|
+
if (!existsSync(pinsDir)) return [];
|
|
145
|
+
const pins: BackupPin[] = [];
|
|
146
|
+
for (const entry of readdirSync(pinsDir, { withFileTypes: true })) {
|
|
147
|
+
if (!entry.isFile() || !entry.name.endsWith(PIN_FILE_SUFFIX)) continue;
|
|
148
|
+
const pinId = entry.name.slice(0, -PIN_FILE_SUFFIX.length);
|
|
149
|
+
let parsed: any;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(readFileSync(join(pinsDir, entry.name), 'utf8'));
|
|
152
|
+
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
153
|
+
throw new Error('pin file is not an object');
|
|
154
|
+
}
|
|
155
|
+
// A parseable record is not a usable one: `{"backup_id":"7"}` matches no requested id, so
|
|
156
|
+
// without this the claim permits exactly the delete it exists to refuse.
|
|
157
|
+
if (!Number.isInteger(parsed.backup_id) || parsed.backup_id < 1) {
|
|
158
|
+
throw new Error('pin file has no usable backup id');
|
|
159
|
+
}
|
|
160
|
+
} catch (error: any) {
|
|
161
|
+
// A released claim, unlinked between the readdir and the read. Anything else still means
|
|
162
|
+
// something claimed a backup: fail closed against every id, because a torn pin file must not
|
|
163
|
+
// become permission to delete whatever it was protecting.
|
|
164
|
+
if (error?.code === 'ENOENT') continue;
|
|
165
|
+
pins.push({ pin_id: pinId, backup_id: Number.NaN, reason: 'unreadable pin', created_at: 0 });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
pins.push({
|
|
169
|
+
pin_id: pinId,
|
|
170
|
+
backup_id: parsed.backup_id,
|
|
171
|
+
reason: typeof parsed.reason === 'string' ? parsed.reason : 'unspecified',
|
|
172
|
+
created_at: parsed.created_at ?? 0,
|
|
173
|
+
database_path: typeof parsed.database_path === 'string' ? parsed.database_path : undefined,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return pins;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Claim a backup so it cannot be deleted or purged. The caller must hold the management lock, which
|
|
181
|
+
* is what makes the claim atomic with the delete/purge admission check.
|
|
182
|
+
*/
|
|
183
|
+
export function pinBackup(
|
|
184
|
+
backupDir: string,
|
|
185
|
+
pinId: string,
|
|
186
|
+
backupId: number,
|
|
187
|
+
reason: string,
|
|
188
|
+
databasePath?: string
|
|
189
|
+
): void {
|
|
190
|
+
const path = pinPath(backupDir, pinId);
|
|
191
|
+
const pinsDir = backupPinsDir(backupDir);
|
|
192
|
+
const created = mkdirSync(pinsDir, { recursive: true });
|
|
193
|
+
// writeFileDurably flushes the pins directory, but on the first pin that directory's own entry is
|
|
194
|
+
// new too — without this the whole directory, and so the pin, can be lost to a power cut.
|
|
195
|
+
if (created !== undefined) fsyncDirectory(backupDir);
|
|
196
|
+
const pin: BackupPin = {
|
|
197
|
+
pin_id: pinId,
|
|
198
|
+
backup_id: backupId,
|
|
199
|
+
reason,
|
|
200
|
+
created_at: Date.now(),
|
|
201
|
+
...(databasePath ? { database_path: databasePath } : {}),
|
|
202
|
+
};
|
|
203
|
+
writeFileDurably(path, JSON.stringify(pin), `${pinId}.tmp`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Whether a pin still protects anything: a restore pin counts only while its database is marked. */
|
|
207
|
+
function pinIsLive(pin: BackupPin): boolean {
|
|
208
|
+
if (!pin.database_path) return true;
|
|
209
|
+
return restoreMarkerPresent(pin.database_path);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Release a claim. Missing is success — the goal is that nothing holds this backup any more. */
|
|
213
|
+
export function unpinBackup(backupDir: string, pinId: string): void {
|
|
214
|
+
removeFileDurably(pinPath(backupDir, pinId));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Refuse to remove any of `backupIds` while something holds a pin on it. An unreadable pin blocks
|
|
219
|
+
* every id: what it was protecting is exactly what cannot be determined.
|
|
220
|
+
*/
|
|
221
|
+
export function assertBackupsUnpinned(backupDir: string, backupIds: number[], databaseName: string): void {
|
|
222
|
+
const pins = readBackupPins(backupDir);
|
|
223
|
+
if (pins.length === 0) return;
|
|
224
|
+
const requested = new Set(backupIds);
|
|
225
|
+
const live: BackupPin[] = [];
|
|
226
|
+
for (const pin of pins) {
|
|
227
|
+
if (pinIsLive(pin)) live.push(pin);
|
|
228
|
+
else {
|
|
229
|
+
// Opportunistic: a lapsed claim we cannot unlink is debris, not a reason to refuse the
|
|
230
|
+
// removal it no longer protects.
|
|
231
|
+
try {
|
|
232
|
+
unpinBackup(backupDir, pin.pin_id);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
logger.warn(`Could not sweep a lapsed backup claim in ${backupDir}`, error);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const blocking = live.filter((pin) => Number.isNaN(pin.backup_id) || requested.has(pin.backup_id));
|
|
239
|
+
if (blocking.length === 0) return;
|
|
240
|
+
const described = blocking
|
|
241
|
+
.map((pin) => `${Number.isNaN(pin.backup_id) ? 'unknown backup' : `backup ${pin.backup_id}`} (${pin.reason})`)
|
|
242
|
+
.join(', ');
|
|
243
|
+
throw new ClientError(
|
|
244
|
+
`Cannot remove backups of database '${databaseName}' that are in use: ${described}. ` +
|
|
245
|
+
`Finish or cancel the operation holding them first.`,
|
|
246
|
+
409
|
|
247
|
+
);
|
|
248
|
+
}
|