@harperfast/rocksdb-js 2.4.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 CHANGED
@@ -47,6 +47,13 @@ Creates a new database instance.
47
47
  - `enableStats: boolean` When `true` and the database is open, RocksDB will captures stats that
48
48
  are retrieved by calling `db.getStats()`. Enabling statistics imposes 5-10% in overhead.
49
49
  Defaults to `false`.
50
+ - `maxOpenFiles: number` The maximum number of table files RocksDB keeps open. `0` (the default)
51
+ derives a budget from the effective per-process open-file limit (an eighth of the limit —
52
+ several databases can share one process — clamped to `[1024, 262144]`); `-1` holds every table
53
+ file open (the RocksDB default, which can exhaust the process file-descriptor limit when
54
+ compaction falls behind under sustained ingest); a positive `int32` is an explicit cap. Reads
55
+ only pay a reopen cost when the number of live table files exceeds the budget, so raise the
56
+ process fd limit (and with it the derived budget) for very large databases.
50
57
  - `name: string` The column family name. Defaults to `"default"`.
51
58
  - `noBlockCache: boolean` When `true`, disables the block cache. Block caching is enabled by
52
59
  default and the cache is shared across all database instances.
@@ -996,18 +1003,28 @@ Note: If the `callback` throws an error, Node.js suppress the error. Node.js 18.
996
1003
  `--force-node-api-uncaught-exceptions-policy` flag which will cause errors to emit the
997
1004
  `'uncaughtException'` event. Future Node.js releases will enable this flag by default.
998
1005
 
999
- ## Exclusive File Locking
1006
+ ## File Locking
1000
1007
 
1001
1008
  `rocksdb-js` includes helper functions for creating lock files and releasing them using native APIs.
1002
1009
  This can be used to prevent multiple processes from concurrently accessing a resource. The lock is
1003
1010
  automatically released when the process exits.
1004
1011
 
1005
- ### `tryFileLock(file: string): number`
1012
+ ### `tryFileLock(file: string, shared?: boolean): number`
1006
1013
 
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.
1014
+ Attempts to acquire a lock on the given file, creating the file and any missing parent directories.
1015
+ The lock is exclusive by default; pass `shared: true` for a shared (reader) lock, which coexists
1016
+ with other shared holders but conflicts with an exclusive holder in either direction. Returns a
1017
+ non-zero token to pass to `fileLockRelease` if the lock was acquired, or `0` if a conflicting
1018
+ holder — in any process, container, or worker thread — currently has it. Throws on a hard error.
1019
+
1020
+ A shared lock needs only read access: it opens the file read-only and does not create it (falling
1021
+ back to create only when the file is missing on a writable directory), so it can lock an existing
1022
+ lock file on a read-only filesystem. If the media is read-only for every process (`EROFS` /
1023
+ `ERROR_WRITE_PROTECT`) so the open fails outright, a shared acquire degrades to a successful no-op
1024
+ token — there can be no exclusive holder to exclude on a directory nothing can write. A plain
1025
+ permission denial (`EACCES` / `ERROR_ACCESS_DENIED`) is _not_ degraded — only the caller is blocked,
1026
+ so a privileged holder may still exist — and throws instead. An exclusive lock always opens
1027
+ read-write and creates the file, and so requires a writable location.
1011
1028
 
1012
1029
  ```typescript
1013
1030
  import { tryFileLock } from '@harperfast/rocksdb-js';
@@ -1372,6 +1389,57 @@ Returns an object containing all of the information in the log file.
1372
1389
  - `length: number` The size of the entry data.
1373
1390
  - `timestamp: number` The entry timestamp.
1374
1391
 
1392
+ ### Transaction Log Validation
1393
+
1394
+ #### `validateTransactionLogStore(path, options?)`
1395
+
1396
+ Validates a transaction log store directory (`<dbDir>/transaction_logs/<name>`): every
1397
+ `<sequence>.txnlog` file is checked for a valid header and intact entry framing (using the same
1398
+ scan as open-time crash recovery), file-name and sequence continuity are checked, and the
1399
+ `txn.state` side file (when present) is checked for shape and a plausible flushed position.
1400
+
1401
+ Validation runs natively on a worker thread and does not require (or open) a database, so it works
1402
+ on the store of a closed database and on backup snapshots alike. It resolves with a report rather
1403
+ than throwing when the store is invalid — check `result.valid`. It rejects only when `path` does
1404
+ not exist or is not a directory.
1405
+
1406
+ ```typescript
1407
+ const result = await validateTransactionLogStore('/path/to/db/transaction_logs/mylog');
1408
+ if (!result.valid) {
1409
+ console.error(result.errors);
1410
+ for (const file of result.files) {
1411
+ console.error(file.file, file.errors, file.warnings);
1412
+ }
1413
+ }
1414
+ ```
1415
+
1416
+ Returns a `TransactionLogStoreValidation` object:
1417
+
1418
+ - `path: string` The validated store directory.
1419
+ - `valid: boolean` `true` when there are no store-level errors and every log file is valid.
1420
+ - `errors: string[]` Store-level errors (malformed file names, corrupt `txn.state`).
1421
+ - `warnings: string[]` Store-level warnings (sequence gaps, unexpected files).
1422
+ - `files: TransactionLogFileValidation[]` Per-file results, ordered by sequence number.
1423
+ - `file: string` File name, e.g. `"3.txnlog"`.
1424
+ - `sequence: number` Sequence number parsed from the file name.
1425
+ - `size: number` On-disk file size in bytes.
1426
+ - `entries: number` Number of well-formed entries.
1427
+ - `validBytes: number` End offset of the validly framed data.
1428
+ - `valid: boolean` `true` when the file has no errors.
1429
+ - `errors: string[]` Bad header, unsupported version, or mid-file framing corruption.
1430
+ - `warnings: string[]` Torn tail (recoverable on next open) and per-entry anomalies.
1431
+
1432
+ Options:
1433
+
1434
+ - `strict?: boolean` Report conditions that indicate an incomplete snapshot — a torn tail, a
1435
+ sequence gap, or a `txn.state` flushed position beyond the newest log file or beyond its file's
1436
+ actual size — as errors instead of warnings. A torn tail on a live store is a normal crash
1437
+ artifact that open-time recovery truncates, but a backup snapshot must be clean end to end —
1438
+ `backups.verify()` uses `strict: true`. Defaults to `false`.
1439
+
1440
+ Validating a store that is being actively appended to can spuriously report a torn tail for the
1441
+ current log file — the tail of an in-flight append is indistinguishable from a crash artifact.
1442
+
1375
1443
  ### `currentThreadId(): number`
1376
1444
 
1377
1445
  Returns the current thread ID.
@@ -1464,11 +1532,14 @@ grouped under the `backups` namespace export.
1464
1532
 
1465
1533
  > **Only one backup per directory may be in-flight at a time.** RocksDB has no cross-engine lock on
1466
1534
  > 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.
1535
+ > `backups.purge()` — take an on-disk lock (a `.backup.lock` file) for the directory.
1536
+ > `backups.restore()` takes the same lock in **shared** mode: concurrent restores run in parallel,
1537
+ > but a writer and a restore on the same directory exclude each other, so a `purge` cannot delete
1538
+ > the backup a restore is copying from. A conflicting operation on the same directory, whether from
1539
+ > the same process, a `worker_thread`, or a separate process, **rejects** with a "locked" error
1540
+ > rather than corrupting the backup; retry once the in-flight operation finishes. Operations on
1541
+ > _different_ directories run in parallel, and the read-only operations (`list`, `verify`) are not
1542
+ > locked.
1472
1543
 
1473
1544
  ```typescript
1474
1545
  import { RocksDatabase, backups } from '@harperfast/rocksdb-js';
@@ -1491,6 +1562,10 @@ re-copied.
1491
1562
  When the database was opened with `disableWAL`, the memtable is flushed before the backup by
1492
1563
  default so unflushed data is not lost; otherwise flushing follows `options.flushBeforeBackup`.
1493
1564
 
1565
+ `backupDir` must not be the database directory itself or a path beneath it — backing up into the
1566
+ live database directory would write backup files on top of RocksDB's own files. Such a call rejects
1567
+ before anything is written.
1568
+
1494
1569
  ```typescript
1495
1570
  const id = await db.backup('/path/to/backups', { metadata: 'nightly-2026-06-04' });
1496
1571
  ```
@@ -1500,6 +1575,7 @@ const id = await db.backup('/path/to/backups', { metadata: 'nightly-2026-06-04'
1500
1575
  | Option | Type | Default | Description |
1501
1576
  | ------------------------- | --------- | ---------------------- | ------------------------------------------------------------------------------------ |
1502
1577
  | `backupLogFiles` | `boolean` | `true` | Include write-ahead log files in the backup. |
1578
+ | `checkDiskSpace` | `boolean` | `true` | Preflight the destination volume and reject if it lacks room for the backup. |
1503
1579
  | `flushBeforeBackup` | `boolean` | `true` if WAL disabled | Flush the memtable before backing up. |
1504
1580
  | `maxBackgroundOperations` | `number` | `1` | Number of background threads used to copy files. |
1505
1581
  | `metadata` | `string` | `''` | Application metadata stored with the backup, returned by `list()`. |
@@ -1515,6 +1591,16 @@ backup either has its complete snapshot or none. The snapshot is captured just a
1515
1591
  engine snapshot, so restored logs may run slightly ahead of the restored key-value data (never
1516
1592
  behind it), which is safe for redo-style logs replayed against the restored data.
1517
1593
 
1594
+ `checkDiskSpace` (on by default) rejects with an I/O error — its message reports the available and
1595
+ required bytes — before any files are copied, so a full destination fails fast instead of partway
1596
+ through (a mid-copy failure can leave zero usable backups). The required size is estimated
1597
+ conservatively as a full copy of the database's live files, plus the transaction-log snapshot when
1598
+ `transactionLogs` is set; since a backup only ever copies files, this can never under-estimate.
1599
+ Incremental backups to a right-sized volume may therefore be over-rejected — set `checkDiskSpace:
1600
+ false` to skip the check. It also self-disables when free space can't be determined (e.g. some
1601
+ network filesystems report `0`), so it never blocks a backup on an untrustworthy number. Only
1602
+ directory-target backups are checked; the streaming backup path is unaffected.
1603
+
1518
1604
  ### `db.backup(stream: WritableStream<Uint8Array>, options?: BackupStreamOptions): Promise<void>`
1519
1605
 
1520
1606
  Streams a consistent snapshot of the entire database to `stream` as a tar archive, with **no
@@ -1551,6 +1637,20 @@ Restores a backup from `backupDir` into `dbDir` (creating parent directories as
1551
1637
  database must **not** be open at `dbDir`, and the default restore mode is **destructive** — it
1552
1638
  purges `dbDir` before restoring. Restoring into the backup directory itself is rejected.
1553
1639
 
1640
+ The restore holds the backup directory's `.backup.lock` in shared mode for its duration, so it
1641
+ rejects with a "locked" error while a writer (`db.backup()`, `backups.delete()`, `backups.purge()`)
1642
+ is in flight — and writers reject while the restore runs. Concurrent restores from the same
1643
+ directory are allowed.
1644
+
1645
+ Because a restore only reads the backup, the shared lock needs no write access to `backupDir`:
1646
+ restoring from a **read-only backup directory** (an immutable/WORM store or a read-only-mounted
1647
+ volume) works — it locks the existing `.backup.lock` read-only, and if the media is read-only for
1648
+ every process (`EROFS`), the lock degrades to a no-op, since nothing can write the directory and so
1649
+ no writer can be racing the restore. A mere permission denial is _not_ treated this way — it means
1650
+ only the current identity is blocked, so a more-privileged writer could still be running, and the
1651
+ restore fails loudly rather than skipping coordination. Writers always need write access and fail on
1652
+ a read-only directory.
1653
+
1554
1654
  ```typescript
1555
1655
  // Restore the latest backup.
1556
1656
  await backups.restore('/path/to/backups', '/path/to/restored-db');
@@ -1592,11 +1692,17 @@ backup references them, so this is not equivalent to deleting files manually.
1592
1692
 
1593
1693
  Deletes all but the newest `keepCount` backups.
1594
1694
 
1595
- ### `backups.verify(backupDir: string, backupId: number, options?: { verifyWithChecksum?: boolean }): Promise<void>`
1695
+ ### `backups.verify(backupDir: string, backupId: number, options?: { verifyWithChecksum?: boolean; verifyTransactionLogs?: boolean }): Promise<void>`
1596
1696
 
1597
1697
  Verifies a backup's file sizes, and optionally their checksums (which requires reading all
1598
1698
  backed-up data). Resolves if the backup is intact and rejects otherwise.
1599
1699
 
1700
+ When the backup was created with `transactionLogs: true`, the backup's transaction log snapshot
1701
+ (`<backupDir>/transaction_logs/<backupId>/`) is also validated with
1702
+ [`validateTransactionLogStore`](#validatetransactionlogstorepath-options) in strict mode — every
1703
+ log file's header and entry framing must be intact (snapshots are copied on committed entry
1704
+ boundaries, so even a torn tail fails verification). Set `verifyTransactionLogs: false` to skip.
1705
+
1600
1706
  ```typescript
1601
1707
  await backups.verify('/path/to/backups', 1, { verifyWithChecksum: true });
1602
1708
  ```
@@ -1756,8 +1862,9 @@ Available commands:
1756
1862
  - `backups <dir> [subcommand]` Manage database backups; with no subcommand or `ls`/`list`, lists the backups in
1757
1863
  `<dir>`. Subcommands: `backup` (create a backup of the open database), `restore <backup-id>`
1758
1864
  (restore into the open database after confirmation; unavailable in read-only mode),
1759
- `verify <backup-id>` (checksum verification), `delete <backup-id>`, and `purge <keep-count>`
1760
- (delete all but the newest backups). `delete` and `purge` report the recovered disk space.
1865
+ `verify <backup-id>` (checksum verification, including the backup's transaction log snapshot),
1866
+ `delete <backup-id>`, and `purge <keep-count>` (delete all but the newest backups). `delete` and
1867
+ `purge` report the recovered disk space.
1761
1868
  - `clear` Clear all data in the current column family
1762
1869
  - `columns` List column families
1763
1870
  - `compact` Compact the current column family
@@ -1775,6 +1882,7 @@ Available commands:
1775
1882
  - `repl` Open a JS sub-REPL; "db" refers to the current column family
1776
1883
  - `stats` Show the statistics for the current column family
1777
1884
  - `use [column]` Create a new column family or switch to an existing one
1885
+ - `verify-logs [name]` Validate the transaction log store files (all stores if no name)
1778
1886
 
1779
1887
  ## Development
1780
1888
 
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { RocksDatabase, backups, parseTransactionLog, versions } from '../dist/index.mjs';
3
+ import {
4
+ RocksDatabase,
5
+ backups,
6
+ parseTransactionLog,
7
+ validateTransactionLogStore,
8
+ versions,
9
+ } from '../dist/index.mjs';
4
10
  import { existsSync, readdirSync } from 'node:fs';
5
11
  import { mkdir, readdir, stat } from 'node:fs/promises';
6
12
  import { join, resolve } from 'node:path';
@@ -29,6 +35,7 @@ const COMMANDS = {
29
35
  repl: replCommand,
30
36
  stats: statsCommand,
31
37
  use: useCommand,
38
+ 'verify-logs': verifyLogsCommand,
32
39
  };
33
40
 
34
41
  const BACKUP_SUBCOMMANDS = ['backup', 'restore', 'verify', 'delete', 'purge'];
@@ -73,13 +80,19 @@ function completer(line) {
73
80
  return [hits.length ? hits : columns, arg];
74
81
  }
75
82
 
76
- if ((command === 'log' || command === 'logs') && currentDB) {
83
+ if (
84
+ (command === 'log' ||
85
+ command === 'logs' ||
86
+ command === 'verify-logs' ||
87
+ command === 'purge-logs') &&
88
+ currentDB
89
+ ) {
77
90
  if (parts.length === 2) {
78
91
  const arg = parts[1];
79
92
  const logs = currentDB.listLogs();
80
93
  const hits = logs.filter((l) => l.startsWith(arg));
81
94
  return [(hits.length ? hits : logs).map((s) => `${s} `), arg];
82
- } else if (parts.length === 3) {
95
+ } else if (parts.length === 3 && (command === 'log' || command === 'logs')) {
83
96
  const [name, store] = parts.slice(1);
84
97
  const logs = currentDB.listLogs();
85
98
  const hit = logs.find((l) => l === name);
@@ -616,6 +629,9 @@ function helpCommand() {
616
629
  console.log(
617
630
  `${hl('use [column]')} Create a new column family or switch to an existing one`
618
631
  );
632
+ console.log(
633
+ `${hl('verify-logs [name]')} Validate transaction log store files (all stores if no name)`
634
+ );
619
635
  console.log();
620
636
  }
621
637
 
@@ -732,6 +748,54 @@ async function purgeLogsCommand(args) {
732
748
  console.log();
733
749
  }
734
750
 
751
+ async function verifyLogsCommand(args) {
752
+ const [name] = args;
753
+ const logs = currentDB.listLogs();
754
+ if (name && !logs.includes(name)) {
755
+ console.log(`Log store ${hl(name)} does not exist\n`);
756
+ return;
757
+ }
758
+ if (logs.length === 0) {
759
+ console.log('No log stores found\n');
760
+ return;
761
+ }
762
+
763
+ let invalidStores = 0;
764
+ for (const storeName of name ? [name] : logs) {
765
+ const storePath = join(currentDB.path, 'transaction_logs', storeName);
766
+ const { result, time } = await run(() => validateTransactionLogStore(storePath));
767
+ console.log(`${hl(storeName)} ${result.valid ? 'OK' : bad('INVALID')} ${note(`(${time}ms)`)}`);
768
+ if (!result.valid) {
769
+ invalidStores++;
770
+ }
771
+ for (const error of result.errors) {
772
+ console.log(` ${bad(`! ${error}`)}`);
773
+ }
774
+ for (const warning of result.warnings) {
775
+ console.log(` ${warn(`! ${warning}`)}`);
776
+ }
777
+ for (const file of result.files) {
778
+ console.log(
779
+ ` ${file.file} ${file.valid ? 'OK' : bad('INVALID')} ${note(
780
+ `${file.entries.toLocaleString()} entr${file.entries === 1 ? 'y' : 'ies'}, ${formatBytes(file.size)}`
781
+ )}`
782
+ );
783
+ for (const error of file.errors) {
784
+ console.log(` ${bad(`! ${error}`)}`);
785
+ }
786
+ for (const warning of file.warnings) {
787
+ console.log(` ${warn(`! ${warning}`)}`);
788
+ }
789
+ }
790
+ }
791
+ if (invalidStores > 0) {
792
+ console.log(
793
+ `\n${bad(`${invalidStores} log store${invalidStores === 1 ? ' is' : 's are'} invalid`)}`
794
+ );
795
+ }
796
+ console.log();
797
+ }
798
+
735
799
  async function putCommand(args) {
736
800
  const [key, ...valueParts] = args;
737
801
  if (!key || valueParts.length === 0) {