@harperfast/rocksdb-js 2.9.1 → 2.11.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 +377 -56
- package/bin/rocksdb-js.mjs +49 -5
- package/dist/index.cjs +206 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +180 -7
- package/dist/index.d.mts +180 -7
- package/dist/index.mjs +205 -33
- package/dist/index.mjs.map +1 -1
- package/docs/backups.md +3 -2
- package/docs/performance/mutex-contention-2026-09-16.json +684 -0
- package/docs/performance/mutex-contention-admission-2026-09-17.json +730 -0
- package/docs/performance/mutex-contention.md +204 -0
- package/docs/stats.md +48 -46
- package/docs/steady-clock-design.md +121 -0
- package/docs/transaction-log.md +120 -1
- package/docs/transaction-timestamp-integrity-design.md +70 -8
- package/package.json +16 -16
package/docs/transaction-log.md
CHANGED
|
@@ -225,7 +225,8 @@ monotonic clock when it is constructed but is appended when it commits, so under
|
|
|
225
225
|
entry can carry a smaller timestamp; an entry that adopted an origin timestamp with
|
|
226
226
|
`txn.setTimestamp()` carries that origin's clock instead. Timestamps are not unique: the monotonic
|
|
227
227
|
clock never issues the same value twice within a process, but a restart after the wall clock moved
|
|
228
|
-
backwards can reissue one, and `txn.setTimestamp()` can assign any
|
|
228
|
+
backwards can reissue one unless the floor below is seeded, and `txn.setTimestamp()` can assign any
|
|
229
|
+
value — including one already
|
|
229
230
|
in the log — to as many transactions as the caller likes. Deduplicating on the timestamp alone is
|
|
230
231
|
therefore never safe; a consumer that needs identity has to supply it (Harper pairs the timestamp
|
|
231
232
|
with the originating node).
|
|
@@ -236,6 +237,124 @@ running maxima — and a query seeks to the lower bound of that index, which is
|
|
|
236
237
|
or before every entry in the requested range. Reading forward from there and filtering is what makes
|
|
237
238
|
range queries correct on an unordered file.
|
|
238
239
|
|
|
240
|
+
## The Timestamp Floor At Open
|
|
241
|
+
|
|
242
|
+
A transaction's timestamp is the key of the batch it is written under, so it has to stay unique
|
|
243
|
+
within the log it is written to. The process clock (`db.getMonotonicTimestamp()`) guarantees that
|
|
244
|
+
only within one process: a new process reads the wall clock again, so a backward step between runs
|
|
245
|
+
can reissue a key that is already durable in the log.
|
|
246
|
+
|
|
247
|
+
Opening with the `timestampFloorLog` option names the log whose keys this process originates. Every
|
|
248
|
+
segment of that store is then walked once, after open-time recovery has decided which bytes are
|
|
249
|
+
still durable, and the process clock is raised above the largest key found — before the database
|
|
250
|
+
handle is returned, so no transaction can be constructed below it. Keys are not ordered within or
|
|
251
|
+
across segments, and a segment header records the store's latest timestamp only as of that
|
|
252
|
+
segment's creation, so there is no shortcut: every segment is read.
|
|
253
|
+
|
|
254
|
+
Name only a log this process originates. A log a replication receiver writes under an adopted origin
|
|
255
|
+
timestamp is keyed by another node's clock, and seeding from it would ratchet this process's clock
|
|
256
|
+
to the fastest of those nodes at each restart. Native code cannot tell the two kinds of log apart,
|
|
257
|
+
which is why the caller names it and why an unset option leaves the clock alone.
|
|
258
|
+
|
|
259
|
+
The walk is the cost of the guarantee, and it is paid on the calling thread before `open()` returns.
|
|
260
|
+
It reads entry headers through a shared 64 KiB window and seeks past payloads, so it scales with the
|
|
261
|
+
number of entries in the named log rather than its size. Measured on Linux with a warm page cache,
|
|
262
|
+
against the same database opened without the option:
|
|
263
|
+
|
|
264
|
+
| Named log | Added open time (median) |
|
|
265
|
+
| --------------------------------- | ------------------------ |
|
|
266
|
+
| 25,000 entries, 99 MB, 7 segments | ~31 ms |
|
|
267
|
+
| 250,000 entries, 19 MB | ~273 ms |
|
|
268
|
+
|
|
269
|
+
Roughly a millisecond per thousand entries. There is a second term on Windows only. A segment there
|
|
270
|
+
is pre-extended to `transactionLogMaxSize` when it is mapped and keeps that physical size for life —
|
|
271
|
+
rotation does not truncate it — so a segment's bytes past its last entry are zero padding, and the
|
|
272
|
+
walk has to read that padding to prove nothing durable hides in it (see fail closed, below). So on
|
|
273
|
+
Windows the walk also scales with `segments x transactionLogMaxSize`. Measured on Linux against
|
|
274
|
+
deliberately Windows-shaped segments, 80 MiB of padding across 5 segments added ~148 ms to the open
|
|
275
|
+
with a warm page cache; a cold cache or slower storage is proportionally worse, and this has not been
|
|
276
|
+
measured on Windows itself. Size the budget below with that in mind before enabling the option on a
|
|
277
|
+
Windows deployment with a long retention window.
|
|
278
|
+
|
|
279
|
+
Retention bounds a log's age, not its entry count, so
|
|
280
|
+
the walk is bounded directly instead: `ROCKSDB_JS_TIMESTAMP_FLOOR_SCAN_MS` (default `2000`) caps it,
|
|
281
|
+
newest segment first, and a walk that runs out of budget rejects an open that names the log. The
|
|
282
|
+
rejection reports the segments and bytes the walk covered, so the budget can be sized from what the
|
|
283
|
+
store actually costs.
|
|
284
|
+
The deadline is checked before each physical read; sequential headers share a 64 KiB window, so the
|
|
285
|
+
small amount of already-buffered header parsing can finish after the deadline but one large segment
|
|
286
|
+
cannot run unbounded.
|
|
287
|
+
The value is honored literally between `0` (scan nothing, and reject) and one day, and there is no
|
|
288
|
+
unbounded setting — the failure it bounds is an `open()` that does not return, so a deployment that
|
|
289
|
+
would rather wait raises the number. Above a day it is clamped, because the deadline is a monotonic
|
|
290
|
+
clock time point and a larger value overflows its resolution and wraps into the past.
|
|
291
|
+
A database opened without `timestampFloorLog` pays none of it. The walk runs while database opens
|
|
292
|
+
and closes are serialized process-wide, so increasing its budget can delay unrelated opens and
|
|
293
|
+
closes too — and the budget is **per database path**, not per process, so a process that opens twenty
|
|
294
|
+
opted-in databases can spend twenty times the number set here, serialized.
|
|
295
|
+
|
|
296
|
+
The seed is fail closed when `timestampFloorLog` is set, and it asks a stronger question of each
|
|
297
|
+
segment than recovery does. Recovery decides where it is safe to truncate; this has to establish that
|
|
298
|
+
it observed every durable key, so the walk may stop short of a segment's extent only on a proof that
|
|
299
|
+
no complete frame can remain: the framed entries reach the extent, or everything past them is zero,
|
|
300
|
+
or fewer than 13 bytes remain (too few to open an entry header). Anything else — a framing break, a
|
|
301
|
+
declared length that overruns, bytes that are neither framed nor padding — rejects the open, as does a
|
|
302
|
+
segment that cannot be opened or scanned, an exhausted budget, or a key more than ten years ahead of
|
|
303
|
+
the wall clock. Entries past a break can still be durable and `query()` resyncs to them, which is
|
|
304
|
+
exactly why this walk cannot treat them as absent.
|
|
305
|
+
|
|
306
|
+
The first open that names `timestampFloorLog` must be the writable primary. A read-only or secondary
|
|
307
|
+
handle cannot establish a cross-process snapshot: a primary could durably append a larger key as the
|
|
308
|
+
handle scans. Once this process has seeded that same log through a writable primary, read-only and
|
|
309
|
+
secondary descriptors reuse the established seed without rescanning. A handle that cannot meet this
|
|
310
|
+
rule must omit the option.
|
|
311
|
+
|
|
312
|
+
The writable recovery scan is separately bounded by `ROCKSDB_JS_TRANSACTION_LOG_RECOVERY_SCAN_MS`
|
|
313
|
+
(default `2000`). At a zero end-of-entries marker it proves every remaining byte is zero before a
|
|
314
|
+
POSIX opener durably truncates copied Windows padding; on timeout, non-zero data, or a failed sync it
|
|
315
|
+
refuses the writable open rather than allowing a later `O_APPEND` write to become invisible past the
|
|
316
|
+
marker.
|
|
317
|
+
|
|
318
|
+
Discovery only registers segments whose filename is one the writer could have produced —
|
|
319
|
+
`<sequence>.txnlog`, a positive decimal with no padding or suffix. A `.txnlog` that is not, or a
|
|
320
|
+
second file claiming a sequence already registered, is ignored and counted as a segment discovery
|
|
321
|
+
could not place, which refuses an opted-in open and names the file. This matters because the
|
|
322
|
+
filename _is_ the segment's identity: a `1 copy.txnlog` next to `1.txnlog` used to take its place in
|
|
323
|
+
the registry, so the real segment was never read and the floor was raised without its keys. **An
|
|
324
|
+
ordinary open is affected too** — such a file used to be exposed as a real segment and could be
|
|
325
|
+
recovered or appended relative to; it is now ignored, so a directory holding manually renamed or
|
|
326
|
+
copied segments starts from the canonical sequence state instead. Rename only after establishing
|
|
327
|
+
which file is canonical. Note that a canonical name is a syntax check, not provenance: a valid
|
|
328
|
+
`9.txnlog` copied in from another node passes it, so directory integrity and naming the log this
|
|
329
|
+
process originates stay the caller's responsibility.
|
|
330
|
+
|
|
331
|
+
A scan that succeeds after using most of its budget warns, so the growth that would otherwise turn
|
|
332
|
+
into a refusal on some later restart is visible before it does.
|
|
333
|
+
|
|
334
|
+
A `timestampFloorLog` naming a log the database does not have warns — including on a database with no
|
|
335
|
+
logs at all, which is where a misspelled name is most likely: a newly named locally originated log has
|
|
336
|
+
no existing keys to seed. Callers that need restart-safe uniqueness must provision and name their
|
|
337
|
+
local log consistently before its first write; a missing name can otherwise be a configuration mistake
|
|
338
|
+
that leaves no key to scan.
|
|
339
|
+
|
|
340
|
+
After that warning, the first use can create a genuinely fresh or empty named directory. It cannot
|
|
341
|
+
adopt a nonempty directory that reappears before that first use: its keys were not covered by the
|
|
342
|
+
missing-log seed, and a transaction's timestamp already exists before `txn.useLog()` could inspect it.
|
|
343
|
+
That use — and a read-only or secondary re-open that discovers the directory — refuses. Close every
|
|
344
|
+
handle for the path and reopen with `timestampFloorLog` so the restored log is scanned before use.
|
|
345
|
+
|
|
346
|
+
A floor raised more than a second above the wall clock also warns. Only a backward clock step puts
|
|
347
|
+
durable keys ahead of now, and from that point the process issues timestamps off the floor rather than
|
|
348
|
+
tracking wall time — and each restart re-seeds from the keys it just wrote — so it stays ahead until
|
|
349
|
+
the clock catches up.
|
|
350
|
+
|
|
351
|
+
The seed belongs to the physical database path, not to one handle. The first open of a path resolves
|
|
352
|
+
it; a later open of the same path — including a read-only or secondary open, which gets its own
|
|
353
|
+
descriptor — finds it already resolved and does not walk the log again, which also means it never
|
|
354
|
+
scans a store the first handle may be appending to. An open that names a _different_ log than the one
|
|
355
|
+
already resolved for the path, or names one when the path was opened without it, is rejected rather
|
|
356
|
+
than appearing to work.
|
|
357
|
+
|
|
239
358
|
### Sequential Read
|
|
240
359
|
|
|
241
360
|
1. Parse file header to get version
|
|
@@ -90,10 +90,10 @@ work.
|
|
|
90
90
|
Keep `setTimestamp()` as the adoption API, reject invalid timestamp values, and reject changes once
|
|
91
91
|
the transaction is no longer pending or has staged a write or log entry. Preserve the existing
|
|
92
92
|
retry behavior: a reset with no durable batch clears staged writes and may adopt again, while
|
|
93
|
-
`committedPosition` freezes a timestamp whose batch already landed. Remove the second-word read API
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
`committedPosition` freezes a timestamp whose batch already landed. Remove the second-word read API
|
|
94
|
+
and flag from this PR; they belong to Harper's record codec. The clock-floor-at-open implementation
|
|
95
|
+
is retained as an explicit, caller-named option: it scans only the locally originated log, and
|
|
96
|
+
fails the opted-in open when it cannot prove a complete, plausible floor. The existing
|
|
97
97
|
`latestTimestamp` behavior used to populate new transaction-log segment headers remains unchanged.
|
|
98
98
|
|
|
99
99
|
Document what is true about log keys today, which the issue also asked for: entries are appended in
|
|
@@ -117,7 +117,69 @@ and making every read a state transition still could not prove that a producer's
|
|
|
117
117
|
the transaction timestamp. The enforced boundary therefore remains the first native staging event;
|
|
118
118
|
producer ordering is documented explicitly.
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
The clock-floor scan's far-future check stays in the open path rather than changing appends: an
|
|
121
|
+
implausible key refuses the explicitly opted-in open, while normal append and segment-header
|
|
122
|
+
behavior remains unchanged. A floor based on unnamed logs remains out of scope because it cannot
|
|
123
|
+
distinguish locally issued keys from adopted peer keys.
|
|
124
|
+
|
|
125
|
+
## Clock-floor fail-closed amendment
|
|
126
|
+
|
|
127
|
+
The task owner explicitly selected restart-safe key uniqueness over availability when
|
|
128
|
+
`timestampFloorLog` is configured. This changes that option's failure behavior only; omitting the
|
|
129
|
+
option preserves the existing process-local clock behavior.
|
|
130
|
+
|
|
131
|
+
### Approaches considered
|
|
132
|
+
|
|
133
|
+
#### Do less
|
|
134
|
+
|
|
135
|
+
Warn and raise the floor from the readable prefix. Rejected: an unreadable segment, framing break,
|
|
136
|
+
or exhausted budget can hide a durable key, leaving the next process able to issue the same key.
|
|
137
|
+
|
|
138
|
+
#### Different layer
|
|
139
|
+
|
|
140
|
+
Make `getMonotonicTimestamp()` persist its own global floor. Rejected: the timestamp source cannot
|
|
141
|
+
know which transaction-log keys this process originated, and a global persisted value would add a
|
|
142
|
+
separate durability protocol without proving it covers the caller's log.
|
|
143
|
+
|
|
144
|
+
#### Deeper cause
|
|
145
|
+
|
|
146
|
+
Require every transaction-log append to use an externally durable counter. Rejected: that changes
|
|
147
|
+
the transaction timestamp contract and replication/replay adoption path, far beyond the opt-in
|
|
148
|
+
startup safety check requested here.
|
|
149
|
+
|
|
150
|
+
#### Chosen
|
|
151
|
+
|
|
152
|
+
After recovery, scan every segment in the caller-named local log under the bounded deadline. Reject
|
|
153
|
+
the open before raising the process-wide floor if the scan is incomplete or sees an implausibly
|
|
154
|
+
future key. A read-only torn tail shorter than an entry header remains valid: no frame can begin in it, so its
|
|
155
|
+
contiguous prefix provably covers every durable entry. A longer one is refused, because nothing
|
|
156
|
+
distinguishes a partial append from corruption hiding a complete higher-keyed frame. The named-log-missing warning remains non-fatal so a fresh local log can be named
|
|
157
|
+
before its first append.
|
|
158
|
+
|
|
159
|
+
### First-use guard for an absent log
|
|
160
|
+
|
|
161
|
+
The warning remains usable for a fresh log, but it cannot authorize adopting old data later in the
|
|
162
|
+
same process. `SeedTimestampFloor` records that the named log was absent. Before a writable first
|
|
163
|
+
use creates a store, the registry rejects a nonempty directory with that name; it also rejects a
|
|
164
|
+
later read-only or secondary descriptor that discovers one. A transaction obtains its timestamp
|
|
165
|
+
before `txn.useLog()`, so rescanning at lazy resolution could not repair an already-issued key.
|
|
166
|
+
Closing every handle removes that per-path state; the next opted-in open discovers and scans the
|
|
167
|
+
restored log before a transaction can be constructed. A process-created fresh log clears the guard,
|
|
168
|
+
so normal destroy-and-recreate log maintenance remains available.
|
|
169
|
+
|
|
170
|
+
### Verification route
|
|
171
|
+
|
|
172
|
+
The forked integration fixture writes a named log in one process and opens it in another. It verifies
|
|
173
|
+
successful seeding, then independently corrupts a segment, exhausts the deadline, skips discovery,
|
|
174
|
+
creates a mid-file framing break, writes an implausible key, and restores a named directory after an
|
|
175
|
+
absent seed; each unsafe case must reject the opted-in open. Native tests cover scan classification
|
|
176
|
+
and the repository build/check gates validate the binding and TypeScript surface.
|
|
177
|
+
|
|
178
|
+
### Late opt-in and the scan budget
|
|
179
|
+
|
|
180
|
+
A handle that names `timestampFloorLog` after the path was opened without it is rejected rather than
|
|
181
|
+
warned: the descriptor cannot rerun its one-time seed, and accepting the request would suggest
|
|
182
|
+
restart-safe uniqueness that is not in force. The scan budget stays bounded by default. An unbounded
|
|
183
|
+
scan can hold serialized opens indefinitely; the bounded scan instead rejects an opted-in open when
|
|
184
|
+
it cannot prove the floor, and an operator who needs more time raises
|
|
185
|
+
`ROCKSDB_JS_TIMESTAMP_FLOOR_SCAN_MS`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harperfast/rocksdb-js",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "RocksDB binding for Node.js",
|
|
5
5
|
"homepage": "https://github.com/HarperFast/rocksdb-js",
|
|
6
6
|
"bugs": [
|
|
@@ -38,15 +38,15 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@rollup/plugin-replace": "6.0.3",
|
|
41
|
-
"@types/node": "26.
|
|
41
|
+
"@types/node": "26.6.4",
|
|
42
42
|
"@vitest/coverage-v8": "4.1.11",
|
|
43
43
|
"cross-env": "10.1.0",
|
|
44
|
-
"dotenv": "
|
|
45
|
-
"lefthook": "2.1.
|
|
46
|
-
"lmdb": "3.5.
|
|
47
|
-
"node-gyp": "13.0
|
|
48
|
-
"oxfmt": "0.
|
|
49
|
-
"oxlint": "1.
|
|
44
|
+
"dotenv": "18.0.5",
|
|
45
|
+
"lefthook": "2.1.16",
|
|
46
|
+
"lmdb": "3.5.7",
|
|
47
|
+
"node-gyp": "13.1.0",
|
|
48
|
+
"oxfmt": "0.71.0",
|
|
49
|
+
"oxlint": "1.86.0",
|
|
50
50
|
"prebuildify": "6.0.1",
|
|
51
51
|
"semver": "7.8.5",
|
|
52
52
|
"tar": "7.5.22",
|
|
@@ -56,14 +56,14 @@
|
|
|
56
56
|
"vitest": "4.1.11"
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
|
-
"@harperfast/rocksdb-js-darwin-arm64": "2.
|
|
60
|
-
"@harperfast/rocksdb-js-darwin-x64": "2.
|
|
61
|
-
"@harperfast/rocksdb-js-linux-arm64-glibc": "2.
|
|
62
|
-
"@harperfast/rocksdb-js-linux-arm64-musl": "2.
|
|
63
|
-
"@harperfast/rocksdb-js-linux-x64-glibc": "2.
|
|
64
|
-
"@harperfast/rocksdb-js-linux-x64-musl": "2.
|
|
65
|
-
"@harperfast/rocksdb-js-win32-arm64": "2.
|
|
66
|
-
"@harperfast/rocksdb-js-win32-x64": "2.
|
|
59
|
+
"@harperfast/rocksdb-js-darwin-arm64": "2.11.0",
|
|
60
|
+
"@harperfast/rocksdb-js-darwin-x64": "2.11.0",
|
|
61
|
+
"@harperfast/rocksdb-js-linux-arm64-glibc": "2.11.0",
|
|
62
|
+
"@harperfast/rocksdb-js-linux-arm64-musl": "2.11.0",
|
|
63
|
+
"@harperfast/rocksdb-js-linux-x64-glibc": "2.11.0",
|
|
64
|
+
"@harperfast/rocksdb-js-linux-x64-musl": "2.11.0",
|
|
65
|
+
"@harperfast/rocksdb-js-win32-arm64": "2.11.0",
|
|
66
|
+
"@harperfast/rocksdb-js-win32-x64": "2.11.0"
|
|
67
67
|
},
|
|
68
68
|
"engines": {
|
|
69
69
|
"node": "^22.18.0 || >=24.0.0"
|