@harperfast/rocksdb-js 2.10.0 → 2.12.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 +252 -62
- package/bin/rocksdb-js.mjs +49 -5
- package/dist/index.cjs +152 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +134 -5
- package/dist/index.d.mts +134 -5
- package/dist/index.mjs +152 -28
- package/dist/index.mjs.map +1 -1
- package/docs/backups.md +27 -0
- package/docs/stats.md +55 -51
- package/docs/transaction-log.md +142 -4
- package/docs/transaction-timestamp-integrity-design.md +70 -8
- package/package.json +19 -18
package/README.md
CHANGED
|
@@ -169,10 +169,25 @@ Creates a new database instance.
|
|
|
169
169
|
- `transactionLogMaxSize: number` The target maximum size of a transaction log file. Transactions
|
|
170
170
|
are never split across files: if the complete transaction does not fit, the log rotates before
|
|
171
171
|
writing it. A transaction written to an empty file may exceed the target. Defaults to 16 MB.
|
|
172
|
+
`0` is rejected: it formerly meant unlimited segment size, which a reader's fixed-size mapping
|
|
173
|
+
cannot cover. A store configured with `0` must omit the option or set a size before opening;
|
|
174
|
+
once it rotates by size, rotated segments older than `transactionLogRetention` are purged, so
|
|
175
|
+
raise the retention to keep more history.
|
|
172
176
|
- `transactionLogRetention: string | number` The number of minutes to retain transaction logs
|
|
173
177
|
before purging. Defaults to `'3d'` (3 days).
|
|
174
178
|
- `transactionLogsPath: string` The path to store transaction logs. Defaults to
|
|
175
179
|
`"${db.path}/transaction_logs"`.
|
|
180
|
+
- `timestampFloorLog: string` The name of the transaction log whose batch keys this process
|
|
181
|
+
originates. At open, the process-wide monotonic clock is raised above every batch key still
|
|
182
|
+
durable in that log, so a backward wall-clock step between runs cannot reissue a transaction
|
|
183
|
+
timestamp that is already a key in it. See
|
|
184
|
+
[Timestamp floor at open](#timestamp-floor-at-open). Unset by default, which leaves the clock
|
|
185
|
+
alone. Its first use in a process must be on a writable primary open; read-only and secondary
|
|
186
|
+
handles can reuse an already-established seed for the same log but cannot establish one.
|
|
187
|
+
If that first seed warned because the log was absent, the log may be created fresh, but a
|
|
188
|
+
nonempty directory that reappears before first use is refused until every handle closes and the
|
|
189
|
+
path reopens with this option. The name must be a single path component that does not start with
|
|
190
|
+
`.`: startup discovery skips such directories, so the log could never be scanned again.
|
|
176
191
|
- `verificationTable: boolean` When `true`, this column family participates in the process-global
|
|
177
192
|
[Verification Table](#verification-table): transaction writes to this column family invalidate
|
|
178
193
|
the verification slot for each written key. Enable this only for column families whose records
|
|
@@ -218,12 +233,21 @@ when a non-default compression level is set. The database must be open. See
|
|
|
218
233
|
[Compression](#compression).
|
|
219
234
|
|
|
220
235
|
```typescript
|
|
221
|
-
const db = RocksDatabase.open('path/to/db', {
|
|
222
|
-
compression: { algorithm: 'zstd', level: 3 },
|
|
223
|
-
});
|
|
236
|
+
const db = RocksDatabase.open('path/to/db', { compression: { algorithm: 'zstd', level: 3 } });
|
|
224
237
|
console.log(db.compression); // { algorithm: 'zstd', level: 3 }
|
|
225
238
|
```
|
|
226
239
|
|
|
240
|
+
### `db.setCompression(compression)`
|
|
241
|
+
|
|
242
|
+
Dynamically changes the compression algorithm (and optional level) for this database's column
|
|
243
|
+
family on an already-open database — no close, no reopen. See [Compression](#compression) for full
|
|
244
|
+
semantics and caveats.
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
const db = RocksDatabase.open('path/to/db', { compression: 'none' });
|
|
248
|
+
db.setCompression({ algorithm: 'zstd', level: 19 });
|
|
249
|
+
```
|
|
250
|
+
|
|
227
251
|
### `db.config(options)`
|
|
228
252
|
|
|
229
253
|
Sets global database settings.
|
|
@@ -232,7 +256,59 @@ Sets global database settings.
|
|
|
232
256
|
- `blockCacheSize: number` The amount of memory in bytes to use to cache uncompressed blocks.
|
|
233
257
|
Defaults to 32MB. Set to `0` (zero) disables block cache for future opened databases. Existing
|
|
234
258
|
block cache for any opened databases is resized immediately. Negative values throw an error.
|
|
259
|
+
- `commitThreads: number` The maximum number of dedicated threads each database opened after the
|
|
260
|
+
call uses to run async `transaction.commit()` calls. Defaults to `min(4, cores)`; accepts
|
|
261
|
+
integers from `1` through `64`. A thread starts only when a commit finds every started thread
|
|
262
|
+
busy, so a database that is never committed to concurrently owns one. Async commits never use
|
|
263
|
+
the libuv threadpool, so slow commits cannot delay `fs`, `dns`, `crypto` or async `get()` calls.
|
|
264
|
+
With more than one thread, RocksDB validates and applies concurrent commits on several cores
|
|
265
|
+
and can combine them into one write: with four worker threads committing 64-key transactions to
|
|
266
|
+
one database, this committed about twice as many transactions per second as one thread.
|
|
267
|
+
Concurrent commits finish, and their promises resolve, in any order, as concurrent
|
|
268
|
+
`commitSync()` calls always have, and their transaction-log entries are appended in the order
|
|
269
|
+
the commits reach the log; a log reader never sees an entry before its transaction's data is
|
|
270
|
+
committed. Log position is not the order in which writes to the same key were applied: a
|
|
271
|
+
commit that conflicts and is retried keeps its original log position but lands after commits
|
|
272
|
+
logged behind it. Its transaction timestamp does not advance across a retry either, so
|
|
273
|
+
replaying entries for one key in timestamp order does not recover true apply order — a
|
|
274
|
+
consumer that needs one must have the producer embed its own last-writer-wins marker.
|
|
275
|
+
Concurrent commit threads make this more frequent; a single commit thread and `commitSync()`
|
|
276
|
+
callers could already produce it. With `1`, a database's async commits run one at a time in
|
|
277
|
+
the order `commit()` was called. Databases already open keep their limit.
|
|
235
278
|
- `compactOnClose: boolean` When `true`, compacts the database on close. Defaults to `false`.
|
|
279
|
+
- `occValidation: 'parallel' | 'serial'` How writable optimistic databases opened after the call
|
|
280
|
+
validate commits for conflicts. Defaults to `'parallel'` (RocksDB's default). Databases already
|
|
281
|
+
open keep their policy. Pessimistic, read-only and secondary opens are unaffected.
|
|
282
|
+
- `'parallel'` locks the commit's keys in a per-database pool of `occLockBuckets` mutexes,
|
|
283
|
+
checks for conflicts, and holds the locks through the write. Commits on different threads
|
|
284
|
+
that touch different buckets can then be batched into one RocksDB write. Non-transactional
|
|
285
|
+
writes (`db.putSync()` and friends) take no bucket, so one that lands between a commit's
|
|
286
|
+
conflict check and its write is overwritten by that commit without an `ERR_BUSY`.
|
|
287
|
+
- `'serial'` checks for conflicts inside RocksDB's write group instead, and allocates no bucket
|
|
288
|
+
pool, which saves its memory. Because the check and the write happen in one step, a
|
|
289
|
+
non-transactional write cannot land between them and be silently overwritten, and a column
|
|
290
|
+
family cannot be dropped between them. The cost: RocksDB never batches a serial-validated
|
|
291
|
+
commit with another writer, including the database's other commit threads, so it gives back
|
|
292
|
+
what `commitThreads` gains. With four worker threads
|
|
293
|
+
committing 64-key transactions to one database it committed about 4,500 transactions per
|
|
294
|
+
second against about 9,900 for `'parallel'`, the same as a single commit thread; it came out
|
|
295
|
+
ahead (about 7%) only at 1,000 keys per transaction with the default bucket count, where
|
|
296
|
+
raising `occLockBuckets` gains more. Concurrent `commitSync()` calls lose throughput the same
|
|
297
|
+
way. It suits a database committed to one transaction at a time (`commitThreads: 1`).
|
|
298
|
+
- `occLockBuckets: number` The number of commit lock buckets each writable optimistic database
|
|
299
|
+
allocates when it is opened with `occValidation: 'parallel'`. Defaults to `65536` (2^16), not
|
|
300
|
+
RocksDB's 2^20. Accepts integers from `16` through `16777216` (2^24). Applies to databases
|
|
301
|
+
opened after the call; databases already open keep their count. Memory is the count times the
|
|
302
|
+
platform mutex size (40 bytes on Linux x64, 64 bytes on macOS), so the default is about 2.5 MiB
|
|
303
|
+
per database on Linux, against about 40 MiB at RocksDB's default. Multiple keys can share a
|
|
304
|
+
bucket, so this is **not a transaction write limit**. A small pool costs throughput only when
|
|
305
|
+
commits to the same database overlap (its `commitThreads`, `commitSync()` callers, or libuv
|
|
306
|
+
commits under `ROCKSDB_JS_COMMIT_THREAD=0`) and their keys share a bucket: colliding commits
|
|
307
|
+
run one at a time and cannot share a RocksDB write. Collisions grow with transaction size. With
|
|
308
|
+
four worker threads and the default four commit threads, 65536 buckets matched 2^20 and 2^22 at
|
|
309
|
+
64 keys per transaction (and 4096 gave about a third fewer commits per second), but at 1,000 keys
|
|
310
|
+
65536 committed 336 transactions per second against 466 at 2^20 and 685 at 2^22, so databases
|
|
311
|
+
with large transactions should raise the count.
|
|
236
312
|
- `lifecycleWaitSeconds: number` How long a synchronous open, destroy, or shutdown waits for a
|
|
237
313
|
_conflicting_ lifecycle operation already in progress on the same path (e.g. another open or
|
|
238
314
|
close) before throwing a retryable timeout error. It does not bound the separate, intentionally
|
|
@@ -266,7 +342,10 @@ Sets global database settings.
|
|
|
266
342
|
```typescript
|
|
267
343
|
RocksDatabase.config({
|
|
268
344
|
blockCacheSize: 100 * 1024 * 1024, // 100MB
|
|
345
|
+
commitThreads: 4, // per database; applies to subsequent opens
|
|
269
346
|
compactOnClose: true,
|
|
347
|
+
occLockBuckets: 1 << 20, // per optimistic database with parallel validation
|
|
348
|
+
occValidation: 'parallel', // or 'serial'; applies to subsequent optimistic opens
|
|
270
349
|
writeBufferManagerAllowStall: false,
|
|
271
350
|
writeBufferManagerCostToCache: false,
|
|
272
351
|
writeBufferManagerSize: 64 * 1024 * 1024, // 64MB
|
|
@@ -688,9 +767,9 @@ Flushes all in-memory data to disk asynchronously.
|
|
|
688
767
|
memtable switch can itself prolong an existing L0 stop-trigger condition rather than clear it.
|
|
689
768
|
That cost is database-wide, covering every column family on the (process-global,
|
|
690
769
|
`worker_threads`-shared) database handle, not just the caller's — and it relocates the hang
|
|
691
|
-
rather than removing it: a stalled write blocks the database's
|
|
692
|
-
|
|
693
|
-
|
|
770
|
+
rather than removing it: a stalled write blocks each of the database's commit threads that
|
|
771
|
+
reaches it, so every async `Transaction.commit()` behind them queues up too, including ones
|
|
772
|
+
from callers that never touched flush. This is a different knob from
|
|
694
773
|
`writeBufferManagerAllowStall` (see [`new RocksDatabase()`](#new-rocksdatabasepath-options)
|
|
695
774
|
options), with nearly opposite polarity: that one governs whether the `WriteBufferManager` may
|
|
696
775
|
stall writers at all, this one governs whether one manual flush is willing to cause a stall
|
|
@@ -864,7 +943,8 @@ const range = db.getKeysCount({ start: 'a', end: 'z' }); // exact number of keys
|
|
|
864
943
|
### `db.getMonotonicTimestamp(): number`
|
|
865
944
|
|
|
866
945
|
Returns the current timestamp as a monotonically increasing timestamp in milliseconds represented as
|
|
867
|
-
a decimal number. This process-wide clock
|
|
946
|
+
a decimal number. This process-wide clock supplies each transaction's initial timestamp, which is
|
|
947
|
+
also the key of the transaction-log batch the transaction is written under.
|
|
868
948
|
|
|
869
949
|
```typescript
|
|
870
950
|
const ts = db.getMonotonicTimestamp();
|
|
@@ -877,6 +957,33 @@ keeps transaction timestamps ordered and durable, but it does not measure elapse
|
|
|
877
957
|
backward step, differences between two calls understate real time. Use
|
|
878
958
|
[`steadyClockNow()`](#steadyclocknow-number) for elapsed durations and deadlines.
|
|
879
959
|
|
|
960
|
+
#### Timestamp floor at open
|
|
961
|
+
|
|
962
|
+
The clock is monotonic within a process, not across restarts: a new process reads the wall clock
|
|
963
|
+
again, so a backward step between runs can reissue a timestamp that is already a batch key in this
|
|
964
|
+
node's transaction log. Opening with
|
|
965
|
+
[`timestampFloorLog`](#new-rocksdatabasepath-options) raises the clock
|
|
966
|
+
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.
|
|
968
|
+
|
|
969
|
+
Name only a log this process **originates**. A log written under a timestamp adopted from another
|
|
970
|
+
node — a replication receiver calling [`txn.setTimestamp()`](#txnsettimestampts-number-void) — is
|
|
971
|
+
keyed by that node's clock; seeding from it would ratchet this process's clock to the fastest of
|
|
972
|
+
those nodes at every restart, and there is no way for the database to tell the two kinds of log
|
|
973
|
+
apart on its own.
|
|
974
|
+
|
|
975
|
+
The named floor is fail closed: an unreadable segment, incomplete scan, malformed framing, or a key
|
|
976
|
+
more than ten years ahead of the wall clock refuses the opted-in open rather than reissuing a durable
|
|
977
|
+
batch key. The floor is process-wide, so it applies to every database open in the process. The scan
|
|
978
|
+
runs while the process serializes database opens and closes, so increasing its budget can delay
|
|
979
|
+
unrelated opens and closes too.
|
|
980
|
+
|
|
981
|
+
Naming an absent log warns so a fresh local log can be configured before its first write. The first
|
|
982
|
+
use may create that fresh log, but if a nonempty directory appears after the warning it is refused:
|
|
983
|
+
those keys were not part of the seed, and a transaction timestamp is already chosen before
|
|
984
|
+
`txn.useLog()` could scan it. Close every handle for the path and reopen with `timestampFloorLog` to
|
|
985
|
+
scan the restored log before using it.
|
|
986
|
+
|
|
880
987
|
### `db.getOldestSnapshotTimestamp(): number`
|
|
881
988
|
|
|
882
989
|
Returns a number representing a unix timestamp of the oldest unreleased snapshot.
|
|
@@ -1664,9 +1771,7 @@ Set the algorithm per column family with the `compression` option when opening a
|
|
|
1664
1771
|
const db = RocksDatabase.open('/path/to/db', { compression: 'zstd' });
|
|
1665
1772
|
|
|
1666
1773
|
// Or an object with an explicit level
|
|
1667
|
-
const db2 = RocksDatabase.open('/path/to/db2', {
|
|
1668
|
-
compression: { algorithm: 'zstd', level: 3 },
|
|
1669
|
-
});
|
|
1774
|
+
const db2 = RocksDatabase.open('/path/to/db2', { compression: { algorithm: 'zstd', level: 3 } });
|
|
1670
1775
|
|
|
1671
1776
|
// Disable compression entirely
|
|
1672
1777
|
const db3 = RocksDatabase.open('/path/to/db3', { compression: 'none' });
|
|
@@ -1683,16 +1788,6 @@ was compiled against, so it varies by build. Always check
|
|
|
1683
1788
|
[`supportedCompression`](#supportedcompression) at runtime — opening with an unavailable algorithm
|
|
1684
1789
|
throws. `'none'` is always available.
|
|
1685
1790
|
|
|
1686
|
-
**Changing the codec of data already written.** Setting `compression` affects files written from
|
|
1687
|
-
that point on. Existing SST and blob files keep the codec they were written with until something
|
|
1688
|
-
rewrites them. An ordinary `compact()` can re-encode non-bottommost levels, but RocksDB leaves the
|
|
1689
|
-
bottommost level alone unless a compaction filter is installed — and that is where most of the data
|
|
1690
|
-
sits, so a plain compaction will not rewrite all existing data. Use
|
|
1691
|
-
[`compact({ bottommost: true })`](#dbcompactoptions-promisevoid) to force the rewrite, once per
|
|
1692
|
-
column family you want migrated. Note also that omitting `compression` on a column family that
|
|
1693
|
-
already exists **inherits** its current codec rather than applying the default — the default
|
|
1694
|
-
applies only when the family is created.
|
|
1695
|
-
|
|
1696
1791
|
**Adopting a codec across a whole database.** Compression is chosen per column family, and RocksDB
|
|
1697
1792
|
opens every family of a database in one call — so by default the families you did not name keep
|
|
1698
1793
|
their persisted algorithm. If your first open targets some other family (a catalog, say), the rest
|
|
@@ -1722,10 +1817,90 @@ changeable, so an explicit change governs files written afterward while existing
|
|
|
1722
1817
|
their original compression until rewritten by compaction. It also applies to blob files (large
|
|
1723
1818
|
values), whose compression otherwise defaults to none.
|
|
1724
1819
|
|
|
1725
|
-
Because
|
|
1726
|
-
a second time in the same process (another
|
|
1727
|
-
`
|
|
1728
|
-
plain reopen (no `compression`) instead
|
|
1820
|
+
Because an open-time `compression` option applies only when a column family is first opened in a
|
|
1821
|
+
process, if the same column family is opened a second time in the same process (another
|
|
1822
|
+
`RocksDatabase` on the same path/`name`, including from a `worker_thread`) with an **explicitly
|
|
1823
|
+
different** algorithm or level, the second open **throws** — a plain reopen (no `compression`) instead
|
|
1824
|
+
inherits the live setting. This is a structural limit of the open-time API: `rocksdb::DB::Open()` opens
|
|
1825
|
+
every column family in the database transitively in one call, so by the time a caller can name a
|
|
1826
|
+
newly-discovered column family explicitly, RocksDB has already opened it. Use `db.setCompression()`
|
|
1827
|
+
below to change the algorithm of an already-open column family without a reopen.
|
|
1828
|
+
|
|
1829
|
+
### `db.setCompression(compression: string | { algorithm: string, level?: number }): void`
|
|
1830
|
+
|
|
1831
|
+
Dynamically changes the compression algorithm (and optional level) for an **already-open** column
|
|
1832
|
+
family, live, with no close/reopen and no conflict with other handles that already have this column
|
|
1833
|
+
family open elsewhere in the process. It accepts the same `compression` shape as the open-time option:
|
|
1834
|
+
|
|
1835
|
+
```typescript
|
|
1836
|
+
const db = RocksDatabase.open('/path/to/db', { compression: 'none' });
|
|
1837
|
+
|
|
1838
|
+
// ... database is upgraded/reconfigured to prefer zstd ...
|
|
1839
|
+
db.setCompression({ algorithm: 'zstd', level: 19 });
|
|
1840
|
+
// new writes are now compressed with zstd/level 19; db is still open and usable.
|
|
1841
|
+
```
|
|
1842
|
+
|
|
1843
|
+
This is backed by RocksDB's `DB::SetOptions()`, which the RocksDB headers document as dynamically
|
|
1844
|
+
mutable for exactly the fields this binding needs: `ColumnFamilyOptions::compression` and
|
|
1845
|
+
`AdvancedColumnFamilyOptions::blob_compression_type` are both annotated `// Dynamically changeable
|
|
1846
|
+
through SetOptions() API` (`rocksdb/options.h`, `rocksdb/advanced_options.h`). `setCompression` sets
|
|
1847
|
+
both together (mirroring the open-time option) so blob-stored large values don't stay uncompressed
|
|
1848
|
+
after a live algorithm change. It was chosen over a per-CF options map on the root/default open call
|
|
1849
|
+
(the alternative design considered) because that alternative doesn't fit the motivating use case at
|
|
1850
|
+
all: a caller that doesn't yet know a column family's desired codec until _after_ reading its own
|
|
1851
|
+
catalog — itself a column family opened by that very same call — has no way to supply per-CF options
|
|
1852
|
+
up front. `SetOptions()` sidesteps the problem structurally: it operates on a column family that is
|
|
1853
|
+
already open, after the caller has had a chance to learn what codec it wants.
|
|
1854
|
+
|
|
1855
|
+
**Verified semantics** (RocksDB's own `SetOptions()` doc explicitly limits the guarantee to what's
|
|
1856
|
+
listed as mutable — this was checked directly against the linked RocksDB build, not assumed):
|
|
1857
|
+
|
|
1858
|
+
- **Governs new writes only, going forward.** The very next flush (memtable → SST) and any future
|
|
1859
|
+
compaction output use the new algorithm; SST/blob files already on disk are untouched by the call
|
|
1860
|
+
itself and keep their old compression until they are naturally rewritten by a later compaction.
|
|
1861
|
+
This exactly matches the open-time `compression` option's existing "governs subsequently written
|
|
1862
|
+
files" semantics — `setCompression` is the live-mutation path to the same effective behavior,
|
|
1863
|
+
without the reopen restriction above. An ordinary `db.compact()`/`db.compactSync()` can leave
|
|
1864
|
+
already-bottommost files untouched because RocksDB skips that level by default when no compaction
|
|
1865
|
+
filter is configured. Use `db.compact({ bottommost: true })` or
|
|
1866
|
+
`db.compactSync({ bottommost: true })` to force those files to be rewritten under the new codec.
|
|
1867
|
+
- **The level is a partial update.** The new level is applied via RocksDB's nested-option syntax
|
|
1868
|
+
(`compression_opts={level=N;}`), which — per RocksDB's own documented example for this same API
|
|
1869
|
+
(`db->SetOptions(cfh, {{"block_based_table_factory", "{prepopulate_block_cache=kDisable;}"}})`)
|
|
1870
|
+
— only touches the field(s) named; every other `CompressionOptions` sub-field (window size,
|
|
1871
|
+
strategy, dictionary training, ...) is left at its current live value. Omitting `level` resets it to
|
|
1872
|
+
the algorithm's default (RocksDB's `kDefaultCompressionLevel` sentinel), matching the open-time
|
|
1873
|
+
option's "an explicit algorithm without a level does not inherit a previously-set level" behavior —
|
|
1874
|
+
it is never silently carried over from before the call.
|
|
1875
|
+
- **A persisted no-op request is free.** If the requested algorithm, blob algorithm, and effective
|
|
1876
|
+
level already match the live options, `setCompression` skips the RocksDB call entirely rather than
|
|
1877
|
+
paying for a no-op OPTIONS-file write. If a prior attempt applied the setting in memory but failed to
|
|
1878
|
+
persist it, the same request retries the persistence step instead. This matters because RocksDB's
|
|
1879
|
+
own `SetOptions()` doc calls it "a slow call because a new OPTIONS file is serialized and persisted
|
|
1880
|
+
for each call. Use only infrequently" — a caller that reconciles many column families against a
|
|
1881
|
+
catalog on every boot will find most of them already at the desired codec, and this keeps that sweep
|
|
1882
|
+
cheap.
|
|
1883
|
+
- **A persistence failure can split live and durable state.** RocksDB applies the new options in
|
|
1884
|
+
memory first, then persists an OPTIONS file reflecting them; the returned status covers the persist
|
|
1885
|
+
step alone. On a failure (e.g. the volume is full or read-only), the column family may already be
|
|
1886
|
+
running the new algorithm in memory while the on-disk OPTIONS file still holds the old one —
|
|
1887
|
+
`setCompression` detects this case and says so explicitly in the thrown error's message, since a
|
|
1888
|
+
later cold reopen (which trusts only the OPTIONS file) will silently revert to the old algorithm.
|
|
1889
|
+
- **Rejected on a read-only database.** `setCompression` persists a new OPTIONS file, which a
|
|
1890
|
+
read-only handle (`{ readOnly: true }`) may not do — it throws `ERR_DATABASE_READONLY`, matching
|
|
1891
|
+
every other mutating operation on this binding.
|
|
1892
|
+
- **Read your write.** `db.compression` reflects the change immediately (it reads live via
|
|
1893
|
+
`DB::GetOptions()`), and so does the already-open-column-family conflict check described above: once
|
|
1894
|
+
`setCompression` has changed the live algorithm, a second explicit `RocksDatabase.open()` on that
|
|
1895
|
+
column family is compared against the **new** value, not the one it was originally opened with.
|
|
1896
|
+
- **Overrides another handle's explicit codec without notice.** The open-time conflict check exists
|
|
1897
|
+
so two handles on the same column family can't disagree about compression — but `setCompression`
|
|
1898
|
+
bypasses it by design: it changes the live setting for every handle on that column family, including
|
|
1899
|
+
one that originally opened with an explicit, different algorithm. There is currently no event or
|
|
1900
|
+
other signal to such a handle when this happens.
|
|
1901
|
+
- **Does not change `RocksDatabase.open`'s existing open-time behavior.** `compression` at open time
|
|
1902
|
+
behaves exactly as before for a column family that is not already open in the process; `setCompression`
|
|
1903
|
+
is purely additive for the already-open case.
|
|
1729
1904
|
|
|
1730
1905
|
### `supportedCompression`
|
|
1731
1906
|
|
|
@@ -1887,8 +2062,12 @@ intended for read-heavy workloads where records carry a monotonically increasing
|
|
|
1887
2062
|
Each record's version is the numeric value stored in the first 8 bytes of its value (interpreted as
|
|
1888
2063
|
a big-endian float64). The table maps `(database, column family, key)` to a single 8-byte slot that
|
|
1889
2064
|
holds the last-known version for that key. Because slots are addressed by a hash, distinct keys may
|
|
1890
|
-
share a slot
|
|
1891
|
-
|
|
2065
|
+
share a slot. A slot stores the version combined with a tag derived from its key, so a version that
|
|
2066
|
+
another key cached in a shared slot — even the same version, as keys written in one transaction
|
|
2067
|
+
often carry — answers fresh only if the two keys' version-and-tag combinations coincide; otherwise
|
|
2068
|
+
the collision costs only a conservative miss (a real read). With the default 131,072 slots that
|
|
2069
|
+
coincidence has a probability of 2^-47 per lookup that finds another key's version in its slot, and
|
|
2070
|
+
each doubling of `verificationTableEntries` doubles it.
|
|
1892
2071
|
|
|
1893
2072
|
This first word is the only version the table derives on its own: it is what a read with
|
|
1894
2073
|
`populateVersion: true` publishes and what a transaction write invalidates against. A producer whose
|
|
@@ -1968,13 +2147,18 @@ Transaction log files are read through read-only memory maps. Understanding how
|
|
|
1968
2147
|
with system memory helps when interpreting [`log.getStats()`](#loggetstats-transactionlogstats)
|
|
1969
2148
|
figures and process memory usage:
|
|
1970
2149
|
|
|
1971
|
-
- **Maps are created lazily
|
|
1972
|
-
anything; a log file is mapped the first time a `log.query()`
|
|
1973
|
-
|
|
1974
|
-
the
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
2150
|
+
- **Maps are created lazily; only the current (still-being-written) file's map is held natively.**
|
|
2151
|
+
Writing log entries does not map anything; a log file is mapped the first time a `log.query()`
|
|
2152
|
+
reads from it. The native layer holds the current file's map for as long as it stays current —
|
|
2153
|
+
released when the log file rotates, is purged, or the database is closed. On POSIX, once a file
|
|
2154
|
+
rotates out or is purged, the native layer keeps only a weak reference: the mapping survives
|
|
2155
|
+
solely through JS `Buffer` views over it (including `entry.data`) and is released by garbage
|
|
2156
|
+
collection once none remain, independent of purge or close, and `stats.memory.activeMaps` counts
|
|
2157
|
+
only the current file's strongly-held map — a frozen file's map is weakly held and not counted,
|
|
2158
|
+
even while a JS `Buffer` view keeps it alive. **On Windows this weak-ownership optimization does
|
|
2159
|
+
not apply**: every frozen read re-creates the mapping and re-pins it strongly for the rest of the
|
|
2160
|
+
file's life, so `stats.memory.activeMaps`/`mappedBytes` count every frozen map a process has read,
|
|
2161
|
+
not just the current file's.
|
|
1978
2162
|
- **Mapped bytes are virtual, not resident.** Creating a map reserves address space only. A page
|
|
1979
2163
|
consumes physical RAM (RSS) when it is first read (demand paging). Querying a multi-gigabyte log
|
|
1980
2164
|
can show `memory.mappedBytes` in the gigabytes while actual memory usage barely moves.
|
|
@@ -1990,9 +2174,10 @@ figures and process memory usage:
|
|
|
1990
2174
|
OS-specific differences:
|
|
1991
2175
|
|
|
1992
2176
|
- **POSIX (Linux and macOS):** The active write file is mapped at the full configured
|
|
1993
|
-
`transactionLogMaxSize` (an anonymous reservation with the file's contents overlaid on top),
|
|
1994
|
-
|
|
1995
|
-
|
|
2177
|
+
`transactionLogMaxSize` (an anonymous reservation with the file's contents overlaid on top), or
|
|
2178
|
+
at its size when a single transaction pushed it past that target, so `memory.mappedBytes`
|
|
2179
|
+
over-reports the active file; `memory.overlayBytes` is the file-backed portion and is the closer
|
|
2180
|
+
proxy for real consumption.
|
|
1996
2181
|
- **macOS:** Activity Monitor's "Memory" column reports the physical footprint, which excludes
|
|
1997
2182
|
clean file-backed pages — mapped log data is essentially invisible there even when resident. Use
|
|
1998
2183
|
process RSS (e.g. `process.memoryUsage().rss`, `ps`, or `vmmap <pid>`) to observe it.
|
|
@@ -2014,20 +2199,21 @@ const names = db.listLogs();
|
|
|
2014
2199
|
|
|
2015
2200
|
### `db.purgeLogs({ includeEntryCounts: true, ...options }): { path: string; entries: number }[]`
|
|
2016
2201
|
|
|
2017
|
-
Deletes transaction log files older than the `transactionLogRetention` (defaults to 3 days).
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
`
|
|
2026
|
-
|
|
2202
|
+
Deletes transaction log files older than the `transactionLogRetention` (defaults to 3 days). A file
|
|
2203
|
+
is deleted only when it lies entirely at or before the flushed position recorded in `txn.state`, so
|
|
2204
|
+
every entry in it is already in RocksDB; that includes the file `txn.state` names and the file
|
|
2205
|
+
currently being written, so an idle, fully flushed store is left with no log files. Retention removes
|
|
2206
|
+
a contiguous prefix: the first file that is too new or holds an unflushed entry stops the run. When
|
|
2207
|
+
the flush position lags — `txn.state` stuck at an old sequence because RocksDB flushing is behind —
|
|
2208
|
+
every file after it is retained regardless of age; `purge.retainedUnflushedFiles` reports that case.
|
|
2209
|
+
While a backup with `transactionLogs` is copying a store's files, purges of that store are skipped
|
|
2210
|
+
(`purge.retentionPins` reports it). On a filesystem that rejects a directory fsync, the store's last
|
|
2211
|
+
file is kept, since `txn.state` could not be made durable before deleting it.
|
|
2212
|
+
Use `destroy: true` only to remove the store itself.
|
|
2027
2213
|
|
|
2028
2214
|
- `options: object`
|
|
2029
2215
|
- `before?: number` Remove transaction log files older than the specified timestamp, subject to
|
|
2030
|
-
the
|
|
2216
|
+
the flushed-position and contiguous-prefix rules above.
|
|
2031
2217
|
- `destroy?: boolean` When `true`, deletes transaction log stores including all log sequence files
|
|
2032
2218
|
on disk.
|
|
2033
2219
|
- `includeEntryCounts?: boolean` When `true`, counts the entries in each deleted log file and
|
|
@@ -2201,9 +2387,8 @@ stats.totals.transactionsWritten; // lifetime count of transactions written
|
|
|
2201
2387
|
|
|
2202
2388
|
The `purge.retainedUnflushedFiles` gauge is useful for diagnosing why logs are not being cleaned
|
|
2203
2389
|
up: a file can be older than the retention period but still retained because its transactions have
|
|
2204
|
-
not yet been flushed to RocksDB (purging it would be unsafe for crash recovery).
|
|
2205
|
-
|
|
2206
|
-
position — is not counted as purgeable even when it is old and fully flushed.
|
|
2390
|
+
not yet been flushed to RocksDB (purging it would be unsafe for crash recovery). An old, fully
|
|
2391
|
+
flushed current file counts as purgeable.
|
|
2207
2392
|
|
|
2208
2393
|
### Transaction Log Initialization
|
|
2209
2394
|
|
|
@@ -2345,7 +2530,7 @@ import { currentThreadId } from '@harperfast/rocksdb-js';
|
|
|
2345
2530
|
console.log(currentThreadId());
|
|
2346
2531
|
```
|
|
2347
2532
|
|
|
2348
|
-
### `
|
|
2533
|
+
### `getRegistryStatus(): RegistryStatus`
|
|
2349
2534
|
|
|
2350
2535
|
Returns an array containing that status of all active RocksDB instances.
|
|
2351
2536
|
|
|
@@ -2363,8 +2548,8 @@ Returns an array containing that status of all active RocksDB instances.
|
|
|
2363
2548
|
- `listenerCallbacks: number` The count of in-flight callbacks.
|
|
2364
2549
|
|
|
2365
2550
|
```typescript
|
|
2366
|
-
import {
|
|
2367
|
-
console.log(
|
|
2551
|
+
import { getRegistryStatus } from '@harperfast/rocksdb-js';
|
|
2552
|
+
console.log(getRegistryStatus());
|
|
2368
2553
|
```
|
|
2369
2554
|
|
|
2370
2555
|
### `shutdown(): void`
|
|
@@ -2479,9 +2664,7 @@ live database directory would write backup files on top of RocksDB's own files.
|
|
|
2479
2664
|
before anything is written.
|
|
2480
2665
|
|
|
2481
2666
|
```typescript
|
|
2482
|
-
const id = await db.backup('/path/to/backups', {
|
|
2483
|
-
metadata: 'nightly-2026-06-04',
|
|
2484
|
-
});
|
|
2667
|
+
const id = await db.backup('/path/to/backups', { metadata: 'nightly-2026-06-04' });
|
|
2485
2668
|
```
|
|
2486
2669
|
|
|
2487
2670
|
`BackupOptions`:
|
|
@@ -2531,9 +2714,7 @@ await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar')));
|
|
|
2531
2714
|
// Restore: `tar -xf backup.tar -C /restored`, then open '/restored'.
|
|
2532
2715
|
|
|
2533
2716
|
// Or gzip it (`tar -xzf backup.tar.gz` to restore):
|
|
2534
|
-
await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar.gz')), {
|
|
2535
|
-
gzip: true,
|
|
2536
|
-
});
|
|
2717
|
+
await db.backup(Writable.toWeb(createWriteStream('/path/to/backup.tar.gz')), { gzip: true });
|
|
2537
2718
|
```
|
|
2538
2719
|
|
|
2539
2720
|
`BackupStreamOptions`:
|
|
@@ -2608,7 +2789,7 @@ backup references them, so this is not equivalent to deleting files manually.
|
|
|
2608
2789
|
|
|
2609
2790
|
Deletes all but the newest `keepCount` backups.
|
|
2610
2791
|
|
|
2611
|
-
### `backups.verify(backupDir: string, backupId: number, options?: { verifyWithChecksum?: boolean; verifyTransactionLogs?: boolean }): Promise<void>`
|
|
2792
|
+
### `backups.verify(backupDir: string, backupId: number, options?: { verifyWithChecksum?: boolean; verifyTables?: boolean; verifyTransactionLogs?: boolean }): Promise<void>`
|
|
2612
2793
|
|
|
2613
2794
|
Verifies a backup's file sizes, and optionally their checksums (which requires reading all
|
|
2614
2795
|
backed-up data). Resolves if the backup is intact and rejects otherwise.
|
|
@@ -2619,8 +2800,17 @@ When the backup was created with `transactionLogs: true`, the backup's transacti
|
|
|
2619
2800
|
log file's header and entry framing must be intact (snapshots are copied on committed entry
|
|
2620
2801
|
boundaries, so even a torn tail fails verification). Set `verifyTransactionLogs: false` to skip.
|
|
2621
2802
|
|
|
2803
|
+
Set `verifyTables: true` (default `false`) to also prove this build can read the backup's table
|
|
2804
|
+
files before you restore it. Each SST is opened in turn, one at a time, so file-descriptor use does
|
|
2805
|
+
not grow with the number of tables. Verification rejects, naming the file, when this build cannot
|
|
2806
|
+
decode a table's footer (for example a `format_version` written by a newer RocksDB) or its
|
|
2807
|
+
properties, or when the table records a compression algorithm this build does not support (see
|
|
2808
|
+
[`supportedCompression`](#supportedcompression)). Checksums cannot catch either case: the file is
|
|
2809
|
+
intact, just unreadable here, and RocksDB would otherwise fail on the first read after a restore.
|
|
2810
|
+
Blob files are not checked.
|
|
2811
|
+
|
|
2622
2812
|
```typescript
|
|
2623
|
-
await backups.verify('/path/to/backups', 1, { verifyWithChecksum: true });
|
|
2813
|
+
await backups.verify('/path/to/backups', 1, { verifyWithChecksum: true, verifyTables: true });
|
|
2624
2814
|
```
|
|
2625
2815
|
|
|
2626
2816
|
## Custom Store
|
|
@@ -2811,7 +3001,7 @@ Available commands:
|
|
|
2811
3001
|
|
|
2812
3002
|
## Development
|
|
2813
3003
|
|
|
2814
|
-
This package requires Node.js 18
|
|
3004
|
+
This package requires Node.js `^22.18.0 || >=24.0.0`, pnpm, and a C++ compiler.
|
|
2815
3005
|
|
|
2816
3006
|
> [!TIP]
|
|
2817
3007
|
> Enable pnpm log streaming to see full build output:
|
package/bin/rocksdb-js.mjs
CHANGED
|
@@ -160,13 +160,32 @@ function createRL() {
|
|
|
160
160
|
const iface = createInterface({ input: stdin, output: stdout, completer });
|
|
161
161
|
iface.on('SIGINT', onSIGINT);
|
|
162
162
|
iface.on('SIGBREAK', () => process.exit(0));
|
|
163
|
+
iface.on('close', () => {
|
|
164
|
+
rlClosed = true;
|
|
165
|
+
});
|
|
166
|
+
iface.on('error', (err) => {
|
|
167
|
+
console.error(bad(`stdin error: ${err.message ?? err}`));
|
|
168
|
+
process.exit(1);
|
|
169
|
+
});
|
|
163
170
|
return iface;
|
|
164
171
|
}
|
|
165
172
|
|
|
166
173
|
let rl = createRL();
|
|
174
|
+
let lineIterator = rl[Symbol.asyncIterator]();
|
|
175
|
+
let rlClosed = false;
|
|
167
176
|
let ctrlC = false;
|
|
168
177
|
let currentAbortController = null;
|
|
169
178
|
|
|
179
|
+
function whenAborted(signal) {
|
|
180
|
+
return new Promise((_resolve, reject) => {
|
|
181
|
+
signal.addEventListener(
|
|
182
|
+
'abort',
|
|
183
|
+
() => reject(Object.assign(new Error('The operation was aborted'), { code: 'ABORT_ERR' })),
|
|
184
|
+
{ once: true }
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
170
189
|
function str(text) {
|
|
171
190
|
try {
|
|
172
191
|
if (typeof text === 'symbol') {
|
|
@@ -240,17 +259,32 @@ async function run(fn) {
|
|
|
240
259
|
return { time: end - start, result };
|
|
241
260
|
}
|
|
242
261
|
|
|
262
|
+
// Returns the next line, or `null` once the iterator itself reports `done`. `close` can
|
|
263
|
+
// fire before lineIterator's already-buffered lines are drained, so `rlClosed` never
|
|
264
|
+
// gates reading — only resume()/setPrompt()/prompt()/pause(), which throw
|
|
265
|
+
// ERR_USE_AFTER_CLOSE once closed.
|
|
243
266
|
async function ask(prompt) {
|
|
244
|
-
|
|
267
|
+
if (!rlClosed) {
|
|
268
|
+
rl.resume();
|
|
269
|
+
rl.setPrompt(prompt);
|
|
270
|
+
rl.prompt();
|
|
271
|
+
}
|
|
272
|
+
// A second next() would queue behind this one and steal the next real line out from
|
|
273
|
+
// under a Ctrl-C retry below, so retries re-await this same promise instead.
|
|
274
|
+
const pending = lineIterator.next();
|
|
245
275
|
while (true) {
|
|
246
276
|
try {
|
|
247
277
|
currentAbortController = new AbortController();
|
|
248
|
-
const
|
|
249
|
-
rl.pause();
|
|
278
|
+
const result = await Promise.race([pending, whenAborted(currentAbortController.signal)]);
|
|
250
279
|
ctrlC = false;
|
|
251
|
-
return
|
|
280
|
+
if (result.done) return null;
|
|
281
|
+
if (!rlClosed) rl.pause();
|
|
282
|
+
return result.value;
|
|
252
283
|
} catch (err) {
|
|
253
284
|
if (err.code !== 'ABORT_ERR') throw err;
|
|
285
|
+
if (rlClosed) continue;
|
|
286
|
+
rl.clearLine();
|
|
287
|
+
rl.prompt();
|
|
254
288
|
}
|
|
255
289
|
}
|
|
256
290
|
}
|
|
@@ -842,6 +876,12 @@ async function removeCommand(args) {
|
|
|
842
876
|
}
|
|
843
877
|
|
|
844
878
|
async function replCommand() {
|
|
879
|
+
// A non-TTY stdin can't feed the sub-REPL interactively, and input already read out
|
|
880
|
+
// of it has no defined recipient once this CLI's interface closes.
|
|
881
|
+
if (!stdin.isTTY) {
|
|
882
|
+
console.log(bad('The JS sub-REPL needs an interactive terminal; stdin is not one here.\n'));
|
|
883
|
+
process.exit(1);
|
|
884
|
+
}
|
|
845
885
|
const history = [...rl.history];
|
|
846
886
|
rl.close();
|
|
847
887
|
r = repl.start({ prompt: styleText('magenta', 'js> '), useColors: true, useGlobal: false });
|
|
@@ -852,6 +892,8 @@ async function replCommand() {
|
|
|
852
892
|
console.log();
|
|
853
893
|
rl = createRL();
|
|
854
894
|
rl.history = history;
|
|
895
|
+
lineIterator = rl[Symbol.asyncIterator]();
|
|
896
|
+
rlClosed = false;
|
|
855
897
|
}
|
|
856
898
|
|
|
857
899
|
function statsCommand() {
|
|
@@ -942,7 +984,9 @@ async function main() {
|
|
|
942
984
|
console.log(`Type "help" for more information.`);
|
|
943
985
|
|
|
944
986
|
while (true) {
|
|
945
|
-
const
|
|
987
|
+
const raw = await ask('> ');
|
|
988
|
+
if (raw === null) break;
|
|
989
|
+
const line = raw.trim().split(/[ \t]+/);
|
|
946
990
|
const command = line[0];
|
|
947
991
|
if (!command) continue;
|
|
948
992
|
const commandFn = COMMANDS[command];
|