@harperfast/rocksdb-js 2.12.0 → 2.14.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
@@ -46,6 +46,32 @@ Creates a new database instance.
46
46
  - `path: string` The path to write the database files to. This path does not need to exist, but the
47
47
  parent directories do.
48
48
  - `options: object` [optional]
49
+ - `arenaBlockSize: number` The size in bytes of the blocks a column family's memtable allocates
50
+ from. Every column family that has been written to holds at least one block, and with a
51
+ `writeBufferManagerSize` set, those blocks count against that shared budget even when the
52
+ column family is idle — so with many column families, large blocks can use up most of a small
53
+ budget and cause constant tiny flushes. Defaults to `0`, which picks a size automatically: with
54
+ a write buffer manager, `writeBufferManagerSize / 2048`, but at least 64 KiB and at most
55
+ `writeBufferSize / 8` or 1 MiB, whichever is smaller (a 64 MiB budget gives 64 KiB, 512 MiB
56
+ gives 256 KiB, 2 GiB or more gives 1 MiB); without one, RocksDB's own default of
57
+ `writeBufferSize / 8` capped at 1 MiB (1 MiB at the default `writeBufferSize`). The automatic
58
+ size uses the manager's budget at the time the column family is opened. An explicit value is
59
+ used as given, except that RocksDB raises one below 4 KiB to 4 KiB. Like `writeBufferSize`,
60
+ this applies to every column family when a database is first opened in the process and to
61
+ column families created later; a column family that is already open keeps its size.
62
+ - `bloomFilterBitsPerKey: number` The size of the filter written into each SST file, in bits per
63
+ key. A `get()` of a key that is not in a file checks that file's filter and skips its index and
64
+ data blocks, so reads of absent keys, and of keys whose range overlaps several files (keys
65
+ written in random order, or updates still in level 0), touch fewer blocks. Filters cover whole
66
+ keys only, so range reads are unaffected. Defaults to `10` (about 1% false positives); otherwise
67
+ a number from 1 to 100, fractions allowed, or `0` for no filters. Each open SST file holds its
68
+ filter in memory beside its index, outside the block cache (about 1.25 bytes per key at `10`;
69
+ a `secondaryPath` open holds every file open, so the whole database's filters). `0` also stops
70
+ an open from loading the filters files already have, so a reader can trade them for memory. An existing
71
+ database opens with the new setting, but its files gain or lose filters only as compaction
72
+ rewrites them (`compact({ bottommost: true })` rewrites them all). Like `writeBufferSize`, this
73
+ applies to every column family when a database is first opened in the process and to column
74
+ families created later; a column family that is already open keeps its setting.
49
75
  - `compression: string | { algorithm: string, level?: number }` The block/blob compression
50
76
  algorithm for this column family. Pass an algorithm name — one of `'none'`, `'snappy'`,
51
77
  `'zlib'`, `'bzip2'`, `'lz4'`, `'lz4hc'`, or `'zstd'` — or an object with an
@@ -119,9 +145,10 @@ Creates a new database instance.
119
145
  - `readOnly: boolean` When `true`, the database is opened in read-only mode. Read operations are
120
146
  permitted. Write operations will throw an error with code `ERR_DATABASE_READONLY`. Transactions
121
147
  are a no-op in read-only mode. [`flush()`](#dbflushoptions-promisevoid),
122
- [`flushSync()`](#dbflushsyncoptions-void), [`compact()`](#dbcompactoptions-promisevoid) and
123
- [`compactSync()`](#dbcompactsyncoptions-void) are the exception: they have nothing to do when
124
- there are no writes, so they succeed as no-ops rather than throwing.
148
+ [`flushSync()`](#dbflushsyncoptions-void), [`compact()`](#dbcompactoptions-promisevoid),
149
+ [`compactSync()`](#dbcompactsyncoptions-void) and [`syncWal()`](#dbsyncwal-promisevoid) are the
150
+ exception: they have nothing to do when there are no writes, so they succeed as no-ops rather
151
+ than throwing.
125
152
 
126
153
  A read-only open is a **point-in-time snapshot**: it never sees later writes. It is safe
127
154
  against a quiescent database, but **unsafe against a live writer**: the open holds no reference
@@ -188,11 +215,14 @@ Creates a new database instance.
188
215
  nonempty directory that reappears before first use is refused until every handle closes and the
189
216
  path reopens with this option. The name must be a single path component that does not start with
190
217
  `.`: startup discovery skips such directories, so the log could never be scanned again.
191
- - `verificationTable: boolean` When `true`, this column family participates in the process-global
192
- [Verification Table](#verification-table): transaction writes to this column family invalidate
193
- the verification slot for each written key. Enable this only for column families whose records
194
- are cached (e.g. the primary column family of a table). Defaults to `false`. Requires
195
- `verificationTableEntries` to be configured before the first database is opened.
218
+ - `verificationTable: boolean` When `true`, this handle participates in the process-global
219
+ [Verification Table](#verification-table): writes through it invalidate the verification slot
220
+ for each written key, and reads through it may seed slots. Enable this only for column families
221
+ whose records are cached (e.g. the primary column family of a table). Defaults to `false`; a
222
+ handle that leaves it unset joins a column family another handle opened with `true`, and an
223
+ explicit `false` throws while any handle on that column family participates (see
224
+ [Verification Table](#verification-table)). Requires `verificationTableEntries` to be configured
225
+ before the first database is opened.
196
226
  - `writeBufferSize: number` The per-column-family memtable size in bytes at which the memtable is
197
227
  sealed and flushed to an SST file. Smaller values produce more frequent, faster flushes; larger
198
228
  values batch more writes per SST file at the cost of memory. Defaults to `16777216` (16 MB).
@@ -200,13 +230,16 @@ Creates a new database instance.
200
230
  ### `db.close()`
201
231
 
202
232
  Closes a database. This function can be called multiple times and will only close an opened
203
- database. A database instance can be reopened once it is closed. A flush failure leaves the native
204
- database quarantined so `shutdown()` can retry without losing unflushed data; an explicit
205
- `destroy()` can instead delete it. A failure while waiting for compaction to settle is reported
206
- after native teardown completes; the optional `compactOnClose` pass itself is best-effort and its
207
- errors do not fail the close, since a skipped compaction loses no data. All native close errors
208
- emit `database:closeFailed`. The quarantine applies to both writable and read-only opens because
209
- both modes share the physical path lifecycle.
233
+ database. A database instance can be reopened once it is closed. Closing the last handle flushes the
234
+ memtables and waits for that flush, then cancels background compaction instead of waiting for it:
235
+ a running compaction is abandoned and redone later, so close does not wait for the compaction
236
+ backlog (the flush itself still waits out a write stall). A flush failure leaves the native database quarantined so `shutdown()`
237
+ can retry without losing unflushed data; an explicit `destroy()` can instead delete it. A failure
238
+ from RocksDB's own final close is reported after native teardown completes; the optional
239
+ `compactOnClose` pass still runs to completion before background work is cancelled, but it is
240
+ best-effort and its errors do not fail the close, since a skipped compaction loses no data. All
241
+ native close errors emit `database:closeFailed`. The quarantine applies to both writable and
242
+ read-only opens because both modes share the physical path lifecycle.
210
243
 
211
244
  ```typescript
212
245
  const db = RocksDatabase.open('foo');
@@ -276,6 +309,21 @@ Sets global database settings.
276
309
  callers could already produce it. With `1`, a database's async commits run one at a time in
277
310
  the order `commit()` was called. Databases already open keep their limit.
278
311
  - `compactOnClose: boolean` When `true`, compacts the database on close. Defaults to `false`.
312
+ - `shutdownOnExit: boolean` When `true`, the main thread runs [`shutdown()`](#shutdown-void)
313
+ from a `process.on('exit')` listener, so `process.exit()` still flushes and closes every
314
+ database. Defaults to `true`. Set to `false` to leave memtables unflushed at a Node
315
+ `process.exit()`, e.g. to exercise crash recovery.
316
+ - `transactionLogAbortMarkers: boolean` When `true`, a transaction that wrote its
317
+ transaction-log batch and then ends without committing (an `abort()`, a commit error it gives
318
+ up on, or a worker torn down with it pending) gets that batch marked aborted in place, and every
319
+ reader skips it; see [Abort markers](#abort-markers). Defaults to `false`, which behaves as
320
+ before. Read when a transaction starts and when it is reset for a retry.
321
+ - `transactionLogWriteIntents: boolean` When `true` with `transactionLogAbortMarkers` on, a
322
+ conflicting attempt is refused before it writes its batch when an older transaction holds a
323
+ write intent on one of its keys; see [Abort markers](#abort-markers). This shortens the worst
324
+ waits of transactions that overlap heavily, but adds retries and costs throughput when
325
+ transactions are large or contention is low. Defaults to `false`. Needs `verificationTable`.
326
+ Read with `transactionLogAbortMarkers`.
279
327
  - `occValidation: 'parallel' | 'serial'` How writable optimistic databases opened after the call
280
328
  validate commits for conflicts. Defaults to `'parallel'` (RocksDB's default). Databases already
281
329
  open keep their policy. Pessimistic, read-only and secondary opens are unaffected.
@@ -328,6 +376,19 @@ Sets global database settings.
328
376
  [`db.isWriteStalled()`](#dbiswritestalled-boolean) — see
329
377
  [`getWriteBufferManagerStats()`](#getwritebuffermanagerstats-writebuffermanagerstats),
330
378
  which also describes the watchdog that logs a sustained stall.
379
+ - `writeBufferManagerFlushPolicy: 'oldest' | 'largest' | 'largestAcrossDBs'` What the write
380
+ buffer manager flushes when it is over budget. `'oldest'` flushes the oldest memtable of the
381
+ database that is being written; `'largest'` its largest memtable (databases here are opened with
382
+ atomic flush, so this currently behaves like `'oldest'`); `'largestAcrossDBs'` has a background
383
+ thread flush whichever database holds the most memtable memory, whether or not it is being
384
+ written. When not set, the policy is `'largestAcrossDBs'` while
385
+ `writeBufferManagerAllowStall` is `true` and `'oldest'` otherwise, and it follows later changes
386
+ to `writeBufferManagerAllowStall`. With stalls allowed, `'oldest'` can stop writes permanently:
387
+ a write to one database waits for memory held by other databases that nothing is writing to,
388
+ so nothing flushes them. Without stalls, `'largestAcrossDBs'` has measured slower. Can be
389
+ changed while databases are open. Once set, the value stays in effect for the life of the
390
+ process; it is reported as `flushPolicy` by
391
+ [`getWriteBufferManagerStats()`](#getwritebuffermanagerstats-writebuffermanagerstats).
331
392
  - `writeBufferManagerCostToCache: boolean` When `true`, memtable memory is "charged" against the
332
393
  shared block cache so the block cache and write buffers draw from a single pool. During write
333
394
  bursts the cache shrinks to make room for memtables; once memtables flush, the cache can grow
@@ -370,6 +431,8 @@ where the call was made.
370
431
  - `mutableMemoryUsage: number` The share of `memoryUsage` held by active (mutable) memtables; the
371
432
  rest is memtables awaiting flush plus retained write history.
372
433
  - `allowStall: boolean`, `costToCache: boolean` The manager's configuration.
434
+ - `flushPolicy: 'oldest' | 'largest' | 'largestAcrossDBs'` The flush policy in effect (see
435
+ `writeBufferManagerFlushPolicy`); before a manager exists, the policy one would be created with.
373
436
  - `stallActive: boolean` Whether the manager is currently stalling writes.
374
437
  - `stallActiveMs: number` How long the current stall has been active; `0` when not stalled.
375
438
  Sampled once a second by the watchdog, so `0` for a stall's first second and whenever
@@ -388,7 +451,7 @@ where the call was made.
388
451
 
389
452
  `bufferSize`, `memoryUsage`, `mutableMemoryUsage`, `stallActive` and `stallActiveMs` are also in
390
453
  [`db.getStats()`](#dbgetstatsall-boolean-rocksdbstats) and `db.getStat()` under the same
391
- `writeBufferManager.` prefix, for scraping; `enabled`, `allowStall`, `costToCache`,
454
+ `writeBufferManager.` prefix, for scraping; `enabled`, `allowStall`, `costToCache`, `flushPolicy`,
392
455
  `watchdogRunning`, and the column-family inventory are only here, because collecting them either
393
456
  walks the database registry or isn't scrape-shaped.
394
457
 
@@ -674,7 +737,11 @@ because releasing the native database beneath an active copy would be unsafe.
674
737
  A previously opened instance does not need to remain open, which allows an explicit `destroy()`
675
738
  retry after failed physical cleanup. A never-opened or read-only instance cannot destroy the
676
739
  database. `shutdown()` reports a pending cleanup tombstone but never retries deletion; only an
677
- explicit `destroy()` can remove the path.
740
+ explicit `destroy()` can remove the path. Destruction flushes the memtables before it closes the
741
+ native database, without waiting out a write stall, so a database whose files outlive the call (the
742
+ deletion fails, or the process dies first) keeps writes made with `disableWAL`. If that flush fails,
743
+ destruction still deletes the database and reports the flush error through `database:closeFailed`;
744
+ only when the deletion then fails too are the unflushed `disableWAL` writes lost.
678
745
 
679
746
  ```typescript
680
747
  db.destroy();
@@ -806,6 +873,22 @@ db.flushSync({ allowWriteStall: true });
806
873
  On a [read-only](#new-rocksdatabasepath-options) database it validates its options and then returns
807
874
  without flushing, rather than throwing.
808
875
 
876
+ ### `db.syncWal(): Promise<void>`
877
+
878
+ Syncs the write-ahead log to stable storage (`DB::SyncWAL`), so every WAL-backed write committed
879
+ before the call survives a power loss afterwards. Unlike [`flush()`](#dbflushoptions-promisevoid) it
880
+ writes no SST file and never stalls writers. Writes that skipped the WAL (`disableWAL`) and
881
+ transaction-log files, which keep their own durability, are not covered. RocksDB serializes
882
+ concurrent syncs of one WAL, so keep one in flight per database rather than queueing them on the
883
+ libuv threadpool.
884
+
885
+ ```typescript
886
+ await db.syncWal();
887
+ ```
888
+
889
+ On a [read-only](#new-rocksdatabasepath-options) database the returned promise resolves without
890
+ syncing.
891
+
809
892
  ### `db.get(key: Key, options?: GetOptions): MaybePromise<any>`
810
893
 
811
894
  Retreives the value for a given key. If the key does not exist, it will resolve `undefined`.
@@ -964,7 +1047,9 @@ again, so a backward step between runs can reissue a timestamp that is already a
964
1047
  node's transaction log. Opening with
965
1048
  [`timestampFloorLog`](#new-rocksdatabasepath-options) raises the clock
966
1049
  above every batch key still durable in the named log before the database handle is returned, so no
967
- transaction can be constructed below it.
1050
+ transaction can be constructed below it. Keys in segments retention has purged are not durable, and
1051
+ neither is a bound advertised to peers; carry those across a restart yourself with
1052
+ [`raiseTimestampFloor()`](#raisetimestampfloortimestamp-number-boolean).
968
1053
 
969
1054
  Name only a log this process **originates**. A log written under a timestamp adopted from another
970
1055
  node — a replication receiver calling [`txn.setTimestamp()`](#txnsettimestampts-number-void) — is
@@ -1250,7 +1335,9 @@ The `attempt` parameter is the number of times the transaction has been retried.
1250
1335
  - `disableSnapshot?: boolean` Whether to disable snapshots. Defaults to `false`.
1251
1336
  - `maxRetries?: number` The maximum number of times to retry the transaction. Defaults to `3`.
1252
1337
  - `retryOnBusy?: boolean` Whether to retry the transaction if the commit fails with `IsBusy`.
1253
- Defaults to `true` when the transaction is bound to a transaction log, otherwise `false`.
1338
+ Defaults to `true` when the transaction wrote a transaction-log batch or was refused before
1339
+ writing one (`refusedBeforeLog`), otherwise `false`. With `false`, `db.transaction()` also
1340
+ commits without `coordinatedRetry`, so a conflict fails at once instead of waiting first.
1254
1341
 
1255
1342
  ### Transaction Retry Logic
1256
1343
 
@@ -1263,7 +1350,45 @@ a bad state and the transaction will need to be retried. If the max retries is r
1263
1350
  transaction is not retried, a `ERR_TRANSACTION_ABANDONED` error will be thrown.
1264
1351
 
1265
1352
  Users should use the `attempt` transaction callback parameter to ensure duplicate transaction log
1266
- entries are not added.
1353
+ entries are not added. `txn.logWritten()` reports whether the batch is already in the log: a commit
1354
+ that conflicts after writing it keeps it for the retry, while an attempt refused before writing it
1355
+ (`err.refusedBeforeLog`, with `transactionLogAbortMarkers`) leaves nothing and must stage it again.
1356
+
1357
+ ### Abort markers
1358
+
1359
+ With `RocksDatabase.config({ transactionLogAbortMarkers: true })`, a transaction's log batch is
1360
+ never left live when the transaction does not commit:
1361
+
1362
+ - A transaction that wrote its batch and then ends without committing has the batch marked aborted:
1363
+ bit `0x02` (`TRANSACTION_LOG_ENTRY_ABORTED_FLAG`) is set on its first entry, and the change is
1364
+ synced to disk before `abort()` returns or the batch stops holding back committed readers. Every
1365
+ reader skips the batch. `abort()` then no longer throws `ERR_TRANSACTION_ABANDONED`.
1366
+ - If the marker cannot be written or synced, `abort()` throws `ERR_TRANSACTION_ABORT_UNRESOLVED`
1367
+ (again on a second call), committed readers stay behind the batch, and the log refuses new batches
1368
+ until a retry on the next batch, database flush or close marks it.
1369
+ - Under `occValidation: 'parallel'` (the default), a retried optimistic commit that RocksDB's own
1370
+ conflict check shows would fail is refused before it writes its batch too, with
1371
+ `refusedBeforeLog` set (needs the RocksDB build this package pins). A first attempt skips the
1372
+ check, so commits that do not conflict pay nothing for it, and so does a retry whose batch an
1373
+ earlier attempt already wrote.
1374
+ - With `transactionLogWriteIntents: true` as well, a commit that finds an older transaction holding
1375
+ a write intent on one of its keys (with `verificationTable: true`) is refused before writing its
1376
+ batch, with `refusedBeforeLog` set on the
1377
+ error, or, if its batch is already written from an earlier attempt, before committing. With
1378
+ `coordinatedRetry` it waits for that transaction first. `commitSync()` never waits: it throws, and
1379
+ `transactionSync()` retries after a short backoff.
1380
+ - `txn.setParkDeadline(epochMs)` bounds those waits by the caller's own deadline.
1381
+ - `log.getStats().totals` reports `abortsMarked`, `abortMarkFailures`, and `watermarkPinnedUs` /
1382
+ `pinnedTransactions`: how long logged batches held committed readers back after their first
1383
+ conflict, until they committed or ended.
1384
+ - A backup waits for every batch it captures that has neither committed nor been aborted, and is
1385
+ refused if one is still unresolved after `ROCKSDB_JS_BACKUP_UNRESOLVED_WAIT_MS` (default
1386
+ `30000`).
1387
+
1388
+ A reader older than the release that added aborted-batch support shows an aborted batch as live, so
1389
+ turn this on only once every reader of the logs (replicas, restores, repair tools) has it. A batch
1390
+ whose transaction was still unresolved when the process crashed is not marked, and is replayed as
1391
+ before.
1267
1392
 
1268
1393
  When `coordinatedRetry: true`, the retry behavior changes for `IsBusy` conflicts: instead of
1269
1394
  rejecting (or retrying immediately and potentially conflicting again), the commit waits until the
@@ -1297,6 +1422,12 @@ operations methods as the `RocksDatabase` instance plus:
1297
1422
  - `txn.commitSync()` Synchronously commits and closes the transaction.
1298
1423
  - `txn.getTimestamp(): number` Retrieves the transaction timestamp in milliseconds as a decimal. It
1299
1424
  defaults to a process-wide monotonic value assigned when the transaction was created.
1425
+ - `txn.lastConflictKind(): number` Why the most recent attempt was refused or retried, one of
1426
+ `ConflictKind`: `none` (0), `intentBeforeLog` (1), `intentAfterLog` (2), `atCommit` (3),
1427
+ `checkBeforeLog` (4).
1428
+ - `txn.logWritten(): boolean` Whether the transaction's batch is in the transaction log.
1429
+ - `txn.setParkDeadline(epochMs: number): void` Past this time a coordinated-retry wait resolves at
1430
+ once. `0` clears it.
1300
1431
  - `txn.id: number` The read-only transaction ID. Transaction IDs are unique to the RocksDB database
1301
1432
  path, regardless the database name/column family.
1302
1433
  - `txn.setTimestamp(ts?: number): void` Overrides the transaction timestamp in milliseconds. If
@@ -1349,6 +1480,11 @@ Type: `number`
1349
1480
  The transaction ID, a positive integer no greater than `Number.MAX_SAFE_INTEGER`. Transaction IDs
1350
1481
  are unique to the RocksDB database path, regardless the database name/column family.
1351
1482
 
1483
+ A transaction belongs to the thread that created it. Although every `worker_threads` worker that
1484
+ opens the same path shares one id space, using a transaction by id from another worker (for example
1485
+ `{ transaction }` read options or `log.addEntry(data, txnId)`) throws `ERR_TRANSACTION_WRONG_ENV`:
1486
+ RocksDB transactions are not thread-safe.
1487
+
1352
1488
  #### `txn.setTimestamp(ts?: number): void`
1353
1489
 
1354
1490
  Overrides the transaction timestamp in milliseconds since the Unix epoch. Replication receivers and
@@ -1937,6 +2073,10 @@ Attempts to acquire a lock for a given key. If the lock is available, the functi
1937
2073
  and the optional `onUnlocked` callback is never called. If the lock is not available, the function
1938
2074
  returns `false` and the `onUnlocked` callback is queued until the lock is released.
1939
2075
 
2076
+ A queued `onUnlocked` callback keeps the thread's event loop alive until it is called, even when the
2077
+ lock is held by another thread. A thread with a callback queued on a lock that is never released
2078
+ will not exit on its own; closing the database that queued the callback does not cancel it.
2079
+
1940
2080
  When a database is closed, all locks associated to it will be unlocked.
1941
2081
 
1942
2082
  ```typescript
@@ -1977,7 +2117,9 @@ db.unlock('foo'); // false, already unlocked
1977
2117
 
1978
2118
  ### `db.withLock(key: Key, callback: () => void | Promise<void>): Promise<void>`
1979
2119
 
1980
- Runs a function with guaranteed exclusive access across all threads.
2120
+ Runs a function with guaranteed exclusive access across all threads. The returned promise rejects
2121
+ with the error if the callback throws or its promise rejects; the lock is released either way. A
2122
+ pending `withLock()` keeps the thread's event loop alive until its callback has run.
1981
2123
 
1982
2124
  ```typescript
1983
2125
  await db.withLock('key', async () => {
@@ -2005,9 +2147,7 @@ await Promise.all([
2005
2147
  ]);
2006
2148
  ```
2007
2149
 
2008
- Note: If the `callback` throws an error, Node.js suppress the error. Node.js 18.3.0 introduced a
2009
- `--force-node-api-uncaught-exceptions-policy` flag which will cause errors to emit the
2010
- `'uncaughtException'` event. Future Node.js releases will enable this flag by default.
2150
+ Note: Errors thrown by a `tryLock()` `onUnlocked` callback are discarded.
2011
2151
 
2012
2152
  ## File Locking
2013
2153
 
@@ -2084,10 +2224,12 @@ The freshness check works as follows:
2084
2224
  2. On a cold read, pass `{ populateVersion: true }` (or call `db.populateVersion()` afterward) to
2085
2225
  seed the slot with the version extracted from the value, so subsequent freshness checks hit.
2086
2226
 
2087
- Transaction writes to a column family opened with `verificationTable: true` invalidate the slot for
2088
- each written key at write time, so a stale version can never survive a write. This is also what
2089
- enables [`coordinatedRetry`](#transactionoptions): a conflicting transaction parks on the slot and
2090
- retries once the write intent is released.
2227
+ Writes through a handle opened with `verificationTable: true` invalidate the slot for each written
2228
+ key at write time, so a stale version cannot survive them, and a read seeds a slot only through
2229
+ such a handle. Once one handle opens a column family with the option, the handles on it that left
2230
+ the option unset participate too (see below). Invalidation is also what enables
2231
+ [`coordinatedRetry`](#transactionoptions): a conflicting transaction parks on the slot and retries
2232
+ once the write intent is released.
2091
2233
 
2092
2234
  The table is **process-global** and backed by a single shared structure, so versions populated on
2093
2235
  the main thread are visible to `worker_threads` workers and vice versa.
@@ -2096,7 +2238,8 @@ the main thread are visible to `worker_threads` workers and vice versa.
2096
2238
 
2097
2239
  The table must be sized before the first database is opened, via the
2098
2240
  [`verificationTableEntries`](#dbconfigoptions) config option (default `131072` slots = 1 MB; set to
2099
- `0` to disable). Then opt-in per column family with the `verificationTable: true` open option:
2241
+ `0` to disable). Then open each cached column family with the `verificationTable: true` open
2242
+ option:
2100
2243
 
2101
2244
  ```typescript
2102
2245
  import { RocksDatabase } from '@harperfast/rocksdb-js';
@@ -2109,6 +2252,18 @@ const db = RocksDatabase.open('path/to/db', { verificationTable: true });
2109
2252
  Enable `verificationTable` only for column families whose records are cached (e.g. the primary
2110
2253
  column family of a table); enabling it adds per-write slot invalidation overhead.
2111
2254
 
2255
+ Participation applies to the whole column family within the process. Once a handle opens a column
2256
+ family with `verificationTable: true`, every later handle on it participates too, even one opened
2257
+ without the option, so a write through any handle invalidates its key's slot. This lasts until
2258
+ every handle on the database closes, or until the column family is opened with an explicit
2259
+ `verificationTable: false` after every participating handle on it has closed. While any handle on
2260
+ it participates, that explicit `false` throws `ERR_VERIFICATION_TABLE_CONFLICT`. Opening with
2261
+ `verificationTable: true` throws `ERR_VERIFICATION_TABLE_CONFLICT` while a non-participating handle
2262
+ is open on that column family: one opened before any opt-in, or with an explicit `false`. After
2263
+ such a handle closes, the same error is thrown while a transaction that existed before it closed
2264
+ is still pending, or while another operation on the database is running; retry the open once they
2265
+ finish.
2266
+
2112
2267
  ### `db.verifyVersion(key: Key, version: number): boolean`
2113
2268
 
2114
2269
  Returns `true` when the verification table currently records `version` for `key` (in this database
@@ -2209,7 +2364,10 @@ every file after it is retained regardless of age; `purge.retainedUnflushedFiles
2209
2364
  While a backup with `transactionLogs` is copying a store's files, purges of that store are skipped
2210
2365
  (`purge.retentionPins` reports it). On a filesystem that rejects a directory fsync, the store's last
2211
2366
  file is kept, since `txn.state` could not be made durable before deleting it.
2212
- Use `destroy: true` only to remove the store itself.
2367
+ Before deleting any file, the purge records the largest key it held in the log's purge watermark
2368
+ ([`log.getPurgeWatermark()`](#loggetpurgewatermark-transactionlogpurgewatermark)) and makes that
2369
+ durable. Use `destroy: true` only to remove the store itself; a log recreated after it starts a new
2370
+ watermark history.
2213
2371
 
2214
2372
  - `options: object`
2215
2373
  - `before?: number` Remove transaction log files older than the specified timestamp, subject to
@@ -2269,6 +2427,8 @@ to disk right before the transaction is committed. You may add multiple enties p
2269
2427
  underlying architecture is thread safe.
2270
2428
 
2271
2429
  - `log.addEntry()`
2430
+ - `log.equals()`
2431
+ - `log.getPurgeWatermark()`
2272
2432
  - `log.path`
2273
2433
  - `log.query()`
2274
2434
 
@@ -2301,6 +2461,57 @@ Note that the `TransactionLog` class also has internal methods `_getMemoryMapOfF
2301
2461
  `_findPosition`, and `_getLastCommittedPosition` that should not be used directly and may change in
2302
2462
  any version.
2303
2463
 
2464
+ #### `log.equals(other): boolean`
2465
+
2466
+ Returns `true` when `other` is a `TransactionLog` for the same log: the same database and the same
2467
+ log name. The database is compared by its resolved filesystem identity, so `db.useLog('foo')`,
2468
+ `txn.useLog('foo')` and a log from another handle opened with a different spelling of the same path
2469
+ all compare equal, and two databases opened through the same relative path from different working
2470
+ directories do not. The comparison still works after either database closes. Any other value
2471
+ compares unequal.
2472
+
2473
+ ```typescript
2474
+ const log = db.useLog('foo');
2475
+ log.equals(otherHandle.useLog('foo')); // true: same database and name
2476
+ log.equals(db.useLog('bar')); // false
2477
+ ```
2478
+
2479
+ A log destroyed with `purgeLogs({ destroy: true })` and recreated under the same name still compares
2480
+ equal to wrappers of the old one.
2481
+
2482
+ #### `log.getPurgeWatermark(): TransactionLogPurgeWatermark`
2483
+
2484
+ ```typescript
2485
+ const log = db.useLog('foo');
2486
+ const entries = Array.from(log.query({ start: resumeKey, exclusiveStart: true }));
2487
+ const watermark = log.getPurgeWatermark();
2488
+ const complete = watermark.known && resumeKey >= watermark.purgedThrough;
2489
+ ```
2490
+
2491
+ Returns an upper bound on every entry key that retention or `db.purgeLogs()` has deleted from this
2492
+ log, or reports that no such bound can be given. A reader that needs every entry with a key above
2493
+ some position can be served from the log only when that position is at or above `purgedThrough`.
2494
+
2495
+ - `{ known: true, purgedThrough: number }` `purgedThrough` is at or above every key of every file
2496
+ deleted from the log, in whatever order the keys were written (0 when nothing was deleted). It is
2497
+ made durable before each file is deleted, including by the retention that runs when a database
2498
+ opens.
2499
+ - `{ known: false }` The log cannot vouch for what was deleted. This holds permanently for a log
2500
+ created by a release without the watermark, one that file deletions bypassed (such as an older
2501
+ release's retention), and one whose record cannot be read. It is also returned when the log's
2502
+ store no longer exists, such as after `purgeLogs({ destroy: true })` until the next write.
2503
+
2504
+ The call is synchronous. A read-only or secondary database reads the record from disk on each call.
2505
+ The result is a snapshot: a purge can run right after it. To trust what a read returned, read the log
2506
+ first and check the watermark afterwards. See
2507
+ [the purge watermark](docs/transaction-log.md#purge-watermark) for the rules.
2508
+
2509
+ ##### `TransactionLogPurgeWatermark`
2510
+
2511
+ ```typescript
2512
+ type TransactionLogPurgeWatermark = { known: true; purgedThrough: number } | { known: false };
2513
+ ```
2514
+
2304
2515
  #### `log.path: string`
2305
2516
 
2306
2517
  Returns the path to the transaction log store files.
@@ -2310,7 +2521,7 @@ const log = db.useLog('foo');
2310
2521
  console.log(log.path);
2311
2522
  ```
2312
2523
 
2313
- #### `log.query(options?): IterableIterator<TransactionLogEntry>`
2524
+ #### `log.query(options?): TransactionLogIterator`
2314
2525
 
2315
2526
  Returns an iterable/iterator that streams all log entries for the given filter.
2316
2527
 
@@ -2319,6 +2530,11 @@ Returns an iterable/iterator that streams all log entries for the given filter.
2319
2530
  - `end?: string` The transction end timestamp.
2320
2531
  - `exclusiveStart?: boolean` When `true`, this will only match transactions with timestamps after
2321
2532
  the start timestamp.
2533
+ - `until?: TransactionLogIterator` Stops before the unread position of another iterator from a log
2534
+ that `equals()` this one, including an entry it has peeked. The bound is exclusive, compares
2535
+ segment and offset before filtering, and works across rotation, even when this query starts past
2536
+ it. It resumes when `until` advances. A closed iterator no longer bounds the query. An invalid or
2537
+ foreign-log iterator throws `TypeError` at query creation.
2322
2538
  - `exactStart?: boolean` When `true`, this will only match and iterate starting from a transaction
2323
2539
  with the given start timestamp. Once the specified transaction is found, all subsequent
2324
2540
  transactions will be returned (regardless of whether their timestamp comes before the `start`
@@ -2335,6 +2551,32 @@ Returns an iterable/iterator that streams all log entries for the given filter.
2335
2551
  have been written to the log but are no longer considered committed if they were not flushed to
2336
2552
  disk.
2337
2553
 
2554
+ An unbounded query whose starting segment is temporarily unmappable reports done and retries
2555
+ its original position on the next pull.
2556
+
2557
+ `iterator.peek()` returns the next entry without consuming it. Repeated peeks return the same
2558
+ result until `next()` consumes it once. A `done` result is not cached, so later appends remain
2559
+ visible. Errors propagate as from `next()`.
2560
+
2561
+ With `until`, `done: true` means the reader has caught up to the bound as of that pull. Running
2562
+ out of readable entries short of the bound throws instead, so an unavailable segment cannot
2563
+ silently trigger a handoff. Exact-start queries take the index's stop-to-tail jump only when
2564
+ its target does not pass the handoff position.
2565
+
2566
+ `iterator.close()` permanently ends the iterator and releases its cached entry and mapping.
2567
+ It is idempotent; later `next()` and `peek()` calls report done. Breaking out of `for...of`
2568
+ keeps the iterator resumable and does not close it. Destroying a log with
2569
+ `purgeLogs({ destroy: true })` invalidates its existing iterators; close them and create new
2570
+ iterators for a recreated log, including any `until` bounds.
2571
+
2572
+ ```typescript
2573
+ const live = log.query();
2574
+ const replay = log.query({ start: startTime, until: live });
2575
+ for (const entry of replay) deliver(entry);
2576
+ replay.close();
2577
+ // A later commit belongs to live even if its timestamp is earlier than startTime.
2578
+ ```
2579
+
2338
2580
  The iterator produces an object with the log entry timestamp and data.
2339
2581
 
2340
2582
  - `object`
@@ -2342,6 +2584,25 @@ The iterator produces an object with the log entry timestamp and data.
2342
2584
  - `timestamp: number` The entry timestamp used to collate entries by transaction.
2343
2585
  - `endTxn: boolean` This is `true` when the entry is the last entry in a transaction.
2344
2586
 
2587
+ These errors leave the iterator usable:
2588
+
2589
+ - `TransactionLogUntilUnreachedError`: readable input ended before `until`. The cursor stays in
2590
+ place; retry after the input becomes readable or the pending transaction commits. Properties:
2591
+ `logId` and `position`, identifying where the read stopped.
2592
+ - `TransactionLogGapError`: the iterator reached segments it had not read yet and could not read
2593
+ them, usually because retention purged them while the reader lagged. Their entries are not in
2594
+ this log anymore, so a consumer that replicates the log should treat its copy as discontinuous
2595
+ (for example, by resyncing from a full copy) rather than continuing. Properties: `logId` (the
2596
+ last segment read), `resumeLogId` (the segment the next `next()` call reads), and
2597
+ `unavailableLogCount`. A new query positioned within the retained segments does not report the
2598
+ segments purged before its starting point.
2599
+ - `CorruptFrameError` (a `RangeError`): an entry's framing is broken. `resyncPosition` is where
2600
+ valid framing resumes, or `undefined` when nothing follows the break.
2601
+
2602
+ All three classes are exported from the package. After a gap or corrupt frame, calling `next()`
2603
+ again continues past the problem. Other errors, such as a failure to open a segment, do not move
2604
+ the iterator, so retrying them without a fix fails the same way.
2605
+
2345
2606
  ```typescript
2346
2607
  const log = db.useLog('foo');
2347
2608
  const iter = log.query({});
@@ -2495,7 +2756,7 @@ Contract:
2495
2756
  `QueryPerformanceCounter` on Windows).
2496
2757
  - **Origin and lifetime**: the origin is unspecified and fixed for the life of the process. Every
2497
2758
  sample taken in the process is in one domain — the main thread and every `worker_threads` worker,
2498
- including workers started or restarted at any later time, under Node, Bun and Deno alike. Samples
2759
+ including workers started or restarted at any later time, under Node and Bun alike. Samples
2499
2760
  are not meaningful across processes or restarts, and are unrelated to the Unix epoch: never compare
2500
2761
  them with `Date.now()`, `db.getMonotonicTimestamp()` or transaction timestamps.
2501
2762
  - **Monotonic, not unique**: a sample taken after another (in real time, on any thread) is `>=` it.
@@ -2521,6 +2782,55 @@ Contract:
2521
2782
  - **Cost**: one clock read and a double conversion; no lock, no native allocation, no wall-clock read,
2522
2783
  no database or log I/O, no per-worker calibration. Safe from any thread. The runtime may allocate a boxed JS number.
2523
2784
 
2785
+ ### `raiseTimestampFloor(timestamp: number): boolean`
2786
+
2787
+ Raises the process-wide monotonic timestamp clock — the clock behind
2788
+ [`db.getMonotonicTimestamp()`](#dbgetmonotonictimestamp-number), every transaction's initial
2789
+ timestamp and therefore every transaction-log batch key — so that every timestamp the process
2790
+ issues afterwards is strictly greater than `timestamp` (Unix epoch milliseconds). No database
2791
+ handle is needed.
2792
+
2793
+ ```typescript
2794
+ import { RocksDatabase, raiseTimestampFloor } from '@harperfast/rocksdb-js';
2795
+
2796
+ // Before anything in this process starts a transaction:
2797
+ raiseTimestampFloor(persistedFloor);
2798
+ const db = RocksDatabase.open('/path/to/db', { timestampFloorLog: 'local' });
2799
+ ```
2800
+
2801
+ The clock is monotonic within a process, not across restarts, and
2802
+ [`timestampFloorLog`](#timestamp-floor-at-open) only restores keys still on disk. A bound this node
2803
+ has promised that is not in its log — a progress floor advertised to peers, or the largest key of
2804
+ segments retention has since purged — can only come back through this call. The caller names the
2805
+ value because only the caller knows which bounds it has promised.
2806
+
2807
+ Contract:
2808
+
2809
+ - **Returns** `true` if the clock was raised: `timestamp` was ahead of both the wall clock and every
2810
+ timestamp already issued, as after a backward clock step. `false` if the clock was already at or
2811
+ above it, which is the usual result at a restart. Raising is one-way; a lower value never moves
2812
+ the clock back.
2813
+ - **Throws** a `TypeError` if `timestamp` is not a number, and a `RangeError` if it is not finite, is
2814
+ not greater than `0`, is not less than `8.64e15`, or is more than ten years ahead of the wall
2815
+ clock — the same bound `timestampFloorLog` applies to a durable key. An unusable persisted floor
2816
+ therefore fails loudly rather than being ignored. The implausibility error includes the wall
2817
+ clock it was checked against, since a host that booted with an unsynchronized clock trips it too.
2818
+ - **Process-wide**: it applies to every database and every `worker_threads` worker in the process,
2819
+ but not to other processes. Each new process must call it again.
2820
+ - **Only issued timestamps**: a transaction constructed before the call keeps its timestamp, and
2821
+ [`txn.setTimestamp(ts)`](#txnsettimestampts-number-void) can still adopt any valid timestamp,
2822
+ including one below the floor. `txn.setTimestamp()` with no argument issues a fresh timestamp
2823
+ above it.
2824
+ - **Not durable and not a fence**: the floor lives in memory only, and nothing stops a transaction
2825
+ constructed concurrently with the call. Persist the floor yourself, and restore it before the
2826
+ process starts any transaction it originates. It composes with `timestampFloorLog` (the clock takes
2827
+ the larger of the two), so keep naming the local log unless the persisted floor already covers
2828
+ every key written to it.
2829
+ - **Trusted input only**: the floor is not lowered until the process exits, so a value hours or years
2830
+ ahead holds every timestamp that far ahead of wall time until the clock catches up — and keys
2831
+ written in the meantime raise the next restart's seed with them. Pass only floors this node
2832
+ persisted itself, never an unvalidated value received from another node.
2833
+
2524
2834
  ### `currentThreadId(): number`
2525
2835
 
2526
2836
  Returns the current thread ID.
@@ -2554,27 +2864,41 @@ console.log(getRegistryStatus());
2554
2864
 
2555
2865
  ### `shutdown(): void`
2556
2866
 
2557
- The `shutdown()` will flush all in-memory data to disk and wait for any outstanding compactions to
2558
- finish, for all open databases. It is highly recommended to call this in a `process` `exit` event
2559
- listener (on the main thread), to ensure that all data is flushed to disk before the process exits.
2560
- It throws the first close failure after attempting every claimed database; call it again to retry
2561
- any descriptor whose native teardown did not complete. It reports pending destroy-cleanup
2562
- tombstones without deleting their paths; retry those with an explicit `destroy()`.
2867
+ The `shutdown()` will flush all in-memory data to disk and cancel outstanding background
2868
+ compactions, for all open databases. It throws the first close failure after attempting every
2869
+ claimed database; call it again to retry any descriptor whose native teardown did not complete. It
2870
+ reports pending destroy-cleanup tombstones without deleting their paths; retry those with an
2871
+ explicit `destroy()`.
2872
+
2873
+ You do not need to call it at exit. When rocksdb-js loads on the main thread, it registers a
2874
+ `process.on('exit')` listener that runs the same shutdown, so a `process.exit()` (which skips the
2875
+ native cleanup Node runs on a natural exit) still flushes and closes every database, including
2876
+ those opened by workers that are still running. That listener reports failures to stderr instead
2877
+ of throwing, and sets `process.exitCode` to 1 (unless it is already nonzero) when data may be left
2878
+ unflushed: a database another thread was still closing when the shutdown timed out, or a final
2879
+ flush that failed. Afterwards opening a database throws. It
2880
+ still runs when an earlier `exit` listener throws. It does not run when the process is killed by a
2881
+ signal it does not handle.
2882
+
2883
+ Like the cleanup on a natural exit, it waits for native work already in flight (a commit, a flush
2884
+ waiting out a write stall, or a backup) without a time limit, so `process.exit()` returns only once
2885
+ that work finishes.
2563
2886
 
2564
- Wrap the call: an exception thrown from an `exit` listener skips every `exit` listener registered
2565
- after it, and sets the exit code to 1 unless an `uncaughtException` handler is installed.
2887
+ Because it is registered when rocksdb-js loads, `exit` listeners added later run after databases
2888
+ are closed. An `exit` listener that still writes to a database must run first:
2566
2889
 
2567
2890
  ```typescript
2568
- import { shutdown } from '@harperfast/rocksdb-js';
2569
- process.on('exit', () => {
2570
- try {
2571
- shutdown();
2572
- } catch (error) {
2573
- console.error('rocksdb-js shutdown failed', error);
2574
- }
2891
+ process.prependListener('exit', () => {
2892
+ db.putSync('lastSeen', Date.now());
2575
2893
  });
2576
2894
  ```
2577
2895
 
2896
+ Set `RocksDatabase.config({ shutdownOnExit: false })` to skip it, for example to leave memtables
2897
+ unflushed at `process.exit()` and exercise crash recovery in a test. The listener stays registered
2898
+ and does nothing: on a natural exit the runtime's native cleanup still flushes, and whether Bun's
2899
+ does at `process.exit()` depends on the Bun release.
2900
+ Calling `shutdown()` from your own `exit` listener as well is safe.
2901
+
2578
2902
  ### `versions: { 'rocksdb': string; 'rocksdb-js': string }`
2579
2903
 
2580
2904
  Returns the `rocksdb-js` and RocksDB version.
@@ -2899,11 +3223,13 @@ Options for `get()`, `getSync()`, and the `getBinary*` methods.
2899
3223
  - `expectedVersion: number` When set, the [Verification Table](#verification-table) is checked
2900
3224
  before reading. If the slot holds this version, the read is skipped and the
2901
3225
  `FRESH_VERSION_FLAG` sentinel is returned. After a database read, the slot is seeded with the
2902
- version extracted from the value. Requires the column family to be opened with
3226
+ version extracted from the value. Requires the handle to be opened with
2903
3227
  `verificationTable: true`.
2904
3228
  - `populateVersion: boolean` When `true`, after a database read the verification-table slot is
2905
3229
  seeded with the version extracted from the value, eliminating the need for a separate
2906
- `db.populateVersion()` call on cold reads. Defaults to `false`.
3230
+ `db.populateVersion()` call on cold reads. Requires the handle to participate in the
3231
+ [Verification Table](#verification-table); otherwise the read seeds nothing. Defaults to
3232
+ `false`.
2907
3233
  - `skipDecode: boolean` When `true`, the value is returned without being decoded. Defaults to
2908
3234
  `false`.
2909
3235