@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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { createReadStream, existsSync, readdirSync } from 'node:fs';
|
|
5
5
|
import { open, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
6
7
|
import { join, relative, resolve, sep } from 'node:path';
|
|
7
8
|
import { PassThrough, Writable } from 'node:stream';
|
|
8
9
|
import { pipeline } from 'node:stream/promises';
|
|
@@ -11,6 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
11
12
|
import { pack as tarPack, type Pack } from 'tar-stream';
|
|
12
13
|
import { RocksDatabase, backups, registryStatus, type BackupInfo } from '@harperfast/rocksdb-js';
|
|
13
14
|
import { getDatabases, resolveDatabasePath } from '../resources/databases.ts';
|
|
15
|
+
import { stampDatabaseDirectory } from '../resources/auditStore.ts';
|
|
14
16
|
import {
|
|
15
17
|
type BlobCaptureDisposition,
|
|
16
18
|
classifyBlobFileForCapture,
|
|
@@ -25,7 +27,16 @@ import { CONFIG_PARAMS, OPERATIONS_ENUM } from '../utility/hdbTerms.ts';
|
|
|
25
27
|
import { ClientError } from '../utility/errors/hdbError.ts';
|
|
26
28
|
import * as signalling from '../utility/signalling.ts';
|
|
27
29
|
import { SchemaEventMsg } from '../server/threads/itc.js';
|
|
28
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
beginRestore,
|
|
32
|
+
clearRestoringMarker,
|
|
33
|
+
abandonRestore,
|
|
34
|
+
checkRestoreState,
|
|
35
|
+
releaseRestoreLock,
|
|
36
|
+
type RestoreLock,
|
|
37
|
+
} from './restoreMarker.ts';
|
|
38
|
+
import { assertBackupsUnpinned, pinBackup, unpinBackup, withBackupRepositoryLock } from './backupRepository.ts';
|
|
39
|
+
import { pathPresent } from '../utility/durableFile.ts';
|
|
29
40
|
import {
|
|
30
41
|
assertBlobSnapshotRestorable,
|
|
31
42
|
assertEngineOnlyRestoreAllowed,
|
|
@@ -161,6 +172,35 @@ function requireRocksRootStore(databaseName: string, operation: string): RocksDa
|
|
|
161
172
|
return rootStore as RocksDatabase;
|
|
162
173
|
}
|
|
163
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Apply the engine gate only when there is a loaded database to gate.
|
|
177
|
+
*
|
|
178
|
+
* A repository outlives its database. A restore that failed leaves the database blocked by its
|
|
179
|
+
* restoring marker with the repository intact, and a backup can be imported for a name that does not
|
|
180
|
+
* exist yet — both are exactly the states an operator needs list/verify/delete/purge for. Refusing
|
|
181
|
+
* them because no root store is loaded turns a recoverable situation into a filesystem intervention,
|
|
182
|
+
* which on Fabric the operator cannot perform at all. A name that is neither a loaded database nor a
|
|
183
|
+
* repository is still a 404, so a typo does not silently answer with an empty list.
|
|
184
|
+
*/
|
|
185
|
+
function requireBackupRepositoryAccess(databaseName: string, operation: string): void {
|
|
186
|
+
const loaded = getDatabases()[databaseName];
|
|
187
|
+
if (loaded != null && Object.keys(loaded).length > 0) {
|
|
188
|
+
requireRocksRootStore(databaseName, operation);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
requireBackupRepositoryDirectory(databaseName);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function requireBackupRepositoryDirectory(databaseName: string): string {
|
|
195
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
196
|
+
if (!existsSync(backupDir)) {
|
|
197
|
+
throw new BackupNotFoundError(
|
|
198
|
+
`Database '${databaseName}' is not loaded and has no backup repository at ${backupDir}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return backupDir;
|
|
202
|
+
}
|
|
203
|
+
|
|
164
204
|
function requireBackupId(backupId: any): number {
|
|
165
205
|
if (!Number.isSafeInteger(backupId) || backupId <= 0) {
|
|
166
206
|
throw new ClientError(`'backup_id' must be a positive integer`);
|
|
@@ -189,9 +229,21 @@ function mapLockedError(error: any, databaseName: string): any {
|
|
|
189
229
|
// --- directory helpers (operate on a backup directory only; no open database, usable offline) ---
|
|
190
230
|
|
|
191
231
|
export async function listBackupsInDir(backupDir: string): Promise<BackupInfo[]> {
|
|
192
|
-
// the backup dir doesn't exist until the first create_backup
|
|
193
|
-
|
|
194
|
-
|
|
232
|
+
// the backup dir doesn't exist until the first create_backup. pathPresent, not existsSync: an
|
|
233
|
+
// empty listing is what reconcileHarperManagedBackupFiles reads as "keep nothing", so a
|
|
234
|
+
// permission or I/O fault answering "absent" would license deleting every manifest and blob
|
|
235
|
+
// snapshot in a repository that is merely unreadable.
|
|
236
|
+
if (!pathPresent(backupDir)) return [];
|
|
237
|
+
try {
|
|
238
|
+
return await backups.list(backupDir);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
// Taking the management lock creates the repository directory before anything has written
|
|
241
|
+
// engine metadata into it, so a directory with no metadata is an empty repository, not a
|
|
242
|
+
// failure — otherwise a purge on a database that never had a backup reports the binding's
|
|
243
|
+
// "meta is missing" instead of "no backups found".
|
|
244
|
+
if (!pathPresent(join(backupDir, 'meta'))) return [];
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
195
247
|
}
|
|
196
248
|
|
|
197
249
|
async function findBackup(backupDir: string, backupId: number, databaseName: string): Promise<BackupInfo> {
|
|
@@ -284,6 +336,17 @@ async function resolveCompleteBackup(
|
|
|
284
336
|
return { backupId: latest.backupId, manifest: { backupId: latest.backupId, blobs: latest.blobs, completedAt: 0 } };
|
|
285
337
|
}
|
|
286
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Drop the Harper-managed files — blob snapshots and completion manifests — of every backup the
|
|
341
|
+
* engine no longer has. Derived from what survives rather than from what a caller believes it
|
|
342
|
+
* removed, so a delete or purge that failed partway through still leaves no orphans behind.
|
|
343
|
+
*/
|
|
344
|
+
async function reconcileHarperManagedBackupFiles(backupDir: string): Promise<void> {
|
|
345
|
+
const keepIds = new Set((await listBackupsInDir(backupDir)).map((backup) => backup.backupId));
|
|
346
|
+
await purgeBlobSnapshots(backupDir, keepIds);
|
|
347
|
+
await purgeBackupManifests(backupDir, keepIds);
|
|
348
|
+
}
|
|
349
|
+
|
|
287
350
|
/**
|
|
288
351
|
* Publish a backup's completion manifest after the engine backup and (when included) blob snapshot
|
|
289
352
|
* are durable. On failure, best-effort roll back the just-created engine backup, its partial blob
|
|
@@ -297,6 +360,15 @@ async function finalizeBackup(
|
|
|
297
360
|
): Promise<void> {
|
|
298
361
|
try {
|
|
299
362
|
if (blobs) await snapshotBlobs(backupDir, backupId, getBlobPathsForDatabaseName(databaseName));
|
|
363
|
+
// Guards writers that do not take Harper's lock — an older binary, or a direct binding call.
|
|
364
|
+
// Harper's own purge cannot reach here: the whole create is one critical section.
|
|
365
|
+
// The manifest is what publishes a backup as usable, so writing one for engine files that are
|
|
366
|
+
// gone is the #2031 false-green shape: a backup that lists and verifies with nothing to restore.
|
|
367
|
+
if (!(await listBackupsInDir(backupDir)).some((backup) => backup.backupId === backupId)) {
|
|
368
|
+
throw new BackupNotFoundError(
|
|
369
|
+
`Backup ${backupId} of database '${databaseName}' was removed while it was being finalized; rerun create_backup`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
300
372
|
await writeBackupManifest(backupDir, backupId, blobs);
|
|
301
373
|
} catch (error) {
|
|
302
374
|
await deleteBackupManifest(backupDir, backupId).catch(() => {});
|
|
@@ -312,55 +384,22 @@ export async function listBackups(request: any) {
|
|
|
312
384
|
requireSuperUser(request, OPERATIONS_ENUM.LIST_BACKUPS);
|
|
313
385
|
const databaseName = getDatabaseName(request);
|
|
314
386
|
logger.info(`Listing backups for database '${databaseName}'`);
|
|
315
|
-
|
|
316
|
-
return (
|
|
317
|
-
toBackupResponse(backup, backup.blobs)
|
|
318
|
-
);
|
|
387
|
+
requireBackupRepositoryAccess(databaseName, OPERATIONS_ENUM.LIST_BACKUPS);
|
|
388
|
+
return listBackupsOffline(databaseName);
|
|
319
389
|
}
|
|
320
390
|
|
|
321
391
|
export async function deleteBackup(request: any) {
|
|
322
392
|
requireSuperUser(request, OPERATIONS_ENUM.DELETE_BACKUP);
|
|
323
393
|
const databaseName = getDatabaseName(request);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const backupDir = backupDirForDatabase(databaseName);
|
|
327
|
-
await findBackup(backupDir, backupId, databaseName);
|
|
328
|
-
try {
|
|
329
|
-
await backups.delete(backupDir, backupId);
|
|
330
|
-
} catch (error) {
|
|
331
|
-
throw mapLockedError(error, databaseName);
|
|
332
|
-
}
|
|
333
|
-
// the engine's delete leaves the (Harper-managed) blob snapshot + manifest behind — remove them too
|
|
334
|
-
await deleteBlobSnapshot(backupDir, backupId);
|
|
335
|
-
await deleteBackupManifest(backupDir, backupId);
|
|
336
|
-
return { ok: true };
|
|
394
|
+
requireBackupRepositoryAccess(databaseName, OPERATIONS_ENUM.DELETE_BACKUP);
|
|
395
|
+
return deleteBackupOffline(databaseName, requireBackupId(request.backup_id));
|
|
337
396
|
}
|
|
338
397
|
|
|
339
398
|
export async function purgeBackups(request: any) {
|
|
340
399
|
requireSuperUser(request, OPERATIONS_ENUM.PURGE_BACKUPS);
|
|
341
400
|
const databaseName = getDatabaseName(request);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
|
|
345
|
-
throw new ClientError(`'keep_count' must be a non-negative integer`);
|
|
346
|
-
}
|
|
347
|
-
const backupDir = backupDirForDatabase(databaseName);
|
|
348
|
-
const before = await listBackupsInDir(backupDir);
|
|
349
|
-
if (before.length === 0) {
|
|
350
|
-
throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
|
|
351
|
-
}
|
|
352
|
-
try {
|
|
353
|
-
await backups.purge(backupDir, keepCount);
|
|
354
|
-
} catch (error) {
|
|
355
|
-
throw mapLockedError(error, databaseName);
|
|
356
|
-
}
|
|
357
|
-
const remainingBackups = await listBackupsInDir(backupDir);
|
|
358
|
-
// drop blob snapshots + manifests for every id the engine purged (keep only the survivors')
|
|
359
|
-
const keepIds = new Set(remainingBackups.map((backup) => backup.backupId));
|
|
360
|
-
await purgeBlobSnapshots(backupDir, keepIds);
|
|
361
|
-
await purgeBackupManifests(backupDir, keepIds);
|
|
362
|
-
// clamp: a concurrent create between the two lists can otherwise make this negative
|
|
363
|
-
return { deleted: Math.max(0, before.length - remainingBackups.length), remaining: remainingBackups.length };
|
|
401
|
+
requireBackupRepositoryAccess(databaseName, OPERATIONS_ENUM.PURGE_BACKUPS);
|
|
402
|
+
return purgeBackupsOffline(databaseName, request.keep_count);
|
|
364
403
|
}
|
|
365
404
|
|
|
366
405
|
// --- job operations: create_backup / verify_backup / restore_backup ---
|
|
@@ -380,14 +419,23 @@ export async function createBackup(request: any) {
|
|
|
380
419
|
const excludeBlobs = requireBooleanOption(request.exclude_blobs, 'exclude_blobs');
|
|
381
420
|
const rootStore = requireRocksRootStore(databaseName, OPERATIONS_ENUM.CREATE_BACKUP);
|
|
382
421
|
const backupDir = backupDirForDatabase(databaseName);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
422
|
+
// One critical section, management lock before the engine call: a purge can neither remove the
|
|
423
|
+
// engine backup before the manifest publishes it nor shift which ids it would remove.
|
|
424
|
+
const backupId = await withBackupRepositoryLock(
|
|
425
|
+
backupDir,
|
|
426
|
+
databaseName,
|
|
427
|
+
async () => {
|
|
428
|
+
let id;
|
|
429
|
+
try {
|
|
430
|
+
id = await rootStore.backup(backupDir, { transactionLogs: true });
|
|
431
|
+
} catch (error) {
|
|
432
|
+
throw mapLockedError(error, databaseName);
|
|
433
|
+
}
|
|
434
|
+
await finalizeBackup(backupDir, id, databaseName, !excludeBlobs);
|
|
435
|
+
return id;
|
|
436
|
+
},
|
|
437
|
+
true
|
|
438
|
+
);
|
|
391
439
|
await writeBackupReadme(backupDir, databaseName);
|
|
392
440
|
return {
|
|
393
441
|
database: databaseName,
|
|
@@ -472,7 +520,7 @@ Both remove the RocksDB backup and its blob snapshot.
|
|
|
472
520
|
export async function validateVerifyBackup(request: any) {
|
|
473
521
|
requireSuperUser(request, OPERATIONS_ENUM.VERIFY_BACKUP);
|
|
474
522
|
const databaseName = getDatabaseName(request);
|
|
475
|
-
|
|
523
|
+
requireBackupRepositoryAccess(databaseName, OPERATIONS_ENUM.VERIFY_BACKUP);
|
|
476
524
|
requireBooleanOption(request.verify_checksum, 'verify_checksum');
|
|
477
525
|
const backupDir = backupDirForDatabase(databaseName);
|
|
478
526
|
const backupId = requireBackupId(request.backup_id);
|
|
@@ -482,21 +530,8 @@ export async function validateVerifyBackup(request: any) {
|
|
|
482
530
|
|
|
483
531
|
export async function verifyBackup(request: any) {
|
|
484
532
|
const databaseName = getDatabaseName(request);
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const verifyWithChecksum = requireBooleanOption(request.verify_checksum, 'verify_checksum');
|
|
488
|
-
const backupDir = backupDirForDatabase(databaseName);
|
|
489
|
-
await findBackup(backupDir, backupId, databaseName);
|
|
490
|
-
const manifest = await requireBackupComplete(backupDir, backupId, databaseName);
|
|
491
|
-
await backups.verify(backupDir, backupId, { verifyWithChecksum });
|
|
492
|
-
// verification covers the declared blob snapshot: a backup that recorded blobs must still have its
|
|
493
|
-
// snapshot directory (a manifest without its blobs is a corrupt backup)
|
|
494
|
-
if (manifest.blobs && !existsSync(blobSnapshotDir(backupDir, backupId))) {
|
|
495
|
-
throw new ClientError(
|
|
496
|
-
`Backup ${backupId} of database '${databaseName}' declares captured blobs but its blob snapshot is missing (corrupt backup)`
|
|
497
|
-
);
|
|
498
|
-
}
|
|
499
|
-
return { database: databaseName, backup_id: backupId, ok: true, blobs: manifest.blobs };
|
|
533
|
+
requireBackupRepositoryAccess(databaseName, OPERATIONS_ENUM.VERIFY_BACKUP);
|
|
534
|
+
return verifyBackupOffline(databaseName, requireBackupId(request.backup_id), request.verify_checksum);
|
|
500
535
|
}
|
|
501
536
|
|
|
502
537
|
export async function validateRestoreBackup(request: any) {
|
|
@@ -575,9 +610,17 @@ export async function restoreBackup(request: any) {
|
|
|
575
610
|
// Once is enough: the decision reads the manifest and the opt-in, never the destination, so no
|
|
576
611
|
// concurrent writer can change the answer between here and the purge.
|
|
577
612
|
assertEngineOnlyRestoreAllowed(databaseName, { backupHasBlobs: manifest.blobs, allowEngineOnly });
|
|
578
|
-
const
|
|
613
|
+
const pinId = restorePinId(databaseDir);
|
|
579
614
|
const restoreToken = randomUUID();
|
|
580
615
|
let destructionStarted = false;
|
|
616
|
+
// Re-check before replacing a previous attempt's claim; publish the new claim before the marker
|
|
617
|
+
// under both locks, so a crash cannot leave a marked database with an unprotected source.
|
|
618
|
+
const lock = await withBackupRepositoryLock(backupDir, databaseName, async () => {
|
|
619
|
+
await findBackup(backupDir, backupId, databaseName);
|
|
620
|
+
return beginRestoreForDatabase(databaseDir, databaseName, () =>
|
|
621
|
+
pinBackup(backupDir, pinId, backupId, `restore of database '${databaseName}'`, databaseDir)
|
|
622
|
+
);
|
|
623
|
+
});
|
|
581
624
|
try {
|
|
582
625
|
// Block new blob saves, drain in-flight saves, and close the database across all worker threads.
|
|
583
626
|
// Each thread also rescans, and the restoring marker keeps it from reloading mid-restore.
|
|
@@ -601,6 +644,7 @@ export async function restoreBackup(request: any) {
|
|
|
601
644
|
if (manifest.blobs) {
|
|
602
645
|
await restoreBlobSnapshot(backupDir, backupId, databaseName, getBlobPathsForDatabaseName(databaseName));
|
|
603
646
|
}
|
|
647
|
+
await stampDatabaseDirectory(databaseDir, { carriesLog: true });
|
|
604
648
|
} catch (error: any) {
|
|
605
649
|
// Leave the marker (so startup/rescan detection reports an incomplete restore until a rerun
|
|
606
650
|
// succeeds) when either the destructive purge has begun, OR this attempt was itself a recovery
|
|
@@ -609,6 +653,8 @@ export async function restoreBackup(request: any) {
|
|
|
609
653
|
// partial/corrupt data. Only a *fresh* marker on a *previously healthy* database that failed
|
|
610
654
|
// before any destruction is safe to clear.
|
|
611
655
|
if (destructionStarted || lock.preexisting) {
|
|
656
|
+
// The marker stays, so the database is unloadable until a rerun — and the rerun needs this
|
|
657
|
+
// backup. The pin stays with it, and lapses on its own once the marker is gone.
|
|
612
658
|
abandonRestore(lock);
|
|
613
659
|
// The restore is over even though it failed, so release the workers' blob fence. Without this
|
|
614
660
|
// the fence outlives the attempt: the marker keeps the database from loading, but an operator
|
|
@@ -632,16 +678,39 @@ export async function restoreBackup(request: any) {
|
|
|
632
678
|
}
|
|
633
679
|
// nothing destructive happened and the marker was fresh — clear it and let every thread reload
|
|
634
680
|
// the intact database
|
|
635
|
-
|
|
681
|
+
releaseRestoreClaim(backupDir, pinId, lock, databaseName);
|
|
636
682
|
await signalling.signalSchemaChange(restoreSchemaEvent(databaseName, 'reload', restoreToken, false));
|
|
637
683
|
throw error;
|
|
638
684
|
}
|
|
639
|
-
|
|
685
|
+
releaseRestoreClaim(backupDir, pinId, lock, databaseName);
|
|
640
686
|
// signal again: with the marker gone, every thread's rescan reloads the restored database
|
|
641
687
|
await signalling.signalSchemaChange(restoreSchemaEvent(databaseName, 'reload', restoreToken));
|
|
642
688
|
return { database: databaseName, backup_id: backupId, ...(allowEngineOnly ? { allow_engine_only: true } : {}) };
|
|
643
689
|
}
|
|
644
690
|
|
|
691
|
+
/**
|
|
692
|
+
* Retire a finished restore: marker, then claim, then the destination lock. While the database is
|
|
693
|
+
* still marked a rerun is required and the rerun needs this backup, so the claim has to outlive the
|
|
694
|
+
* marker — a crash the other way round leaves an unloadable database whose source a purge may
|
|
695
|
+
* delete. Both happen under the restore lock, so no later attempt can have written a claim at this
|
|
696
|
+
* same id yet; one stranded after the marker is gone simply lapses and is swept.
|
|
697
|
+
*/
|
|
698
|
+
function releaseRestoreClaim(backupDir: string, pinId: string, lock: RestoreLock, databaseName: string): void {
|
|
699
|
+
try {
|
|
700
|
+
clearRestoringMarker(lock);
|
|
701
|
+
try {
|
|
702
|
+
unpinBackup(backupDir, pinId);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
logger.error(
|
|
705
|
+
`Could not release the backup claim after restoring '${databaseName}'; it lapses with the marker`,
|
|
706
|
+
error
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
} finally {
|
|
710
|
+
releaseRestoreLock(lock);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
645
714
|
/**
|
|
646
715
|
* `restoreToken` identifies the restore that owns the blob fence. Two restores of the same database
|
|
647
716
|
* can overlap, so a worker releases the fence only for the token that established it -- see
|
|
@@ -700,9 +769,21 @@ async function verifyDatabaseClosed(databaseDir: string, databaseName: string):
|
|
|
700
769
|
* beginRestore's own error message carries the filesystem path (useful in CLI/server logs);
|
|
701
770
|
* client-facing operations report by database name instead.
|
|
702
771
|
*/
|
|
703
|
-
|
|
772
|
+
/**
|
|
773
|
+
* One pin per target database, not per attempt: two attempts can never collide on it, and a rerun
|
|
774
|
+
* after a failed restore reuses the claim the failed attempt left protecting its source.
|
|
775
|
+
*/
|
|
776
|
+
function restorePinId(databaseDir: string): string {
|
|
777
|
+
return `restore-${createHash('sha256').update(resolve(databaseDir)).digest('hex').slice(0, 32)}`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function beginRestoreForDatabase(
|
|
781
|
+
databaseDir: string,
|
|
782
|
+
databaseName: string,
|
|
783
|
+
beforePublishMarker: () => void
|
|
784
|
+
): RestoreLock {
|
|
704
785
|
try {
|
|
705
|
-
return beginRestore(databaseDir);
|
|
786
|
+
return beginRestore(databaseDir, beforePublishMarker);
|
|
706
787
|
} catch (error) {
|
|
707
788
|
if (error.statusCode === 409) {
|
|
708
789
|
throw new BackupInProgressError(`Restore already in progress for database '${databaseName}'`);
|
|
@@ -1010,13 +1091,21 @@ export async function createBackupOffline(databaseName: string, excludeBlobs = f
|
|
|
1010
1091
|
const database = RocksDatabase.open(databaseDir);
|
|
1011
1092
|
try {
|
|
1012
1093
|
const backupDir = backupDirForDatabase(databaseName);
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1094
|
+
const backupId = await withBackupRepositoryLock(
|
|
1095
|
+
backupDir,
|
|
1096
|
+
databaseName,
|
|
1097
|
+
async () => {
|
|
1098
|
+
let id;
|
|
1099
|
+
try {
|
|
1100
|
+
id = await database.backup(backupDir, { transactionLogs: true });
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
throw mapLockedError(error, databaseName);
|
|
1103
|
+
}
|
|
1104
|
+
await finalizeBackup(backupDir, id, databaseName, !excludeBlobs);
|
|
1105
|
+
return id;
|
|
1106
|
+
},
|
|
1107
|
+
true
|
|
1108
|
+
);
|
|
1020
1109
|
await writeBackupReadme(backupDir, databaseName);
|
|
1021
1110
|
return {
|
|
1022
1111
|
database: databaseName,
|
|
@@ -1064,10 +1153,21 @@ export async function restoreBackupOffline(
|
|
|
1064
1153
|
const blobRoots = getBlobPathsForDatabaseName(targetDatabase ?? databaseName);
|
|
1065
1154
|
await assertBlobSnapshotRestorable(backupDir, backupId, blobRoots);
|
|
1066
1155
|
assertEngineOnlyRestoreAllowed(targetDatabase ?? databaseName, { backupHasBlobs: manifest.blobs, allowEngineOnly });
|
|
1067
|
-
|
|
1068
|
-
// marker and refuses to load the database (closing the window between the probe and the purge).
|
|
1069
|
-
const lock = beginRestoreForDatabase(databaseDir, targetDatabase ?? databaseName);
|
|
1156
|
+
const pinId = restorePinId(databaseDir);
|
|
1070
1157
|
let destructionStarted = false;
|
|
1158
|
+
// As online, claim before marking under both locks, and mark before probing the destination.
|
|
1159
|
+
const lock = await withBackupRepositoryLock(backupDir, databaseName, async () => {
|
|
1160
|
+
await findBackup(backupDir, backupId as number, databaseName);
|
|
1161
|
+
return beginRestoreForDatabase(databaseDir, targetDatabase ?? databaseName, () =>
|
|
1162
|
+
pinBackup(
|
|
1163
|
+
backupDir,
|
|
1164
|
+
pinId,
|
|
1165
|
+
backupId as number,
|
|
1166
|
+
`restore of database '${targetDatabase ?? databaseName}'`,
|
|
1167
|
+
databaseDir
|
|
1168
|
+
)
|
|
1169
|
+
);
|
|
1170
|
+
});
|
|
1071
1171
|
try {
|
|
1072
1172
|
// The offline path is entered only when the CLI sees no running server (getHdbPid), but that is
|
|
1073
1173
|
// a heuristic: the PID file is briefly absent mid-`harper restart`, and backups.restore's
|
|
@@ -1101,12 +1201,17 @@ export async function restoreBackupOffline(
|
|
|
1101
1201
|
getBlobPathsForDatabaseName(targetDatabase ?? databaseName)
|
|
1102
1202
|
);
|
|
1103
1203
|
}
|
|
1204
|
+
await stampDatabaseDirectory(databaseDir, { carriesLog: true });
|
|
1104
1205
|
} catch (error: any) {
|
|
1105
1206
|
// Preserve the marker on a destructive failure or a recovery over a pre-existing marker (see
|
|
1106
1207
|
// the online restoreBackup for the rationale); otherwise clear the fresh marker so an intact,
|
|
1107
1208
|
// merely-locked database is not left flagged as an incomplete restore.
|
|
1209
|
+
// The pin stays exactly as long as the marker does: a retained marker means a rerun is required,
|
|
1210
|
+
// and the rerun needs this backup to still be there.
|
|
1108
1211
|
if (destructionStarted || lock.preexisting) abandonRestore(lock);
|
|
1109
|
-
else
|
|
1212
|
+
else {
|
|
1213
|
+
releaseRestoreClaim(backupDir, pinId, lock, databaseName);
|
|
1214
|
+
}
|
|
1110
1215
|
// preserve typed client errors (e.g. the 409 lock probe) unwrapped; only wrap an opaque restore
|
|
1111
1216
|
// failure after destruction has begun
|
|
1112
1217
|
if (destructionStarted && !(error instanceof ClientError)) {
|
|
@@ -1117,7 +1222,7 @@ export async function restoreBackupOffline(
|
|
|
1117
1222
|
}
|
|
1118
1223
|
throw error;
|
|
1119
1224
|
}
|
|
1120
|
-
|
|
1225
|
+
releaseRestoreClaim(backupDir, pinId, lock, databaseName);
|
|
1121
1226
|
return {
|
|
1122
1227
|
database: databaseName,
|
|
1123
1228
|
backup_id: backupId,
|
|
@@ -1135,7 +1240,10 @@ function isMissingOrEmptyDir(path: string): boolean {
|
|
|
1135
1240
|
}
|
|
1136
1241
|
}
|
|
1137
1242
|
|
|
1138
|
-
// ---
|
|
1243
|
+
// --- directory-only management implementations ---
|
|
1244
|
+
// What the CLI runs when the local server is stopped, and what the online operations delegate to
|
|
1245
|
+
// once their engine gate has passed. One implementation, so the two paths cannot drift, and so a
|
|
1246
|
+
// repository whose database is absent or blocked by a restore stays manageable.
|
|
1139
1247
|
|
|
1140
1248
|
export async function listBackupsOffline(databaseName: string) {
|
|
1141
1249
|
validateDatabaseName(databaseName);
|
|
@@ -1162,16 +1270,29 @@ export async function verifyBackupOffline(databaseName: string, backupId: number
|
|
|
1162
1270
|
|
|
1163
1271
|
export async function deleteBackupOffline(databaseName: string, backupId: number) {
|
|
1164
1272
|
validateDatabaseName(databaseName);
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
await
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1273
|
+
requireBackupId(backupId);
|
|
1274
|
+
const backupDir = requireBackupRepositoryDirectory(databaseName);
|
|
1275
|
+
return withBackupRepositoryLock(backupDir, databaseName, async () => {
|
|
1276
|
+
await findBackup(backupDir, backupId, databaseName);
|
|
1277
|
+
assertBackupsUnpinned(backupDir, [backupId], databaseName);
|
|
1278
|
+
let engineSucceeded = false;
|
|
1279
|
+
try {
|
|
1280
|
+
await backups.delete(backupDir, backupId);
|
|
1281
|
+
engineSucceeded = true;
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
throw mapLockedError(error, databaseName);
|
|
1284
|
+
} finally {
|
|
1285
|
+
// The engine's delete leaves the Harper-managed blob snapshot + manifest behind, and it can
|
|
1286
|
+
// fail after removing engine files — so reconcile against what survives rather than assuming
|
|
1287
|
+
// this id was the only thing that changed.
|
|
1288
|
+
// Preserve a primary engine error, but never report success when cleanup failed.
|
|
1289
|
+
await reconcileHarperManagedBackupFiles(backupDir).catch((error) => {
|
|
1290
|
+
if (engineSucceeded) throw error;
|
|
1291
|
+
logger.warn(`Could not reconcile Harper-managed backup files in ${backupDir}`, error);
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
return { ok: true };
|
|
1295
|
+
});
|
|
1175
1296
|
}
|
|
1176
1297
|
|
|
1177
1298
|
export async function purgeBackupsOffline(databaseName: string, keepCount: number) {
|
|
@@ -1179,19 +1300,41 @@ export async function purgeBackupsOffline(databaseName: string, keepCount: numbe
|
|
|
1179
1300
|
if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
|
|
1180
1301
|
throw new ClientError(`'keep_count' must be a non-negative integer`);
|
|
1181
1302
|
}
|
|
1182
|
-
const backupDir =
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1303
|
+
const backupDir = requireBackupRepositoryDirectory(databaseName);
|
|
1304
|
+
return withBackupRepositoryLock(backupDir, databaseName, async () => {
|
|
1305
|
+
const before = await listBackupsInDir(backupDir);
|
|
1306
|
+
if (before.length === 0) {
|
|
1307
|
+
throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
|
|
1308
|
+
}
|
|
1309
|
+
// Every Harper writer is excluded by this lock, so `before` is exactly what the binding will
|
|
1310
|
+
// see, and `purge` keeps the newest `keepCount` by id — so the ids it will remove can be named
|
|
1311
|
+
// here and checked against the claims before it runs. One bulk call then does the removal.
|
|
1312
|
+
// Sorted rather than sliced off the listing as returned: this set gates claim protection, so it
|
|
1313
|
+
// does not rest on the documented list order holding, matching resolveCompleteBackup below.
|
|
1314
|
+
const removing = [...before]
|
|
1315
|
+
.sort((first, second) => first.backupId - second.backupId)
|
|
1316
|
+
.slice(0, Math.max(0, before.length - keepCount))
|
|
1317
|
+
.map((backup) => backup.backupId);
|
|
1318
|
+
assertBackupsUnpinned(backupDir, removing, databaseName);
|
|
1319
|
+
let engineSucceeded = false;
|
|
1320
|
+
try {
|
|
1321
|
+
await backups.purge(backupDir, keepCount);
|
|
1322
|
+
engineSucceeded = true;
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
throw mapLockedError(error, databaseName);
|
|
1325
|
+
} finally {
|
|
1326
|
+
// Reconciled from what actually survives, in a finally: a purge that failed partway through
|
|
1327
|
+
// still removed engine backups, and their blob snapshots would otherwise be orphaned on disk
|
|
1328
|
+
// — invisible to list_backups and still charged to the tenant's quota.
|
|
1329
|
+
// Preserve a primary engine error, but never report success when cleanup failed.
|
|
1330
|
+
await reconcileHarperManagedBackupFiles(backupDir).catch((error) => {
|
|
1331
|
+
if (engineSucceeded) throw error;
|
|
1332
|
+
logger.warn(`Could not reconcile Harper-managed backup files in ${backupDir}`, error);
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
// Counted from what actually survives rather than from `removing`: no create can land inside
|
|
1336
|
+
// this lock, so the difference is exactly what the purge removed.
|
|
1337
|
+
const remaining = (await listBackupsInDir(backupDir)).length;
|
|
1338
|
+
return { deleted: before.length - remaining, remaining };
|
|
1339
|
+
});
|
|
1197
1340
|
}
|
package/dist/bin/copyDb.js
CHANGED
|
@@ -938,6 +938,9 @@ async function copyDbToRocks(sourceRootStore, sourceDatabase, targetPath) {
|
|
|
938
938
|
if (idMappingBytes) {
|
|
939
939
|
targetRootStore.putSync(REMOTE_NODE_IDS_KEY, (0, lmdb_1.asBinary)(idMappingBytes));
|
|
940
940
|
}
|
|
941
|
+
// flushed so the stamp is durable before the caller publishes the staging directory
|
|
942
|
+
(0, auditStore_ts_1.stampDatabaseGeneration)(targetRootStore, { carriesLog: false });
|
|
943
|
+
await targetRootStore.flush({ allowWriteStall: true });
|
|
941
944
|
console.log('migrated database ' + sourceDatabase + ' to RocksDB');
|
|
942
945
|
}
|
|
943
946
|
finally {
|