@harperfast/rocksdb-js 2.3.0 → 2.4.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/README.md +94 -9
- package/dist/index.cjs +415 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1215 -1129
- package/dist/index.d.mts +1215 -1129
- package/dist/index.mjs +415 -40
- package/dist/index.mjs.map +1 -1
- package/docs/backups.md +238 -0
- package/package.json +20 -20
package/README.md
CHANGED
|
@@ -996,6 +996,40 @@ Note: If the `callback` throws an error, Node.js suppress the error. Node.js 18.
|
|
|
996
996
|
`--force-node-api-uncaught-exceptions-policy` flag which will cause errors to emit the
|
|
997
997
|
`'uncaughtException'` event. Future Node.js releases will enable this flag by default.
|
|
998
998
|
|
|
999
|
+
## Exclusive File Locking
|
|
1000
|
+
|
|
1001
|
+
`rocksdb-js` includes helper functions for creating lock files and releasing them using native APIs.
|
|
1002
|
+
This can be used to prevent multiple processes from concurrently accessing a resource. The lock is
|
|
1003
|
+
automatically released when the process exits.
|
|
1004
|
+
|
|
1005
|
+
### `tryFileLock(file: string): number`
|
|
1006
|
+
|
|
1007
|
+
Attempts to acquire an exclusive lock on the given file, creating it if it doesn't exist. Returns a
|
|
1008
|
+
non-zero token to pass to `fileLockRelease` if the lock was acquired, or `0` if another holder — in
|
|
1009
|
+
any process, container, or worker thread — currently has it. Throws if the file's parent directory
|
|
1010
|
+
is missing or on a hard error.
|
|
1011
|
+
|
|
1012
|
+
```typescript
|
|
1013
|
+
import { tryFileLock } from '@harperfast/rocksdb-js';
|
|
1014
|
+
|
|
1015
|
+
const token = tryFileLock('/path/to/lock');
|
|
1016
|
+
if (token) {
|
|
1017
|
+
console.log('lock acquired');
|
|
1018
|
+
} else {
|
|
1019
|
+
console.log('lock not available, another process is holding it');
|
|
1020
|
+
}
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
### `fileLockRelease(token: number): void`
|
|
1024
|
+
|
|
1025
|
+
Releases the file lock for the given token.
|
|
1026
|
+
|
|
1027
|
+
```typescript
|
|
1028
|
+
import { fileLockRelease } from '@harperfast/rocksdb-js';
|
|
1029
|
+
|
|
1030
|
+
fileLockRelease(token);
|
|
1031
|
+
```
|
|
1032
|
+
|
|
999
1033
|
## Verification Table
|
|
1000
1034
|
|
|
1001
1035
|
The verification table is a process-global, fixed-size structure that lets an application cheaply
|
|
@@ -1419,10 +1453,23 @@ Backups use RocksDB's `BackupEngine` to capture consistent, incremental, checksu
|
|
|
1419
1453
|
snapshots of a database. A backup covers the **entire database** — every column family, the
|
|
1420
1454
|
manifest, and (by default) the write-ahead log — so it is not scoped to an individual `Store`.
|
|
1421
1455
|
|
|
1456
|
+
A backup can be written to a local **directory** (incremental, with a management API) or streamed to
|
|
1457
|
+
a **`WritableStream`** as a tar archive with no intermediate copy on disk. See
|
|
1458
|
+
[docs/backups.md](docs/backups.md) for a full guide covering both modes, restore, checkpoints, and
|
|
1459
|
+
caveats.
|
|
1460
|
+
|
|
1422
1461
|
Creating a backup is an instance method (`db.backup()`) because it needs a live database. The
|
|
1423
1462
|
remaining operations act on a backup directory and do not require an open database, so they are
|
|
1424
1463
|
grouped under the `backups` namespace export.
|
|
1425
1464
|
|
|
1465
|
+
> **Only one backup per directory may be in-flight at a time.** RocksDB has no cross-engine lock on
|
|
1466
|
+
> a backup directory, so the writing operations — `db.backup()`, `backups.delete()`, and
|
|
1467
|
+
> `backups.purge()` — take an on-disk lock (a `.backup.lock` file) for the directory. A second
|
|
1468
|
+
> writing operation on the same directory, whether from the same process, a `worker_thread`, or a
|
|
1469
|
+
> separate process, **rejects** with a "locked" error rather than corrupting the backup; retry once
|
|
1470
|
+
> the in-flight operation finishes. Operations on _different_ directories run in parallel, and the
|
|
1471
|
+
> read-only operations (`list`, `verify`, `restore`) are not locked.
|
|
1472
|
+
|
|
1426
1473
|
```typescript
|
|
1427
1474
|
import { RocksDatabase, backups } from '@harperfast/rocksdb-js';
|
|
1428
1475
|
|
|
@@ -1450,15 +1497,53 @@ const id = await db.backup('/path/to/backups', { metadata: 'nightly-2026-06-04'
|
|
|
1450
1497
|
|
|
1451
1498
|
`BackupOptions`:
|
|
1452
1499
|
|
|
1453
|
-
| Option | Type | Default | Description
|
|
1454
|
-
| ------------------------- | --------- | ---------------------- |
|
|
1455
|
-
| `
|
|
1456
|
-
| `
|
|
1457
|
-
| `
|
|
1458
|
-
| `
|
|
1459
|
-
| `
|
|
1460
|
-
| `
|
|
1461
|
-
| `
|
|
1500
|
+
| Option | Type | Default | Description |
|
|
1501
|
+
| ------------------------- | --------- | ---------------------- | ------------------------------------------------------------------------------------ |
|
|
1502
|
+
| `backupLogFiles` | `boolean` | `true` | Include write-ahead log files in the backup. |
|
|
1503
|
+
| `flushBeforeBackup` | `boolean` | `true` if WAL disabled | Flush the memtable before backing up. |
|
|
1504
|
+
| `maxBackgroundOperations` | `number` | `1` | Number of background threads used to copy files. |
|
|
1505
|
+
| `metadata` | `string` | `''` | Application metadata stored with the backup, returned by `list()`. |
|
|
1506
|
+
| `shareFilesWithChecksum` | `boolean` | `true` | Distinguish shared files by checksum to avoid cross-database clashes. |
|
|
1507
|
+
| `shareTableFiles` | `boolean` | `true` | Share files between backups to enable incremental backups. |
|
|
1508
|
+
| `sync` | `boolean` | `true` | `fsync` backup files (including the transaction log snapshot) for crash consistency. |
|
|
1509
|
+
| `transactionLogs` | `boolean` | `false` | Snapshot the transaction log store into `<backupDir>/transaction_logs/<backupId>/`. |
|
|
1510
|
+
|
|
1511
|
+
When `transactionLogs` is enabled, the log snapshot is staged and atomically renamed into
|
|
1512
|
+
`<backupDir>/transaction_logs/<backupId>/` only after every file has been copied (and fsynced, per
|
|
1513
|
+
`sync`), so a crash mid-backup can never leave a partial log snapshot for a listed backup id — a
|
|
1514
|
+
backup either has its complete snapshot or none. The snapshot is captured just after the RocksDB
|
|
1515
|
+
engine snapshot, so restored logs may run slightly ahead of the restored key-value data (never
|
|
1516
|
+
behind it), which is safe for redo-style logs replayed against the restored data.
|
|
1517
|
+
|
|
1518
|
+
### `db.backup(stream: WritableStream<Uint8Array>, options?: BackupStreamOptions): Promise<void>`
|
|
1519
|
+
|
|
1520
|
+
Streams a consistent snapshot of the entire database to `stream` as a tar archive, with **no
|
|
1521
|
+
intermediate copy written to disk**, and resolves once the stream has been fully written and closed.
|
|
1522
|
+
Backpressure is honored end to end, so a slow consumer (e.g. an upload) paces the backup rather than
|
|
1523
|
+
buffering it in memory. The archive unpacks with any tar tool into a directory that opens as a
|
|
1524
|
+
RocksDB database.
|
|
1525
|
+
|
|
1526
|
+
```typescript
|
|
1527
|
+
import { createWriteStream } from 'node:fs';
|
|
1528
|
+
import { Writable } from 'node:stream';
|
|
1529
|
+
|
|
1530
|
+
await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar')));
|
|
1531
|
+
// Restore: `tar -xf backup.tar -C /restored`, then open '/restored'.
|
|
1532
|
+
|
|
1533
|
+
// Or gzip it (`tar -xzf backup.tar.gz` to restore):
|
|
1534
|
+
await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar.gz')), { gzip: true });
|
|
1535
|
+
```
|
|
1536
|
+
|
|
1537
|
+
`BackupStreamOptions`:
|
|
1538
|
+
|
|
1539
|
+
| Option | Type | Default | Description |
|
|
1540
|
+
| ------------------- | --------- | ---------------------- | ------------------------------------------------------------------- |
|
|
1541
|
+
| `flushBeforeBackup` | `boolean` | `true` if WAL disabled | Flush the memtable before streaming. |
|
|
1542
|
+
| `gzip` | `boolean` | `false` | Gzip-compress the archive, producing a `.tar.gz` instead of `.tar`. |
|
|
1543
|
+
|
|
1544
|
+
Stream backups are always full snapshots (no incremental sharing), have no `backups.*` management
|
|
1545
|
+
API, and **cannot be resumed** — a failed transfer must be restarted from the beginning. See
|
|
1546
|
+
[docs/backups.md](docs/backups.md#stream-backups) for details.
|
|
1462
1547
|
|
|
1463
1548
|
### `backups.restore(backupDir: string, dbDir: string, options?: RestoreOptions): Promise<void>`
|
|
1464
1549
|
|
package/dist/index.cjs
CHANGED
|
@@ -114,6 +114,21 @@ const coolTransactionLogs = binding.coolTransactionLogs;
|
|
|
114
114
|
* the underlying mapping rather than leaving it retained.
|
|
115
115
|
*/
|
|
116
116
|
const transactionLogMapCount = binding.transactionLogMapCount;
|
|
117
|
+
/**
|
|
118
|
+
* Creates a native file lock using the specified file path (`flock` on POSIX,
|
|
119
|
+
* `LockFileEx` on Windows). Returns an opaque non-zero token to pass to
|
|
120
|
+
* `fileLockRelease`, or `0` if another holder — in any process, container, or
|
|
121
|
+
* worker thread — currently has it. Throws if `file` is missing or on a hard
|
|
122
|
+
* error. The OS handle is owned entirely in native code (no fd crosses into
|
|
123
|
+
* JS), and the kernel releases the lock when the handle closes, including on
|
|
124
|
+
* process death.
|
|
125
|
+
*/
|
|
126
|
+
const tryFileLock = binding.tryFileLock;
|
|
127
|
+
/**
|
|
128
|
+
* Releases a file lock acquired via `tryFileLock`. A no-op for
|
|
129
|
+
* token `0` or an unknown token.
|
|
130
|
+
*/
|
|
131
|
+
const fileLockRelease = binding.fileLockRelease;
|
|
117
132
|
const nativeBackupRestore = binding.backupRestore;
|
|
118
133
|
const nativeBackupList = binding.backupList;
|
|
119
134
|
const nativeBackupDelete = binding.backupDelete;
|
|
@@ -124,6 +139,126 @@ const version = binding.version;
|
|
|
124
139
|
|
|
125
140
|
//#endregion
|
|
126
141
|
//#region src/backup.ts
|
|
142
|
+
/** Subdirectory (under a backup directory) holding per-backup transaction log snapshots. */
|
|
143
|
+
const TRANSACTION_LOGS_DIRNAME = "transaction_logs";
|
|
144
|
+
/** Non-blocking existence check (`fs.existsSync` would block the event loop). */
|
|
145
|
+
async function exists(path) {
|
|
146
|
+
try {
|
|
147
|
+
await (0, node_fs_promises.access)(path);
|
|
148
|
+
return true;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Runs `fn` while holding a native file lock for `backupDir`, releasing it when
|
|
155
|
+
* `fn` settles. Used by the writable-engine operations `backups.delete` and
|
|
156
|
+
* `backups.purge`; backup creation (`Store.backup`) takes the same lock on the
|
|
157
|
+
* same file natively inside `Database::Backup` (see `runCreateBackup` in
|
|
158
|
+
* `src/binding/database/backup.cpp`), where the backup directory is also
|
|
159
|
+
* created. Throws immediately (without running `fn`) if another writer holds
|
|
160
|
+
* the directory lock.
|
|
161
|
+
*
|
|
162
|
+
* Read-only operations (`list`, `verify`, and a restore's source read) use
|
|
163
|
+
* `BackupEngineReadOnly` and are not locked: concurrent readers are safe.
|
|
164
|
+
* Running a reader concurrently with a `delete`/`purge` on the same directory is
|
|
165
|
+
* a caller-managed hazard, matching RocksDB's one-writable-engine-per-dir model.
|
|
166
|
+
*
|
|
167
|
+
* RocksDB only serializes work within a single `BackupEngine` instance and has
|
|
168
|
+
* no lock on the backup directory itself. Two writable engines — in any
|
|
169
|
+
* processes — creating/deleting/purging backups in the same directory
|
|
170
|
+
* concurrently race on the per-backup staging directory and both fail,
|
|
171
|
+
* potentially leaving the directory with no usable backup. An in-memory lock
|
|
172
|
+
* cannot prevent this across processes, so the lock lives on disk: a kernel
|
|
173
|
+
* advisory lock (`flock` on POSIX, `LockFileEx` on Windows) held on a
|
|
174
|
+
* `.backup.lock` file at the directory root.
|
|
175
|
+
*
|
|
176
|
+
* The lock is taken entirely in native code (`tryFileLock`), which opens,
|
|
177
|
+
* locks, and later closes the file's OS handle without ever exposing a
|
|
178
|
+
* descriptor to JS — the addon statically links its own C runtime, so a
|
|
179
|
+
* Node/libuv fd is not usable across that boundary. The kernel owning the lock
|
|
180
|
+
* buys two properties a pidfile cannot:
|
|
181
|
+
*
|
|
182
|
+
* - No staleness heuristic. The lock is released when the holder's handle closes
|
|
183
|
+
* — normal release, crash, `kill -9`, container exit — so there is nothing to
|
|
184
|
+
* reclaim and no liveness check.
|
|
185
|
+
* - True cross-context exclusion. The lock conflicts across processes, containers
|
|
186
|
+
* sharing a volume (same kernel), and `worker_threads`.
|
|
187
|
+
*
|
|
188
|
+
* The lock file is never deleted, only locked and unlocked; an unlocked, empty
|
|
189
|
+
* `.backup.lock` at the directory root is the steady state.
|
|
190
|
+
*/
|
|
191
|
+
async function withBackupDirLock(backupDir, fn) {
|
|
192
|
+
const token = tryFileLock((0, node_path.join)(backupDir, ".backup.lock"));
|
|
193
|
+
if (token === 0) throw new Error(`Backup directory is locked: ${backupDir}`);
|
|
194
|
+
try {
|
|
195
|
+
return await fn();
|
|
196
|
+
} finally {
|
|
197
|
+
fileLockRelease(token);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Removes `<backupDir>/transaction_logs/<id>` subtrees whose backup id no longer
|
|
202
|
+
* corresponds to a surviving backup. Called after a `purge` (which holds the
|
|
203
|
+
* backup-dir lock); a no-op when no transaction logs were backed up. Self-healing:
|
|
204
|
+
* it drops every log subtree without a live backup. Note the surviving set comes
|
|
205
|
+
* from `nativeBackupList`, which omits corrupt backups — a still-present but
|
|
206
|
+
* corrupt backup's logs may be pruned (acceptable: it cannot be restored anyway).
|
|
207
|
+
* This also sweeps `.staging-*` leftovers from a crashed backup process (a
|
|
208
|
+
* staging name never matches a live backup id); backup creation sweeps them
|
|
209
|
+
* natively as well, under the backup-dir lock.
|
|
210
|
+
*/
|
|
211
|
+
async function pruneOrphanedTransactionLogs(backupDir) {
|
|
212
|
+
const logsRoot = (0, node_path.join)(backupDir, TRANSACTION_LOGS_DIRNAME);
|
|
213
|
+
let ids;
|
|
214
|
+
try {
|
|
215
|
+
ids = await (0, node_fs_promises.readdir)(logsRoot);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (err.code === "ENOENT") return;
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
const list = await new Promise((resolve, reject) => nativeBackupList(resolve, reject, backupDir));
|
|
221
|
+
const surviving = new Set(list.map((info) => String(info.backupId)));
|
|
222
|
+
await Promise.all(ids.filter((id) => !surviving.has(id)).map((id) => (0, node_fs_promises.rm)((0, node_path.join)(logsRoot, id), {
|
|
223
|
+
recursive: true,
|
|
224
|
+
force: true
|
|
225
|
+
})));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Restores the transaction log snapshot for the restored backup into
|
|
229
|
+
* `<dbDir>/transaction_logs/`, wiping the destination first so that restoring an
|
|
230
|
+
* older backup over a newer one cannot leave stale (newer) log files behind.
|
|
231
|
+
*
|
|
232
|
+
* Only acts when THIS backup actually captured logs: if the restored id has no
|
|
233
|
+
* snapshot, the destination is left untouched. Wiping unconditionally would
|
|
234
|
+
* destroy on-disk logs that a mixed-mode (`transactionLogs:false`) backup never
|
|
235
|
+
* managed.
|
|
236
|
+
*
|
|
237
|
+
* Offline only: the files are picked up when the database is next opened. mtimes
|
|
238
|
+
* are preserved because the store derives file age (rotation/retention) from
|
|
239
|
+
* mtime — a fresh mtime would break retention.
|
|
240
|
+
*/
|
|
241
|
+
async function restoreTransactionLogs(backupDir, dbDir, backupId) {
|
|
242
|
+
const logsRoot = (0, node_path.join)(backupDir, TRANSACTION_LOGS_DIRNAME);
|
|
243
|
+
if (!await exists(logsRoot)) return;
|
|
244
|
+
let id = backupId;
|
|
245
|
+
if (id === void 0) {
|
|
246
|
+
const list = await new Promise((resolve, reject) => nativeBackupList(resolve, reject, backupDir));
|
|
247
|
+
if (list.length === 0) return;
|
|
248
|
+
id = Math.max(...list.map((info) => info.backupId));
|
|
249
|
+
}
|
|
250
|
+
const logsSrc = (0, node_path.join)(logsRoot, String(id));
|
|
251
|
+
if (!await exists(logsSrc)) return;
|
|
252
|
+
const logsDest = (0, node_path.join)(dbDir, TRANSACTION_LOGS_DIRNAME);
|
|
253
|
+
await (0, node_fs_promises.rm)(logsDest, {
|
|
254
|
+
recursive: true,
|
|
255
|
+
force: true
|
|
256
|
+
});
|
|
257
|
+
await (0, node_fs_promises.cp)(logsSrc, logsDest, {
|
|
258
|
+
recursive: true,
|
|
259
|
+
preserveTimestamps: true
|
|
260
|
+
});
|
|
261
|
+
}
|
|
127
262
|
/**
|
|
128
263
|
* Backup management operations that act on a backup directory and do not
|
|
129
264
|
* require an open database. To create a backup, use the `db.backup()` instance
|
|
@@ -149,11 +284,12 @@ const backups = {
|
|
|
149
284
|
const walDir = options?.walDir ?? dbDir;
|
|
150
285
|
await (0, node_fs_promises.mkdir)(dbDir, { recursive: true });
|
|
151
286
|
if (walDir !== dbDir) await (0, node_fs_promises.mkdir)(walDir, { recursive: true });
|
|
152
|
-
|
|
287
|
+
await new Promise((resolve, reject) => nativeBackupRestore(resolve, reject, backupDir, dbDir, walDir, {
|
|
153
288
|
backupId: options?.backupId,
|
|
154
289
|
keepLogFiles: options?.keepLogFiles,
|
|
155
290
|
mode: options?.mode
|
|
156
291
|
}));
|
|
292
|
+
await restoreTransactionLogs(backupDir, dbDir, options?.backupId);
|
|
157
293
|
},
|
|
158
294
|
/**
|
|
159
295
|
* Lists the non-corrupt backups in a backup directory, ordered by id.
|
|
@@ -165,14 +301,27 @@ const backups = {
|
|
|
165
301
|
* Deletes a specific backup. Shared files are reference-counted and only
|
|
166
302
|
* removed once no remaining backup references them.
|
|
167
303
|
*/
|
|
168
|
-
delete(backupDir, backupId) {
|
|
169
|
-
return
|
|
304
|
+
async delete(backupDir, backupId) {
|
|
305
|
+
return withBackupDirLock(backupDir, async () => {
|
|
306
|
+
await new Promise((resolve, reject) => nativeBackupDelete(resolve, reject, backupDir, backupId));
|
|
307
|
+
try {
|
|
308
|
+
await (0, node_fs_promises.rm)((0, node_path.join)(backupDir, TRANSACTION_LOGS_DIRNAME, String(backupId)), {
|
|
309
|
+
recursive: true,
|
|
310
|
+
force: true
|
|
311
|
+
});
|
|
312
|
+
} catch {}
|
|
313
|
+
});
|
|
170
314
|
},
|
|
171
315
|
/**
|
|
172
316
|
* Deletes all but the newest `keepCount` backups.
|
|
173
317
|
*/
|
|
174
|
-
purge(backupDir, keepCount) {
|
|
175
|
-
return
|
|
318
|
+
async purge(backupDir, keepCount) {
|
|
319
|
+
return withBackupDirLock(backupDir, async () => {
|
|
320
|
+
await new Promise((resolve, reject) => nativeBackupPurge(resolve, reject, backupDir, keepCount));
|
|
321
|
+
try {
|
|
322
|
+
await pruneOrphanedTransactionLogs(backupDir);
|
|
323
|
+
} catch {}
|
|
324
|
+
});
|
|
176
325
|
},
|
|
177
326
|
/**
|
|
178
327
|
* Verifies a backup's file sizes, and optionally their checksums (which
|
|
@@ -543,6 +692,258 @@ var DBI = class DBI {
|
|
|
543
692
|
}
|
|
544
693
|
};
|
|
545
694
|
|
|
695
|
+
//#endregion
|
|
696
|
+
//#region src/tar.ts
|
|
697
|
+
/**
|
|
698
|
+
* A minimal, dependency-free streaming USTAR (POSIX tar) encoder.
|
|
699
|
+
*
|
|
700
|
+
* Built for streaming whole-database backups: the set of live RocksDB files
|
|
701
|
+
* (SSTs, blobs, MANIFEST, CURRENT, OPTIONS, WAL) is serialized into a single
|
|
702
|
+
* tar byte stream that can be piped to any destination and unpacked with
|
|
703
|
+
* standard tools (`tar -xf`). The encoder never buffers a whole file — each
|
|
704
|
+
* entry's payload is streamed through in chunks — so peak memory stays flat
|
|
705
|
+
* regardless of database size.
|
|
706
|
+
*
|
|
707
|
+
* Backpressure is the caller's lever: every method `await`s the {@link TarSink},
|
|
708
|
+
* so wiring the sink to a stream writer's `write()` naturally paces production
|
|
709
|
+
* to the consumer.
|
|
710
|
+
*
|
|
711
|
+
* Only what a RocksDB backup needs is implemented: regular files. Paths longer
|
|
712
|
+
* than USTAR's 100-byte `name` field are split across the `name` and `prefix`
|
|
713
|
+
* fields (~255 bytes total), which covers `transaction_logs/<store>/<file>`
|
|
714
|
+
* entries; GNU/pax long-name extensions are not implemented, so a path that
|
|
715
|
+
* still doesn't fit throws rather than being silently truncated. File sizes
|
|
716
|
+
* larger than the octal `size` field can hold (~8 GiB) fall back to the GNU
|
|
717
|
+
* base-256 encoding.
|
|
718
|
+
*/
|
|
719
|
+
/** Size of a tar block. Every header and the archive length are multiples of this. */
|
|
720
|
+
const BLOCK_SIZE = 512;
|
|
721
|
+
/** Maximum bytes for the USTAR `name` field. */
|
|
722
|
+
const MAX_NAME_LENGTH = 100;
|
|
723
|
+
/** Maximum bytes for the USTAR `prefix` field (holds the leading directories). */
|
|
724
|
+
const MAX_PREFIX_LENGTH = 155;
|
|
725
|
+
const textEncoder = new TextEncoder();
|
|
726
|
+
const textDecoder = new TextDecoder();
|
|
727
|
+
/**
|
|
728
|
+
* Writes a UTF-8 string into a fixed-width header field, left-justified and
|
|
729
|
+
* NUL-padded (the block is already zero-filled, so nothing else is needed).
|
|
730
|
+
* Throws if the encoded value does not fit — tar fields are silently lossy
|
|
731
|
+
* otherwise, which corrupts the archive.
|
|
732
|
+
*/
|
|
733
|
+
function writeField(block, value, offset, length) {
|
|
734
|
+
const bytes = textEncoder.encode(value);
|
|
735
|
+
if (bytes.length > length) throw new RangeError(`tar field value ${JSON.stringify(value)} exceeds ${length} bytes`);
|
|
736
|
+
block.set(bytes, offset);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Writes a non-negative integer as a NUL-terminated, zero-padded octal string
|
|
740
|
+
* into a `length`-byte field (so `length - 1` octal digits). Values too large
|
|
741
|
+
* for octal fall back to the GNU base-256 extension, where the high bit of the
|
|
742
|
+
* first byte is set and the magnitude is stored big-endian in the remainder.
|
|
743
|
+
*/
|
|
744
|
+
function writeOctal(block, value, offset, length) {
|
|
745
|
+
const digits = length - 1;
|
|
746
|
+
if (value > 8 ** digits - 1) {
|
|
747
|
+
let v = value;
|
|
748
|
+
for (let i = offset + length - 1; i > offset; i--) {
|
|
749
|
+
block[i] = v & 255;
|
|
750
|
+
v = Math.floor(v / 256);
|
|
751
|
+
}
|
|
752
|
+
block[offset] = 128;
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const str = value.toString(8).padStart(digits, "0");
|
|
756
|
+
for (let i = 0; i < digits; i++) block[offset + i] = str.charCodeAt(i);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Computes and writes the USTAR header checksum: the unsigned sum of all 512
|
|
760
|
+
* header bytes with the checksum field itself taken as ASCII spaces. Stored as
|
|
761
|
+
* six octal digits, a NUL, then a space (the conventional encoding).
|
|
762
|
+
*/
|
|
763
|
+
function writeChecksum(block) {
|
|
764
|
+
for (let i = 148; i < 156; i++) block[i] = 32;
|
|
765
|
+
let sum = 0;
|
|
766
|
+
for (let i = 0; i < BLOCK_SIZE; i++) sum += block[i];
|
|
767
|
+
const str = sum.toString(8).padStart(6, "0");
|
|
768
|
+
for (let i = 0; i < 6; i++) block[148 + i] = str.charCodeAt(i);
|
|
769
|
+
block[154] = 0;
|
|
770
|
+
block[155] = 32;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Splits a path into USTAR `name` (<=100 bytes) and `prefix` (<=155 bytes)
|
|
774
|
+
* fields at a `/` boundary; a reader rejoins them as `prefix + "/" + name`.
|
|
775
|
+
* Returns an empty prefix when the whole path fits in `name`. Throws when the
|
|
776
|
+
* path cannot be represented — a trailing component over 100 bytes, or leading
|
|
777
|
+
* directories that don't fit in 155. RocksDB and transaction-log filenames never
|
|
778
|
+
* hit this; only a pathological log-store name would.
|
|
779
|
+
*/
|
|
780
|
+
function splitTarName(path) {
|
|
781
|
+
const encoded = textEncoder.encode(path);
|
|
782
|
+
if (encoded.length <= MAX_NAME_LENGTH) return {
|
|
783
|
+
name: path,
|
|
784
|
+
prefix: ""
|
|
785
|
+
};
|
|
786
|
+
let best = -1;
|
|
787
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
788
|
+
if (encoded[i] !== 47) continue;
|
|
789
|
+
const prefixLength = i;
|
|
790
|
+
const nameLength = encoded.length - i - 1;
|
|
791
|
+
if (nameLength > 0 && nameLength <= MAX_NAME_LENGTH && prefixLength <= MAX_PREFIX_LENGTH) best = i;
|
|
792
|
+
}
|
|
793
|
+
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`);
|
|
794
|
+
return {
|
|
795
|
+
name: textDecoder.decode(encoded.subarray(best + 1)),
|
|
796
|
+
prefix: textDecoder.decode(encoded.subarray(0, best))
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Builds a single 512-byte USTAR header block for a regular file. Long paths are
|
|
801
|
+
* split across the `name` and `prefix` fields (see {@link splitTarName}).
|
|
802
|
+
*/
|
|
803
|
+
function buildHeader(name, size, mode, mtime) {
|
|
804
|
+
const block = new Uint8Array(BLOCK_SIZE);
|
|
805
|
+
const split = splitTarName(name);
|
|
806
|
+
writeField(block, split.name, 0, MAX_NAME_LENGTH);
|
|
807
|
+
writeOctal(block, mode & 4095, 100, 8);
|
|
808
|
+
writeOctal(block, 0, 108, 8);
|
|
809
|
+
writeOctal(block, 0, 116, 8);
|
|
810
|
+
writeOctal(block, size, 124, 12);
|
|
811
|
+
writeOctal(block, Math.floor(mtime), 136, 12);
|
|
812
|
+
block[156] = 48;
|
|
813
|
+
writeField(block, "ustar", 257, 6);
|
|
814
|
+
block[263] = 48;
|
|
815
|
+
block[264] = 48;
|
|
816
|
+
if (split.prefix.length > 0) writeField(block, split.prefix, 345, MAX_PREFIX_LENGTH);
|
|
817
|
+
writeChecksum(block);
|
|
818
|
+
return block;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Streaming USTAR encoder. Usage is a strict sequence per file —
|
|
822
|
+
* {@link TarEncoder.addFile} once, then {@link TarEncoder.writeData} until the
|
|
823
|
+
* declared size is reached — followed by a single {@link TarEncoder.finalize}:
|
|
824
|
+
*
|
|
825
|
+
* ```ts
|
|
826
|
+
* const tar = new TarEncoder((chunk) => writer.write(chunk));
|
|
827
|
+
* await tar.addFile('CURRENT', current.length);
|
|
828
|
+
* await tar.writeData(current);
|
|
829
|
+
* await tar.addFile('000123.sst', sstSize);
|
|
830
|
+
* for await (const chunk of sstChunks) await tar.writeData(chunk);
|
|
831
|
+
* await tar.finalize();
|
|
832
|
+
* ```
|
|
833
|
+
*
|
|
834
|
+
* The size passed to `addFile` is authoritative: tar records it in the header
|
|
835
|
+
* before any payload, so it must be known up front (it is — RocksDB's
|
|
836
|
+
* `GetLiveFilesStorageInfo` returns each file's exact size). Writing more or
|
|
837
|
+
* fewer bytes than declared throws.
|
|
838
|
+
*/
|
|
839
|
+
var TarEncoder = class {
|
|
840
|
+
#sink;
|
|
841
|
+
/** Payload bytes still expected for the current entry. */
|
|
842
|
+
#remaining = 0;
|
|
843
|
+
/** Zero-padding bytes owed after the current entry's payload reaches a block boundary. */
|
|
844
|
+
#pad = 0;
|
|
845
|
+
#finalized = false;
|
|
846
|
+
constructor(sink) {
|
|
847
|
+
this.#sink = sink;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Emits the header for a new file entry. Must be preceded by completing any
|
|
851
|
+
* prior entry's payload. After this, exactly `size` bytes must be supplied
|
|
852
|
+
* via {@link TarEncoder.writeData} before the next `addFile` or `finalize`.
|
|
853
|
+
*/
|
|
854
|
+
async addFile(name, size, options) {
|
|
855
|
+
if (this.#finalized) throw new Error("TarEncoder already finalized");
|
|
856
|
+
if (this.#remaining !== 0) throw new Error(`previous tar entry incomplete: ${this.#remaining} byte(s) unwritten`);
|
|
857
|
+
if (!Number.isInteger(size) || size < 0) throw new RangeError("tar entry size must be a non-negative integer");
|
|
858
|
+
const header = buildHeader(name, size, options?.mode ?? 420, options?.mtime ?? 0);
|
|
859
|
+
this.#remaining = size;
|
|
860
|
+
this.#pad = (BLOCK_SIZE - size % BLOCK_SIZE) % BLOCK_SIZE;
|
|
861
|
+
await this.#sink(header);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Streams a chunk of the current entry's payload. Chunks may be any size; the
|
|
865
|
+
* total across all `writeData` calls for an entry must equal the size passed
|
|
866
|
+
* to {@link TarEncoder.addFile}. Block padding is emitted automatically once
|
|
867
|
+
* the final byte of an entry is written.
|
|
868
|
+
*/
|
|
869
|
+
async writeData(chunk) {
|
|
870
|
+
if (this.#finalized) throw new Error("TarEncoder already finalized");
|
|
871
|
+
if (chunk.length === 0) return;
|
|
872
|
+
if (chunk.length > this.#remaining) throw new RangeError(`tar data overruns entry size by ${chunk.length - this.#remaining} byte(s)`);
|
|
873
|
+
this.#remaining -= chunk.length;
|
|
874
|
+
await this.#sink(chunk);
|
|
875
|
+
if (this.#remaining === 0 && this.#pad > 0) {
|
|
876
|
+
const pad = this.#pad;
|
|
877
|
+
this.#pad = 0;
|
|
878
|
+
await this.#sink(new Uint8Array(pad));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Convenience for an entry whose contents are already fully in memory (e.g.
|
|
883
|
+
* the small `CURRENT`/`OPTIONS` files RocksDB returns inline).
|
|
884
|
+
*/
|
|
885
|
+
async addFileData(name, data, options) {
|
|
886
|
+
await this.addFile(name, data.length, options);
|
|
887
|
+
await this.writeData(data);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Writes the two zero-filled blocks that mark end-of-archive. The encoder is
|
|
891
|
+
* unusable afterward. Throws if the current entry is not fully written, so a
|
|
892
|
+
* truncated stream cannot be silently finalized into a valid-looking archive.
|
|
893
|
+
*/
|
|
894
|
+
async finalize() {
|
|
895
|
+
if (this.#finalized) throw new Error("TarEncoder already finalized");
|
|
896
|
+
if (this.#remaining !== 0) throw new Error(`cannot finalize: current tar entry has ${this.#remaining} byte(s) unwritten`);
|
|
897
|
+
this.#finalized = true;
|
|
898
|
+
await this.#sink(new Uint8Array(BLOCK_SIZE * 2));
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
//#endregion
|
|
903
|
+
//#region src/backup-stream.ts
|
|
904
|
+
/** Native event discriminator: a new file header (vs. a payload chunk). */
|
|
905
|
+
const EVENT_FILE = 0;
|
|
906
|
+
/**
|
|
907
|
+
* Streams a consistent snapshot of the database to a `WritableStream` as a tar
|
|
908
|
+
* archive, with no intermediate copy on disk. The set of live RocksDB files is
|
|
909
|
+
* enumerated and read natively; each file header and payload chunk is framed
|
|
910
|
+
* into the {@link TarEncoder} here in JS and written to `stream`.
|
|
911
|
+
*
|
|
912
|
+
* Backpressure flows end to end: the encoder awaits each `writer.write()`, and
|
|
913
|
+
* the native producer awaits each `emit()` before reading the next chunk, so a
|
|
914
|
+
* slow consumer paces the entire pipeline rather than buffering in memory.
|
|
915
|
+
*
|
|
916
|
+
* The resulting archive can be unpacked with any tar tool (`tar -xf`) into a
|
|
917
|
+
* directory that opens directly as a RocksDB database.
|
|
918
|
+
*/
|
|
919
|
+
async function backupToStream(db, stream, options) {
|
|
920
|
+
let tarDestination;
|
|
921
|
+
let piped;
|
|
922
|
+
if (options?.gzip) {
|
|
923
|
+
const gzip = new CompressionStream("gzip");
|
|
924
|
+
piped = gzip.readable.pipeTo(stream);
|
|
925
|
+
tarDestination = gzip.writable;
|
|
926
|
+
} else tarDestination = stream;
|
|
927
|
+
const writer = tarDestination.getWriter();
|
|
928
|
+
const tar = new TarEncoder((chunk) => writer.write(chunk));
|
|
929
|
+
const emit = async (kind, data, size, mtime) => {
|
|
930
|
+
if (kind === EVENT_FILE) await tar.addFile(data, size, { mtime });
|
|
931
|
+
else await tar.writeData(data);
|
|
932
|
+
};
|
|
933
|
+
try {
|
|
934
|
+
await new Promise((resolve, reject) => {
|
|
935
|
+
db.backupStream(resolve, reject, emit, options);
|
|
936
|
+
});
|
|
937
|
+
await tar.finalize();
|
|
938
|
+
await writer.close();
|
|
939
|
+
if (piped) await piped;
|
|
940
|
+
} catch (err) {
|
|
941
|
+
await writer.abort(err).catch(() => {});
|
|
942
|
+
if (piped) await piped.catch(() => {});
|
|
943
|
+
throw err;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
546
947
|
//#endregion
|
|
547
948
|
//#region src/dbi-iterator.ts
|
|
548
949
|
const { ITERATOR_RESULT_DONE, ITERATOR_RESULT_FAST } = constants;
|
|
@@ -925,19 +1326,9 @@ var Store = class {
|
|
|
925
1326
|
}
|
|
926
1327
|
return new Promise((resolve, reject) => this.db.compact(resolve, reject, startBuffer, endBuffer));
|
|
927
1328
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
* are created as needed. See `backups` for restore and management.
|
|
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));
|
|
1329
|
+
async backup(target, options) {
|
|
1330
|
+
if (typeof target !== "string" && typeof target?.getWriter === "function") return backupToStream(this.db, target, options);
|
|
1331
|
+
return new Promise((resolve, reject) => this.db.backup(resolve, reject, target, options));
|
|
941
1332
|
}
|
|
942
1333
|
/**
|
|
943
1334
|
* Compacts the entire key range of the database synchronously.
|
|
@@ -1506,25 +1897,9 @@ var RocksDatabase = class RocksDatabase extends DBI {
|
|
|
1506
1897
|
compact(options) {
|
|
1507
1898
|
return this.store.compact(options);
|
|
1508
1899
|
}
|
|
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);
|
|
1900
|
+
backup(target, options) {
|
|
1901
|
+
if (typeof target === "string") return this.store.backup(target, options);
|
|
1902
|
+
return this.store.backup(target, options);
|
|
1528
1903
|
}
|
|
1529
1904
|
/**
|
|
1530
1905
|
* Creates a hardlinked, point-in-time, fully independent copy of the entire
|
|
@@ -2409,7 +2784,7 @@ function loadLastPosition(transactionLog, readUncommitted) {
|
|
|
2409
2784
|
//#region src/index.ts
|
|
2410
2785
|
const versions = {
|
|
2411
2786
|
rocksdb: version,
|
|
2412
|
-
"rocksdb-js": "2.
|
|
2787
|
+
"rocksdb-js": "2.4.1"
|
|
2413
2788
|
};
|
|
2414
2789
|
|
|
2415
2790
|
//#endregion
|
|
@@ -2423,9 +2798,11 @@ exports.backups = backups;
|
|
|
2423
2798
|
exports.constants = constants;
|
|
2424
2799
|
exports.coolTransactionLogs = coolTransactionLogs;
|
|
2425
2800
|
exports.currentThreadId = currentThreadId;
|
|
2801
|
+
exports.fileLockRelease = fileLockRelease;
|
|
2426
2802
|
exports.parseTransactionLog = parseTransactionLog;
|
|
2427
2803
|
exports.registryStatus = registryStatus;
|
|
2428
2804
|
exports.shutdown = shutdown;
|
|
2429
2805
|
exports.stats = stats;
|
|
2806
|
+
exports.tryFileLock = tryFileLock;
|
|
2430
2807
|
exports.versions = versions;
|
|
2431
2808
|
//# sourceMappingURL=index.cjs.map
|