@harperfast/rocksdb-js 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +205 -12
- package/bin/rocksdb-js.mjs +67 -3
- package/dist/index.cjs +600 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1610 -1365
- package/dist/index.d.mts +1610 -1365
- package/dist/index.mjs +601 -57
- package/dist/index.mjs.map +1 -1
- package/docs/backups.md +253 -0
- package/docs/stats.md +2 -0
- package/docs/transaction-log.md +10 -0
- package/package.json +23 -23
package/dist/index.cjs
CHANGED
|
@@ -114,16 +114,248 @@ const coolTransactionLogs = binding.coolTransactionLogs;
|
|
|
114
114
|
* the underlying mapping rather than leaving it retained.
|
|
115
115
|
*/
|
|
116
116
|
const transactionLogMapCount = binding.transactionLogMapCount;
|
|
117
|
+
/**
|
|
118
|
+
* Test-only: force the next `count` transaction commits to fail with TryAgain (rolled back, so
|
|
119
|
+
* no data is committed), reproducing a stranded-snapshot conflict deterministically. Pass 0 to
|
|
120
|
+
* disarm. Used by the ERR_TRY_AGAIN retry regression test.
|
|
121
|
+
*/
|
|
122
|
+
const forceTryAgainForTesting = binding.forceTryAgainForTesting;
|
|
123
|
+
/**
|
|
124
|
+
* Creates a native file lock using the specified file path (`flock` on POSIX,
|
|
125
|
+
* `LockFileEx` on Windows), creating the file and any missing parent
|
|
126
|
+
* directories. Exclusive by default; pass `shared` for a shared (reader) lock
|
|
127
|
+
* that coexists with other shared holders but conflicts with an exclusive
|
|
128
|
+
* holder in either direction. Returns an opaque non-zero token to pass to
|
|
129
|
+
* `fileLockRelease`, or `0` if a conflicting holder — in any process,
|
|
130
|
+
* container, or worker thread — currently has it. Throws on a hard error. The
|
|
131
|
+
* OS handle is owned entirely in native code (no fd crosses into JS), and the
|
|
132
|
+
* kernel releases the lock when the handle closes, including on process death.
|
|
133
|
+
*/
|
|
134
|
+
const tryFileLock = binding.tryFileLock;
|
|
135
|
+
/**
|
|
136
|
+
* Releases a file lock acquired via `tryFileLock`. A no-op for
|
|
137
|
+
* token `0` or an unknown token.
|
|
138
|
+
*/
|
|
139
|
+
const fileLockRelease = binding.fileLockRelease;
|
|
117
140
|
const nativeBackupRestore = binding.backupRestore;
|
|
118
141
|
const nativeBackupList = binding.backupList;
|
|
119
142
|
const nativeBackupDelete = binding.backupDelete;
|
|
120
143
|
const nativeBackupPurge = binding.backupPurge;
|
|
121
144
|
const nativeBackupVerify = binding.backupVerify;
|
|
145
|
+
const nativeValidateTransactionLog = binding.validateTransactionLog;
|
|
122
146
|
const stats = binding.stats;
|
|
123
147
|
const version = binding.version;
|
|
124
148
|
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/validate-transaction-log.ts
|
|
151
|
+
/**
|
|
152
|
+
* Validates a transaction log store directory: every `<sequence>.txnlog` file
|
|
153
|
+
* is checked for a valid header (token, version) and intact v1 entry framing
|
|
154
|
+
* (using the same scan as open-time crash recovery), file-name and sequence
|
|
155
|
+
* continuity are checked, and the `txn.state` side file (when present) is
|
|
156
|
+
* checked for shape and a plausible flushed position. The result reports
|
|
157
|
+
* per-file entry counts, errors, and warnings; it does not throw when the
|
|
158
|
+
* store is invalid — check `result.valid`.
|
|
159
|
+
*
|
|
160
|
+
* Runs entirely in native code on a worker thread and does not require (or
|
|
161
|
+
* open) a database, so it works on the store of a closed database and on the
|
|
162
|
+
* per-backup snapshots under `<backupDir>/transaction_logs/<backupId>/` alike.
|
|
163
|
+
*
|
|
164
|
+
* Validating a store that is being actively appended to can spuriously report
|
|
165
|
+
* a torn tail for the current log file — the tail of an in-flight append is
|
|
166
|
+
* indistinguishable from a crash artifact.
|
|
167
|
+
*
|
|
168
|
+
* Rejects if `path` does not exist or is not a directory (a bad argument, as
|
|
169
|
+
* opposed to an invalid store).
|
|
170
|
+
*
|
|
171
|
+
* @param path The store directory, e.g. `<dbDir>/transaction_logs/<name>`.
|
|
172
|
+
* @param options Validation options.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```typescript
|
|
176
|
+
* import { validateTransactionLogStore } from '@harperfast/rocksdb-js';
|
|
177
|
+
*
|
|
178
|
+
* const result = await validateTransactionLogStore('/path/to/db/transaction_logs/mylog');
|
|
179
|
+
* if (!result.valid) {
|
|
180
|
+
* for (const file of result.files) {
|
|
181
|
+
* console.error(file.file, file.errors);
|
|
182
|
+
* }
|
|
183
|
+
* }
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
function validateTransactionLogStore(path, options) {
|
|
187
|
+
return new Promise((resolve, reject) => nativeValidateTransactionLog(resolve, reject, path, options?.strict ?? false));
|
|
188
|
+
}
|
|
189
|
+
|
|
125
190
|
//#endregion
|
|
126
191
|
//#region src/backup.ts
|
|
192
|
+
/** Subdirectory (under a backup directory) holding per-backup transaction log snapshots. */
|
|
193
|
+
const TRANSACTION_LOGS_DIRNAME = "transaction_logs";
|
|
194
|
+
/** Non-blocking existence check (`fs.existsSync` would block the event loop). */
|
|
195
|
+
async function exists(path) {
|
|
196
|
+
try {
|
|
197
|
+
await (0, node_fs_promises.access)(path);
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Runs `fn` while holding a native file lock for `backupDir`, releasing it when
|
|
205
|
+
* `fn` settles. The lock is exclusive by default — used by the writable-engine
|
|
206
|
+
* operations `backups.delete` and `backups.purge`; backup creation
|
|
207
|
+
* (`Store.backup`) takes the same exclusive lock on the same file natively
|
|
208
|
+
* inside `Database::Backup` (see `runCreateBackup` in
|
|
209
|
+
* `src/binding/database/backup.cpp`), where the backup directory is also
|
|
210
|
+
* created. Throws immediately (without running `fn`) if the backup directory
|
|
211
|
+
* is missing — `tryFileLock` creates missing parents, but `delete`/`purge`/
|
|
212
|
+
* `restore` on a nonexistent directory is a caller error and must not conjure
|
|
213
|
+
* an empty one — or if a conflicting holder has the directory lock.
|
|
214
|
+
*
|
|
215
|
+
* `backups.restore` holds the lock in `shared` mode for its source read:
|
|
216
|
+
* shared holders coexist (concurrent restores are safe), but a writer's
|
|
217
|
+
* exclusive acquisition rejects while a restore is in flight — and vice versa.
|
|
218
|
+
* Without this, a `purge`/`delete` racing a restore can delete the very files
|
|
219
|
+
* the restore is copying, after the restore's default `purgeAllFiles` mode has
|
|
220
|
+
* already wiped the destination — failing the restore with no fallback. A
|
|
221
|
+
* rejected writer, by contrast, just retries later.
|
|
222
|
+
*
|
|
223
|
+
* The remaining read-only operations (`list`, `verify`) use
|
|
224
|
+
* `BackupEngineReadOnly` and are not locked: concurrent readers are safe, and
|
|
225
|
+
* locking them would make cheap listings reject during a long backup. Running
|
|
226
|
+
* them concurrently with a `delete`/`purge` on the same directory is a
|
|
227
|
+
* caller-managed hazard, matching RocksDB's one-writable-engine-per-dir model.
|
|
228
|
+
*
|
|
229
|
+
* RocksDB only serializes work within a single `BackupEngine` instance and has
|
|
230
|
+
* no lock on the backup directory itself. Two writable engines — in any
|
|
231
|
+
* processes — creating/deleting/purging backups in the same directory
|
|
232
|
+
* concurrently race on the per-backup staging directory and both fail,
|
|
233
|
+
* potentially leaving the directory with no usable backup. An in-memory lock
|
|
234
|
+
* cannot prevent this across processes, so the lock lives on disk: a kernel
|
|
235
|
+
* advisory lock (`flock` on POSIX, `LockFileEx` on Windows) held on a
|
|
236
|
+
* `.backup.lock` file at the directory root.
|
|
237
|
+
*
|
|
238
|
+
* The lock is taken entirely in native code (`tryFileLock`), which opens,
|
|
239
|
+
* locks, and later closes the file's OS handle without ever exposing a
|
|
240
|
+
* descriptor to JS — the addon statically links its own C runtime, so a
|
|
241
|
+
* Node/libuv fd is not usable across that boundary. The kernel owning the lock
|
|
242
|
+
* buys two properties a pidfile cannot:
|
|
243
|
+
*
|
|
244
|
+
* - No staleness heuristic. The lock is released when the holder's handle closes
|
|
245
|
+
* — normal release, crash, `kill -9`, container exit — so there is nothing to
|
|
246
|
+
* reclaim and no liveness check.
|
|
247
|
+
* - True cross-context exclusion. The lock conflicts across processes, containers
|
|
248
|
+
* sharing a volume (same kernel), and `worker_threads`.
|
|
249
|
+
*
|
|
250
|
+
* The lock file is never deleted, only locked and unlocked; an unlocked, empty
|
|
251
|
+
* `.backup.lock` at the directory root is the steady state.
|
|
252
|
+
*/
|
|
253
|
+
async function withBackupDirLock(backupDir, fn, options) {
|
|
254
|
+
if (!await exists(backupDir)) throw new Error(`Backup directory does not exist: ${backupDir}`);
|
|
255
|
+
const token = tryFileLock((0, node_path.join)(backupDir, ".backup.lock"), options?.shared === true);
|
|
256
|
+
if (token === 0) throw new Error(`Backup directory is locked: ${backupDir}`);
|
|
257
|
+
try {
|
|
258
|
+
return await fn();
|
|
259
|
+
} finally {
|
|
260
|
+
fileLockRelease(token);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Throws if `backupDir` resolves to the database directory itself or any path
|
|
265
|
+
* beneath it. Backing up into the live database directory would write backup
|
|
266
|
+
* files (the `.backup.lock`, `meta/`, `shared/`, `private/` subtrees) on top of
|
|
267
|
+
* RocksDB's own files, and every subsequent backup would recursively capture
|
|
268
|
+
* the prior ones. Both paths are resolved first so trailing slashes and
|
|
269
|
+
* relative/absolute variants of the same location cannot slip past the check.
|
|
270
|
+
*
|
|
271
|
+
* Only the "backup dir at or under the database dir" direction is rejected —
|
|
272
|
+
* the case the user hits by pointing a backup at `<db>` or `<db>/backups`. The
|
|
273
|
+
* reverse (a database nested under the backup dir) is left to the caller: a
|
|
274
|
+
* backup engine writes into named subtrees, not on top of arbitrary sibling
|
|
275
|
+
* files, so it does not clobber the database in place.
|
|
276
|
+
*
|
|
277
|
+
* On case-insensitive filesystems `<db>` and `<db>/../DB/backup` are the same
|
|
278
|
+
* directory on disk, but `relative()` compares case-sensitively and would call
|
|
279
|
+
* the second one "outside". We case-fold both paths on the platforms whose
|
|
280
|
+
* default filesystem is case-insensitive (macOS, Windows) so a casing
|
|
281
|
+
* difference cannot slip a backup into the database directory. This is a
|
|
282
|
+
* platform heuristic, not a per-volume probe (a case-sensitive macOS/Windows
|
|
283
|
+
* volume exists), so it can over-reject a legitimately differently-cased
|
|
284
|
+
* sibling there — the safe direction for a guard against clobbering the live
|
|
285
|
+
* database.
|
|
286
|
+
*/
|
|
287
|
+
function assertBackupDirOutsideDatabase(dbPath, backupDir) {
|
|
288
|
+
let resolvedDb = (0, node_path.resolve)(dbPath);
|
|
289
|
+
let resolvedBackup = (0, node_path.resolve)(backupDir);
|
|
290
|
+
if (process.platform === "win32" || process.platform === "darwin") {
|
|
291
|
+
resolvedDb = resolvedDb.toLowerCase();
|
|
292
|
+
resolvedBackup = resolvedBackup.toLowerCase();
|
|
293
|
+
}
|
|
294
|
+
const rel = (0, node_path.relative)(resolvedDb, resolvedBackup);
|
|
295
|
+
if (!(rel === ".." || rel.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(rel))) throw new Error(`Backup directory must not be inside the database directory: ${backupDir} is at or within ${dbPath}`);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Removes `<backupDir>/transaction_logs/<id>` subtrees whose backup id no longer
|
|
299
|
+
* corresponds to a surviving backup. Called after a `purge` (which holds the
|
|
300
|
+
* backup-dir lock); a no-op when no transaction logs were backed up. Self-healing:
|
|
301
|
+
* it drops every log subtree without a live backup. Note the surviving set comes
|
|
302
|
+
* from `nativeBackupList`, which omits corrupt backups — a still-present but
|
|
303
|
+
* corrupt backup's logs may be pruned (acceptable: it cannot be restored anyway).
|
|
304
|
+
* This also sweeps `.staging-*` leftovers from a crashed backup process (a
|
|
305
|
+
* staging name never matches a live backup id); backup creation sweeps them
|
|
306
|
+
* natively as well, under the backup-dir lock.
|
|
307
|
+
*/
|
|
308
|
+
async function pruneOrphanedTransactionLogs(backupDir) {
|
|
309
|
+
const logsRoot = (0, node_path.join)(backupDir, TRANSACTION_LOGS_DIRNAME);
|
|
310
|
+
let ids;
|
|
311
|
+
try {
|
|
312
|
+
ids = await (0, node_fs_promises.readdir)(logsRoot);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if (err.code === "ENOENT") return;
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
317
|
+
const list = await new Promise((resolve, reject) => nativeBackupList(resolve, reject, backupDir));
|
|
318
|
+
const surviving = new Set(list.map((info) => String(info.backupId)));
|
|
319
|
+
await Promise.all(ids.filter((id) => !surviving.has(id)).map((id) => (0, node_fs_promises.rm)((0, node_path.join)(logsRoot, id), {
|
|
320
|
+
recursive: true,
|
|
321
|
+
force: true
|
|
322
|
+
})));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Restores the transaction log snapshot for the restored backup into
|
|
326
|
+
* `<dbDir>/transaction_logs/`, wiping the destination first so that restoring an
|
|
327
|
+
* older backup over a newer one cannot leave stale (newer) log files behind.
|
|
328
|
+
*
|
|
329
|
+
* Only acts when THIS backup actually captured logs: if the restored id has no
|
|
330
|
+
* snapshot, the destination is left untouched. Wiping unconditionally would
|
|
331
|
+
* destroy on-disk logs that a mixed-mode (`transactionLogs:false`) backup never
|
|
332
|
+
* managed.
|
|
333
|
+
*
|
|
334
|
+
* Offline only: the files are picked up when the database is next opened. mtimes
|
|
335
|
+
* are preserved because the store derives file age (rotation/retention) from
|
|
336
|
+
* mtime — a fresh mtime would break retention.
|
|
337
|
+
*/
|
|
338
|
+
async function restoreTransactionLogs(backupDir, dbDir, backupId) {
|
|
339
|
+
const logsRoot = (0, node_path.join)(backupDir, TRANSACTION_LOGS_DIRNAME);
|
|
340
|
+
if (!await exists(logsRoot)) return;
|
|
341
|
+
let id = backupId;
|
|
342
|
+
if (id === void 0) {
|
|
343
|
+
const list = await new Promise((resolve, reject) => nativeBackupList(resolve, reject, backupDir));
|
|
344
|
+
if (list.length === 0) return;
|
|
345
|
+
id = Math.max(...list.map((info) => info.backupId));
|
|
346
|
+
}
|
|
347
|
+
const logsSrc = (0, node_path.join)(logsRoot, String(id));
|
|
348
|
+
if (!await exists(logsSrc)) return;
|
|
349
|
+
const logsDest = (0, node_path.join)(dbDir, TRANSACTION_LOGS_DIRNAME);
|
|
350
|
+
await (0, node_fs_promises.rm)(logsDest, {
|
|
351
|
+
recursive: true,
|
|
352
|
+
force: true
|
|
353
|
+
});
|
|
354
|
+
await (0, node_fs_promises.cp)(logsSrc, logsDest, {
|
|
355
|
+
recursive: true,
|
|
356
|
+
preserveTimestamps: true
|
|
357
|
+
});
|
|
358
|
+
}
|
|
127
359
|
/**
|
|
128
360
|
* Backup management operations that act on a backup directory and do not
|
|
129
361
|
* require an open database. To create a backup, use the `db.backup()` instance
|
|
@@ -143,17 +375,32 @@ const backups = {
|
|
|
143
375
|
* Restores a database from a backup directory into a (closed) database
|
|
144
376
|
* directory. The default mode purges the destination directory, so it must
|
|
145
377
|
* not point at a live database.
|
|
378
|
+
*
|
|
379
|
+
* Holds the backup directory lock in shared mode for the duration: multiple
|
|
380
|
+
* restores can read concurrently, but a writer (`db.backup()`, `delete`,
|
|
381
|
+
* `purge`) rejects while a restore is in flight rather than deleting files
|
|
382
|
+
* out from under it — and a restore rejects while a writer holds the lock.
|
|
383
|
+
*
|
|
384
|
+
* The shared lock needs no write access to `backupDir`, so restoring from a
|
|
385
|
+
* read-only backup directory (immutable/WORM store, read-only-mounted volume)
|
|
386
|
+
* works: the lock is taken on an existing `.backup.lock` read-only, degrading
|
|
387
|
+
* to a no-op only when the media is read-only for every process (`EROFS`). A
|
|
388
|
+
* mere permission denial hard-fails instead — it doesn't prove a privileged
|
|
389
|
+
* writer isn't running.
|
|
146
390
|
*/
|
|
147
391
|
async restore(backupDir, dbDir, options) {
|
|
148
392
|
if ((0, node_path.resolve)(backupDir) === (0, node_path.resolve)(dbDir)) throw new Error("Backup directory and database directory must be different");
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
393
|
+
return withBackupDirLock(backupDir, async () => {
|
|
394
|
+
const walDir = options?.walDir ?? dbDir;
|
|
395
|
+
await (0, node_fs_promises.mkdir)(dbDir, { recursive: true });
|
|
396
|
+
if (walDir !== dbDir) await (0, node_fs_promises.mkdir)(walDir, { recursive: true });
|
|
397
|
+
await new Promise((resolve, reject) => nativeBackupRestore(resolve, reject, backupDir, dbDir, walDir, {
|
|
398
|
+
backupId: options?.backupId,
|
|
399
|
+
keepLogFiles: options?.keepLogFiles,
|
|
400
|
+
mode: options?.mode
|
|
401
|
+
}));
|
|
402
|
+
await restoreTransactionLogs(backupDir, dbDir, options?.backupId);
|
|
403
|
+
}, { shared: true });
|
|
157
404
|
},
|
|
158
405
|
/**
|
|
159
406
|
* Lists the non-corrupt backups in a backup directory, ordered by id.
|
|
@@ -165,21 +412,53 @@ const backups = {
|
|
|
165
412
|
* Deletes a specific backup. Shared files are reference-counted and only
|
|
166
413
|
* removed once no remaining backup references them.
|
|
167
414
|
*/
|
|
168
|
-
delete(backupDir, backupId) {
|
|
169
|
-
return
|
|
415
|
+
async delete(backupDir, backupId) {
|
|
416
|
+
return withBackupDirLock(backupDir, async () => {
|
|
417
|
+
await new Promise((resolve, reject) => nativeBackupDelete(resolve, reject, backupDir, backupId));
|
|
418
|
+
try {
|
|
419
|
+
await (0, node_fs_promises.rm)((0, node_path.join)(backupDir, TRANSACTION_LOGS_DIRNAME, String(backupId)), {
|
|
420
|
+
recursive: true,
|
|
421
|
+
force: true
|
|
422
|
+
});
|
|
423
|
+
} catch {}
|
|
424
|
+
});
|
|
170
425
|
},
|
|
171
426
|
/**
|
|
172
427
|
* Deletes all but the newest `keepCount` backups.
|
|
173
428
|
*/
|
|
174
|
-
purge(backupDir, keepCount) {
|
|
175
|
-
return
|
|
429
|
+
async purge(backupDir, keepCount) {
|
|
430
|
+
return withBackupDirLock(backupDir, async () => {
|
|
431
|
+
await new Promise((resolve, reject) => nativeBackupPurge(resolve, reject, backupDir, keepCount));
|
|
432
|
+
try {
|
|
433
|
+
await pruneOrphanedTransactionLogs(backupDir);
|
|
434
|
+
} catch {}
|
|
435
|
+
});
|
|
176
436
|
},
|
|
177
437
|
/**
|
|
178
438
|
* Verifies a backup's file sizes, and optionally their checksums (which
|
|
179
439
|
* requires reading all backed-up data).
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
440
|
+
*
|
|
441
|
+
* When the backup was created with `transactionLogs: true`, the backup's
|
|
442
|
+
* transaction log snapshot (`<backupDir>/transaction_logs/<backupId>/`) is
|
|
443
|
+
* also validated — every log file's header and entry framing must be intact
|
|
444
|
+
* (snapshots are copied on committed entry boundaries, so even a torn tail
|
|
445
|
+
* is a verification failure). Set `verifyTransactionLogs: false` to skip.
|
|
446
|
+
*/
|
|
447
|
+
async verify(backupDir, backupId, options) {
|
|
448
|
+
await new Promise((resolve, reject) => nativeBackupVerify(resolve, reject, backupDir, backupId, options?.verifyWithChecksum ?? false));
|
|
449
|
+
if (options?.verifyTransactionLogs === false) return;
|
|
450
|
+
const logsDir = (0, node_path.join)(backupDir, TRANSACTION_LOGS_DIRNAME, String(backupId));
|
|
451
|
+
if (!await exists(logsDir)) return;
|
|
452
|
+
const failures = [];
|
|
453
|
+
for (const entry of await (0, node_fs_promises.readdir)(logsDir, { withFileTypes: true })) {
|
|
454
|
+
if (!entry.isDirectory()) continue;
|
|
455
|
+
const result = await validateTransactionLogStore((0, node_path.join)(logsDir, entry.name), { strict: true });
|
|
456
|
+
if (!result.valid) {
|
|
457
|
+
const details = [...result.errors, ...result.files.flatMap((file) => file.errors.map((error) => `${file.file}: ${error}`))];
|
|
458
|
+
failures.push(`${entry.name}: ${details.join("; ")}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (failures.length > 0) throw new Error(`Backup ${backupId} transaction log verification failed: ${failures.join("\n")}`);
|
|
183
462
|
}
|
|
184
463
|
};
|
|
185
464
|
|
|
@@ -543,6 +822,258 @@ var DBI = class DBI {
|
|
|
543
822
|
}
|
|
544
823
|
};
|
|
545
824
|
|
|
825
|
+
//#endregion
|
|
826
|
+
//#region src/tar.ts
|
|
827
|
+
/**
|
|
828
|
+
* A minimal, dependency-free streaming USTAR (POSIX tar) encoder.
|
|
829
|
+
*
|
|
830
|
+
* Built for streaming whole-database backups: the set of live RocksDB files
|
|
831
|
+
* (SSTs, blobs, MANIFEST, CURRENT, OPTIONS, WAL) is serialized into a single
|
|
832
|
+
* tar byte stream that can be piped to any destination and unpacked with
|
|
833
|
+
* standard tools (`tar -xf`). The encoder never buffers a whole file — each
|
|
834
|
+
* entry's payload is streamed through in chunks — so peak memory stays flat
|
|
835
|
+
* regardless of database size.
|
|
836
|
+
*
|
|
837
|
+
* Backpressure is the caller's lever: every method `await`s the {@link TarSink},
|
|
838
|
+
* so wiring the sink to a stream writer's `write()` naturally paces production
|
|
839
|
+
* to the consumer.
|
|
840
|
+
*
|
|
841
|
+
* Only what a RocksDB backup needs is implemented: regular files. Paths longer
|
|
842
|
+
* than USTAR's 100-byte `name` field are split across the `name` and `prefix`
|
|
843
|
+
* fields (~255 bytes total), which covers `transaction_logs/<store>/<file>`
|
|
844
|
+
* entries; GNU/pax long-name extensions are not implemented, so a path that
|
|
845
|
+
* still doesn't fit throws rather than being silently truncated. File sizes
|
|
846
|
+
* larger than the octal `size` field can hold (~8 GiB) fall back to the GNU
|
|
847
|
+
* base-256 encoding.
|
|
848
|
+
*/
|
|
849
|
+
/** Size of a tar block. Every header and the archive length are multiples of this. */
|
|
850
|
+
const BLOCK_SIZE = 512;
|
|
851
|
+
/** Maximum bytes for the USTAR `name` field. */
|
|
852
|
+
const MAX_NAME_LENGTH = 100;
|
|
853
|
+
/** Maximum bytes for the USTAR `prefix` field (holds the leading directories). */
|
|
854
|
+
const MAX_PREFIX_LENGTH = 155;
|
|
855
|
+
const textEncoder = new TextEncoder();
|
|
856
|
+
const textDecoder = new TextDecoder();
|
|
857
|
+
/**
|
|
858
|
+
* Writes a UTF-8 string into a fixed-width header field, left-justified and
|
|
859
|
+
* NUL-padded (the block is already zero-filled, so nothing else is needed).
|
|
860
|
+
* Throws if the encoded value does not fit — tar fields are silently lossy
|
|
861
|
+
* otherwise, which corrupts the archive.
|
|
862
|
+
*/
|
|
863
|
+
function writeField(block, value, offset, length) {
|
|
864
|
+
const bytes = textEncoder.encode(value);
|
|
865
|
+
if (bytes.length > length) throw new RangeError(`tar field value ${JSON.stringify(value)} exceeds ${length} bytes`);
|
|
866
|
+
block.set(bytes, offset);
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Writes a non-negative integer as a NUL-terminated, zero-padded octal string
|
|
870
|
+
* into a `length`-byte field (so `length - 1` octal digits). Values too large
|
|
871
|
+
* for octal fall back to the GNU base-256 extension, where the high bit of the
|
|
872
|
+
* first byte is set and the magnitude is stored big-endian in the remainder.
|
|
873
|
+
*/
|
|
874
|
+
function writeOctal(block, value, offset, length) {
|
|
875
|
+
const digits = length - 1;
|
|
876
|
+
if (value > 8 ** digits - 1) {
|
|
877
|
+
let v = value;
|
|
878
|
+
for (let i = offset + length - 1; i > offset; i--) {
|
|
879
|
+
block[i] = v & 255;
|
|
880
|
+
v = Math.floor(v / 256);
|
|
881
|
+
}
|
|
882
|
+
block[offset] = 128;
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const str = value.toString(8).padStart(digits, "0");
|
|
886
|
+
for (let i = 0; i < digits; i++) block[offset + i] = str.charCodeAt(i);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Computes and writes the USTAR header checksum: the unsigned sum of all 512
|
|
890
|
+
* header bytes with the checksum field itself taken as ASCII spaces. Stored as
|
|
891
|
+
* six octal digits, a NUL, then a space (the conventional encoding).
|
|
892
|
+
*/
|
|
893
|
+
function writeChecksum(block) {
|
|
894
|
+
for (let i = 148; i < 156; i++) block[i] = 32;
|
|
895
|
+
let sum = 0;
|
|
896
|
+
for (let i = 0; i < BLOCK_SIZE; i++) sum += block[i];
|
|
897
|
+
const str = sum.toString(8).padStart(6, "0");
|
|
898
|
+
for (let i = 0; i < 6; i++) block[148 + i] = str.charCodeAt(i);
|
|
899
|
+
block[154] = 0;
|
|
900
|
+
block[155] = 32;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Splits a path into USTAR `name` (<=100 bytes) and `prefix` (<=155 bytes)
|
|
904
|
+
* fields at a `/` boundary; a reader rejoins them as `prefix + "/" + name`.
|
|
905
|
+
* Returns an empty prefix when the whole path fits in `name`. Throws when the
|
|
906
|
+
* path cannot be represented — a trailing component over 100 bytes, or leading
|
|
907
|
+
* directories that don't fit in 155. RocksDB and transaction-log filenames never
|
|
908
|
+
* hit this; only a pathological log-store name would.
|
|
909
|
+
*/
|
|
910
|
+
function splitTarName(path) {
|
|
911
|
+
const encoded = textEncoder.encode(path);
|
|
912
|
+
if (encoded.length <= MAX_NAME_LENGTH) return {
|
|
913
|
+
name: path,
|
|
914
|
+
prefix: ""
|
|
915
|
+
};
|
|
916
|
+
let best = -1;
|
|
917
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
918
|
+
if (encoded[i] !== 47) continue;
|
|
919
|
+
const prefixLength = i;
|
|
920
|
+
const nameLength = encoded.length - i - 1;
|
|
921
|
+
if (nameLength > 0 && nameLength <= MAX_NAME_LENGTH && prefixLength <= MAX_PREFIX_LENGTH) best = i;
|
|
922
|
+
}
|
|
923
|
+
if (best === -1) throw new RangeError(`tar entry name ${JSON.stringify(path)} does not fit the USTAR name (<=${MAX_NAME_LENGTH}) + prefix (<=${MAX_PREFIX_LENGTH}) fields`);
|
|
924
|
+
return {
|
|
925
|
+
name: textDecoder.decode(encoded.subarray(best + 1)),
|
|
926
|
+
prefix: textDecoder.decode(encoded.subarray(0, best))
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Builds a single 512-byte USTAR header block for a regular file. Long paths are
|
|
931
|
+
* split across the `name` and `prefix` fields (see {@link splitTarName}).
|
|
932
|
+
*/
|
|
933
|
+
function buildHeader(name, size, mode, mtime) {
|
|
934
|
+
const block = new Uint8Array(BLOCK_SIZE);
|
|
935
|
+
const split = splitTarName(name);
|
|
936
|
+
writeField(block, split.name, 0, MAX_NAME_LENGTH);
|
|
937
|
+
writeOctal(block, mode & 4095, 100, 8);
|
|
938
|
+
writeOctal(block, 0, 108, 8);
|
|
939
|
+
writeOctal(block, 0, 116, 8);
|
|
940
|
+
writeOctal(block, size, 124, 12);
|
|
941
|
+
writeOctal(block, Math.floor(mtime), 136, 12);
|
|
942
|
+
block[156] = 48;
|
|
943
|
+
writeField(block, "ustar", 257, 6);
|
|
944
|
+
block[263] = 48;
|
|
945
|
+
block[264] = 48;
|
|
946
|
+
if (split.prefix.length > 0) writeField(block, split.prefix, 345, MAX_PREFIX_LENGTH);
|
|
947
|
+
writeChecksum(block);
|
|
948
|
+
return block;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Streaming USTAR encoder. Usage is a strict sequence per file —
|
|
952
|
+
* {@link TarEncoder.addFile} once, then {@link TarEncoder.writeData} until the
|
|
953
|
+
* declared size is reached — followed by a single {@link TarEncoder.finalize}:
|
|
954
|
+
*
|
|
955
|
+
* ```ts
|
|
956
|
+
* const tar = new TarEncoder((chunk) => writer.write(chunk));
|
|
957
|
+
* await tar.addFile('CURRENT', current.length);
|
|
958
|
+
* await tar.writeData(current);
|
|
959
|
+
* await tar.addFile('000123.sst', sstSize);
|
|
960
|
+
* for await (const chunk of sstChunks) await tar.writeData(chunk);
|
|
961
|
+
* await tar.finalize();
|
|
962
|
+
* ```
|
|
963
|
+
*
|
|
964
|
+
* The size passed to `addFile` is authoritative: tar records it in the header
|
|
965
|
+
* before any payload, so it must be known up front (it is — RocksDB's
|
|
966
|
+
* `GetLiveFilesStorageInfo` returns each file's exact size). Writing more or
|
|
967
|
+
* fewer bytes than declared throws.
|
|
968
|
+
*/
|
|
969
|
+
var TarEncoder = class {
|
|
970
|
+
#sink;
|
|
971
|
+
/** Payload bytes still expected for the current entry. */
|
|
972
|
+
#remaining = 0;
|
|
973
|
+
/** Zero-padding bytes owed after the current entry's payload reaches a block boundary. */
|
|
974
|
+
#pad = 0;
|
|
975
|
+
#finalized = false;
|
|
976
|
+
constructor(sink) {
|
|
977
|
+
this.#sink = sink;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Emits the header for a new file entry. Must be preceded by completing any
|
|
981
|
+
* prior entry's payload. After this, exactly `size` bytes must be supplied
|
|
982
|
+
* via {@link TarEncoder.writeData} before the next `addFile` or `finalize`.
|
|
983
|
+
*/
|
|
984
|
+
async addFile(name, size, options) {
|
|
985
|
+
if (this.#finalized) throw new Error("TarEncoder already finalized");
|
|
986
|
+
if (this.#remaining !== 0) throw new Error(`previous tar entry incomplete: ${this.#remaining} byte(s) unwritten`);
|
|
987
|
+
if (!Number.isInteger(size) || size < 0) throw new RangeError("tar entry size must be a non-negative integer");
|
|
988
|
+
const header = buildHeader(name, size, options?.mode ?? 420, options?.mtime ?? 0);
|
|
989
|
+
this.#remaining = size;
|
|
990
|
+
this.#pad = (BLOCK_SIZE - size % BLOCK_SIZE) % BLOCK_SIZE;
|
|
991
|
+
await this.#sink(header);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Streams a chunk of the current entry's payload. Chunks may be any size; the
|
|
995
|
+
* total across all `writeData` calls for an entry must equal the size passed
|
|
996
|
+
* to {@link TarEncoder.addFile}. Block padding is emitted automatically once
|
|
997
|
+
* the final byte of an entry is written.
|
|
998
|
+
*/
|
|
999
|
+
async writeData(chunk) {
|
|
1000
|
+
if (this.#finalized) throw new Error("TarEncoder already finalized");
|
|
1001
|
+
if (chunk.length === 0) return;
|
|
1002
|
+
if (chunk.length > this.#remaining) throw new RangeError(`tar data overruns entry size by ${chunk.length - this.#remaining} byte(s)`);
|
|
1003
|
+
this.#remaining -= chunk.length;
|
|
1004
|
+
await this.#sink(chunk);
|
|
1005
|
+
if (this.#remaining === 0 && this.#pad > 0) {
|
|
1006
|
+
const pad = this.#pad;
|
|
1007
|
+
this.#pad = 0;
|
|
1008
|
+
await this.#sink(new Uint8Array(pad));
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Convenience for an entry whose contents are already fully in memory (e.g.
|
|
1013
|
+
* the small `CURRENT`/`OPTIONS` files RocksDB returns inline).
|
|
1014
|
+
*/
|
|
1015
|
+
async addFileData(name, data, options) {
|
|
1016
|
+
await this.addFile(name, data.length, options);
|
|
1017
|
+
await this.writeData(data);
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Writes the two zero-filled blocks that mark end-of-archive. The encoder is
|
|
1021
|
+
* unusable afterward. Throws if the current entry is not fully written, so a
|
|
1022
|
+
* truncated stream cannot be silently finalized into a valid-looking archive.
|
|
1023
|
+
*/
|
|
1024
|
+
async finalize() {
|
|
1025
|
+
if (this.#finalized) throw new Error("TarEncoder already finalized");
|
|
1026
|
+
if (this.#remaining !== 0) throw new Error(`cannot finalize: current tar entry has ${this.#remaining} byte(s) unwritten`);
|
|
1027
|
+
this.#finalized = true;
|
|
1028
|
+
await this.#sink(new Uint8Array(BLOCK_SIZE * 2));
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region src/backup-stream.ts
|
|
1034
|
+
/** Native event discriminator: a new file header (vs. a payload chunk). */
|
|
1035
|
+
const EVENT_FILE = 0;
|
|
1036
|
+
/**
|
|
1037
|
+
* Streams a consistent snapshot of the database to a `WritableStream` as a tar
|
|
1038
|
+
* archive, with no intermediate copy on disk. The set of live RocksDB files is
|
|
1039
|
+
* enumerated and read natively; each file header and payload chunk is framed
|
|
1040
|
+
* into the {@link TarEncoder} here in JS and written to `stream`.
|
|
1041
|
+
*
|
|
1042
|
+
* Backpressure flows end to end: the encoder awaits each `writer.write()`, and
|
|
1043
|
+
* the native producer awaits each `emit()` before reading the next chunk, so a
|
|
1044
|
+
* slow consumer paces the entire pipeline rather than buffering in memory.
|
|
1045
|
+
*
|
|
1046
|
+
* The resulting archive can be unpacked with any tar tool (`tar -xf`) into a
|
|
1047
|
+
* directory that opens directly as a RocksDB database.
|
|
1048
|
+
*/
|
|
1049
|
+
async function backupToStream(db, stream, options) {
|
|
1050
|
+
let tarDestination;
|
|
1051
|
+
let piped;
|
|
1052
|
+
if (options?.gzip) {
|
|
1053
|
+
const gzip = new CompressionStream("gzip");
|
|
1054
|
+
piped = gzip.readable.pipeTo(stream);
|
|
1055
|
+
tarDestination = gzip.writable;
|
|
1056
|
+
} else tarDestination = stream;
|
|
1057
|
+
const writer = tarDestination.getWriter();
|
|
1058
|
+
const tar = new TarEncoder((chunk) => writer.write(chunk));
|
|
1059
|
+
const emit = async (kind, data, size, mtime) => {
|
|
1060
|
+
if (kind === EVENT_FILE) await tar.addFile(data, size, { mtime });
|
|
1061
|
+
else await tar.writeData(data);
|
|
1062
|
+
};
|
|
1063
|
+
try {
|
|
1064
|
+
await new Promise((resolve, reject) => {
|
|
1065
|
+
db.backupStream(resolve, reject, emit, options);
|
|
1066
|
+
});
|
|
1067
|
+
await tar.finalize();
|
|
1068
|
+
await writer.close();
|
|
1069
|
+
if (piped) await piped;
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
await writer.abort(err).catch(() => {});
|
|
1072
|
+
if (piped) await piped.catch(() => {});
|
|
1073
|
+
throw err;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
546
1077
|
//#endregion
|
|
547
1078
|
//#region src/dbi-iterator.ts
|
|
548
1079
|
const { ITERATOR_RESULT_DONE, ITERATOR_RESULT_FAST } = constants;
|
|
@@ -756,6 +1287,16 @@ var Store = class {
|
|
|
756
1287
|
*/
|
|
757
1288
|
maxKeySize;
|
|
758
1289
|
/**
|
|
1290
|
+
* The maximum number of table files RocksDB keeps open. `0` (the default)
|
|
1291
|
+
* derives a budget from the effective per-process open-file limit (an
|
|
1292
|
+
* eighth of the limit — several databases can share one process — clamped
|
|
1293
|
+
* to `[1024, 262144]`); `-1` holds every table file open (the RocksDB
|
|
1294
|
+
* default, which can exhaust the process file-descriptor limit when
|
|
1295
|
+
* compaction falls behind under sustained ingest); a positive `int32` is
|
|
1296
|
+
* an explicit cap.
|
|
1297
|
+
*/
|
|
1298
|
+
maxOpenFiles;
|
|
1299
|
+
/**
|
|
759
1300
|
* The maximum number of memtables that can be queued per column family
|
|
760
1301
|
* before writes stall. Higher values absorb write bursts while flushes catch
|
|
761
1302
|
* up, at the cost of memory.
|
|
@@ -876,6 +1417,7 @@ var Store = class {
|
|
|
876
1417
|
this.keyBuffer = KEY_BUFFER;
|
|
877
1418
|
this.keyEncoding = keyEncoding;
|
|
878
1419
|
this.maxKeySize = options?.maxKeySize ?? MAX_KEY_SIZE;
|
|
1420
|
+
this.maxOpenFiles = options?.maxOpenFiles;
|
|
879
1421
|
this.maxWriteBufferNumber = options?.maxWriteBufferNumber;
|
|
880
1422
|
this.maxWriteBufferSizeToMaintain = options?.maxWriteBufferSizeToMaintain;
|
|
881
1423
|
this.name = options?.name ?? "default";
|
|
@@ -925,19 +1467,10 @@ var Store = class {
|
|
|
925
1467
|
}
|
|
926
1468
|
return new Promise((resolve, reject) => this.db.compact(resolve, reject, startBuffer, endBuffer));
|
|
927
1469
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
*
|
|
933
|
-
* @example
|
|
934
|
-
* ```typescript
|
|
935
|
-
* const id = await db.backup('/path/to/backups');
|
|
936
|
-
* ```
|
|
937
|
-
*/
|
|
938
|
-
async backup(backupDir, options) {
|
|
939
|
-
await (0, node_fs_promises.mkdir)(backupDir, { recursive: true });
|
|
940
|
-
return new Promise((resolve, reject) => this.db.backup(resolve, reject, backupDir, options));
|
|
1470
|
+
async backup(target, options) {
|
|
1471
|
+
if (typeof target !== "string" && typeof target?.getWriter === "function") return backupToStream(this.db, target, options);
|
|
1472
|
+
assertBackupDirOutsideDatabase(this.path, target);
|
|
1473
|
+
return new Promise((resolve, reject) => this.db.backup(resolve, reject, target, options));
|
|
941
1474
|
}
|
|
942
1475
|
/**
|
|
943
1476
|
* Compacts the entire key range of the database synchronously.
|
|
@@ -1177,6 +1710,7 @@ var Store = class {
|
|
|
1177
1710
|
dbWriteBufferSize: this.dbWriteBufferSize,
|
|
1178
1711
|
disableWAL: this.disableWAL,
|
|
1179
1712
|
enableStats: this.enableStats,
|
|
1713
|
+
maxOpenFiles: this.maxOpenFiles,
|
|
1180
1714
|
maxWriteBufferNumber: this.maxWriteBufferNumber,
|
|
1181
1715
|
maxWriteBufferSizeToMaintain: this.maxWriteBufferSizeToMaintain,
|
|
1182
1716
|
mode: this.pessimistic ? "pessimistic" : "optimistic",
|
|
@@ -1295,8 +1829,18 @@ const RETRY_NOW = constants.RETRY_NOW_VALUE;
|
|
|
1295
1829
|
var TransactionAlreadyAbortedError = class extends Error {
|
|
1296
1830
|
code = "ERR_ALREADY_ABORTED";
|
|
1297
1831
|
};
|
|
1298
|
-
|
|
1299
|
-
|
|
1832
|
+
/**
|
|
1833
|
+
* Base for commit conflicts the native layer has already recovered from by resetting the
|
|
1834
|
+
* transaction in place (fresh RocksDB snapshot; `committedPosition` preserved, so a staged
|
|
1835
|
+
* transaction-log batch stays write-once): the caller should re-run the transaction body
|
|
1836
|
+
* and retry the commit — the handling is identical for every subclass. `hasLog` reports
|
|
1837
|
+
* whether the transaction wrote to a transaction log (drives the default retry decision).
|
|
1838
|
+
*
|
|
1839
|
+
* {@link TransactionAbandonedError} shares this field shape but deliberately does NOT
|
|
1840
|
+
* extend this class — an abandoned transaction is not retryable, and retryability is the
|
|
1841
|
+
* boundary this hierarchy expresses.
|
|
1842
|
+
*/
|
|
1843
|
+
var TransactionRetryableError = class extends Error {
|
|
1300
1844
|
hasLog;
|
|
1301
1845
|
txn;
|
|
1302
1846
|
constructor(error, txn) {
|
|
@@ -1305,6 +1849,21 @@ var TransactionIsBusyError = class extends Error {
|
|
|
1305
1849
|
this.txn = txn;
|
|
1306
1850
|
}
|
|
1307
1851
|
};
|
|
1852
|
+
/**
|
|
1853
|
+
* An optimistic commit detected a write conflict with a concurrently committed
|
|
1854
|
+
* transaction (RocksDB `Busy`).
|
|
1855
|
+
*/
|
|
1856
|
+
var TransactionIsBusyError = class extends TransactionRetryableError {
|
|
1857
|
+
code = "ERR_BUSY";
|
|
1858
|
+
};
|
|
1859
|
+
/**
|
|
1860
|
+
* An optimistic commit could not be validated: the transaction's snapshot was stranded
|
|
1861
|
+
* outside the memtable window (its sequence history was flushed away — RocksDB
|
|
1862
|
+
* `TryAgain`), so the conflict check had nothing to validate against.
|
|
1863
|
+
*/
|
|
1864
|
+
var TransactionTryAgainError = class extends TransactionRetryableError {
|
|
1865
|
+
code = "ERR_TRY_AGAIN";
|
|
1866
|
+
};
|
|
1308
1867
|
var TransactionAbandonedError = class extends Error {
|
|
1309
1868
|
code = "ERR_TRANSACTION_ABANDONED";
|
|
1310
1869
|
hasLog;
|
|
@@ -1399,6 +1958,7 @@ var Transaction = class extends DBI {
|
|
|
1399
1958
|
if (err instanceof Error && "code" in err) {
|
|
1400
1959
|
if (err.code === "ERR_ALREADY_ABORTED") return new TransactionAlreadyAbortedError(err.message);
|
|
1401
1960
|
if (err.code === "ERR_BUSY") return new TransactionIsBusyError(err, this);
|
|
1961
|
+
if (err.code === "ERR_TRY_AGAIN") return new TransactionTryAgainError(err, this);
|
|
1402
1962
|
}
|
|
1403
1963
|
return err;
|
|
1404
1964
|
}
|
|
@@ -1506,25 +2066,9 @@ var RocksDatabase = class RocksDatabase extends DBI {
|
|
|
1506
2066
|
compact(options) {
|
|
1507
2067
|
return this.store.compact(options);
|
|
1508
2068
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
* the new backup id. Use the `backups` namespace to restore, list, delete,
|
|
1513
|
-
* purge, or verify backups.
|
|
1514
|
-
*
|
|
1515
|
-
* When the database was opened with `disableWAL`, the memtable is flushed
|
|
1516
|
-
* before the backup by default so unflushed data is not lost.
|
|
1517
|
-
*
|
|
1518
|
-
* @example
|
|
1519
|
-
* ```typescript
|
|
1520
|
-
* import { backups } from '@harperfast/rocksdb-js';
|
|
1521
|
-
*
|
|
1522
|
-
* const db = RocksDatabase.open('/path/to/database');
|
|
1523
|
-
* const id = await db.backup('/path/to/backups');
|
|
1524
|
-
* ```
|
|
1525
|
-
*/
|
|
1526
|
-
backup(backupDir, options) {
|
|
1527
|
-
return this.store.backup(backupDir, options);
|
|
2069
|
+
backup(target, options) {
|
|
2070
|
+
if (typeof target === "string") return this.store.backup(target, options);
|
|
2071
|
+
return this.store.backup(target, options);
|
|
1528
2072
|
}
|
|
1529
2073
|
/**
|
|
1530
2074
|
* Creates a hardlinked, point-in-time, fully independent copy of the entire
|
|
@@ -1995,7 +2539,7 @@ var RocksDatabase = class RocksDatabase extends DBI {
|
|
|
1995
2539
|
commitResult = await txn.commit();
|
|
1996
2540
|
} catch (commitErr) {
|
|
1997
2541
|
if (commitErr instanceof TransactionAlreadyAbortedError) return;
|
|
1998
|
-
if (commitErr instanceof
|
|
2542
|
+
if (commitErr instanceof TransactionRetryableError && (options?.retryOnBusy ?? commitErr.hasLog) && attempt < maxRetries) continue;
|
|
1999
2543
|
this.#abandonTransaction(txn, commitErr);
|
|
2000
2544
|
return;
|
|
2001
2545
|
}
|
|
@@ -2027,7 +2571,7 @@ var RocksDatabase = class RocksDatabase extends DBI {
|
|
|
2027
2571
|
if (typeof callback !== "function") throw new TypeError("Callback must be a function");
|
|
2028
2572
|
const maxRetries = options?.maxRetries ?? 3;
|
|
2029
2573
|
const isRetryable = (err, attempt) => {
|
|
2030
|
-
return err instanceof
|
|
2574
|
+
return err instanceof TransactionRetryableError && (options?.retryOnBusy ?? err.hasLog) && attempt <= maxRetries;
|
|
2031
2575
|
};
|
|
2032
2576
|
const runAttempt = (attempt) => {
|
|
2033
2577
|
const txn = new Transaction(this.store, options);
|
|
@@ -2223,7 +2767,7 @@ function parseTransactionLog(path, options = {}) {
|
|
|
2223
2767
|
|
|
2224
2768
|
//#endregion
|
|
2225
2769
|
//#region src/transaction-log-reader.ts
|
|
2226
|
-
const FLOAT_TO_UINT32 = new Float64Array(1);
|
|
2770
|
+
const FLOAT_TO_UINT32 = /* @__PURE__ */ new Float64Array(1);
|
|
2227
2771
|
const UINT32_FROM_FLOAT = new Uint32Array(FLOAT_TO_UINT32.buffer);
|
|
2228
2772
|
const { TRANSACTION_LOG_FILE_HEADER_SIZE, TRANSACTION_LOG_ENTRY_HEADER_SIZE } = constants;
|
|
2229
2773
|
/**
|
|
@@ -2409,7 +2953,7 @@ function loadLastPosition(transactionLog, readUncommitted) {
|
|
|
2409
2953
|
//#region src/index.ts
|
|
2410
2954
|
const versions = {
|
|
2411
2955
|
rocksdb: version,
|
|
2412
|
-
"rocksdb-js": "2.
|
|
2956
|
+
"rocksdb-js": "2.5.0"
|
|
2413
2957
|
};
|
|
2414
2958
|
|
|
2415
2959
|
//#endregion
|
|
@@ -2423,9 +2967,12 @@ exports.backups = backups;
|
|
|
2423
2967
|
exports.constants = constants;
|
|
2424
2968
|
exports.coolTransactionLogs = coolTransactionLogs;
|
|
2425
2969
|
exports.currentThreadId = currentThreadId;
|
|
2970
|
+
exports.fileLockRelease = fileLockRelease;
|
|
2426
2971
|
exports.parseTransactionLog = parseTransactionLog;
|
|
2427
2972
|
exports.registryStatus = registryStatus;
|
|
2428
2973
|
exports.shutdown = shutdown;
|
|
2429
2974
|
exports.stats = stats;
|
|
2975
|
+
exports.tryFileLock = tryFileLock;
|
|
2976
|
+
exports.validateTransactionLogStore = validateTransactionLogStore;
|
|
2430
2977
|
exports.versions = versions;
|
|
2431
2978
|
//# sourceMappingURL=index.cjs.map
|