@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 +375 -49
- package/dist/index.cjs +587 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +276 -58
- package/dist/index.d.mts +276 -58
- package/dist/index.mjs +583 -159
- package/dist/index.mjs.map +1 -1
- package/docs/performance/mutex-contention.md +4 -3
- package/docs/performance/transaction-state-2026-11-09.csv +28 -0
- package/docs/performance/transaction-state.md +67 -0
- package/docs/stats.md +4 -0
- package/docs/steady-clock-design.md +1 -1
- package/docs/transaction-log.md +39 -7
- package/package.json +19 -19
|
@@ -171,11 +171,12 @@ A separate native reproduction confirmed [#860](https://github.com/HarperFast/ro
|
|
|
171
171
|
`tryClose()` releases its written-position lock before latching admission closed.
|
|
172
172
|
A transaction can bind, finish `writeBatch()` and reduce the pending count back to zero
|
|
173
173
|
between those checks. A temporary scheduling hook before phase 3 reproduced
|
|
174
|
-
`pending=0`, one real uncommitted position, and `tryClose() == true`. This
|
|
174
|
+
`pending=0`, one real uncommitted position, and `tryClose() == true`. This was a
|
|
175
175
|
pre-existing close-protocol defect, not fixed by the completion-lock change. Its
|
|
176
176
|
production trigger is a concurrent destructive log purge; no customer incident or
|
|
177
|
-
JavaScript-level data loss was reproduced.
|
|
178
|
-
|
|
177
|
+
JavaScript-level data loss was reproduced. It is now fixed without changing the binding
|
|
178
|
+
lock: every bind advances a generation that `tryClose()` rechecks before latching (see
|
|
179
|
+
"Conditional close" in `src/binding/transaction_log/DESIGN.md`).
|
|
179
180
|
|
|
180
181
|
The investigation also found [#859](https://github.com/HarperFast/rocksdb-js/issues/859):
|
|
181
182
|
the DB-instance stress test awaits an empty promise array in its worker-close phase.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
lifecycle,trial,plain_ns,atomic_ns
|
|
2
|
+
commit,1,0.490,2.389
|
|
3
|
+
commit,2,0.416,2.180
|
|
4
|
+
commit,3,0.409,2.009
|
|
5
|
+
commit,4,0.368,1.901
|
|
6
|
+
commit,5,0.368,1.854
|
|
7
|
+
commit,6,0.353,1.729
|
|
8
|
+
commit,7,0.359,1.714
|
|
9
|
+
commit,8,0.361,1.697
|
|
10
|
+
commit,9,0.365,1.698
|
|
11
|
+
retry_then_commit,1,0.609,3.140
|
|
12
|
+
retry_then_commit,2,0.602,3.137
|
|
13
|
+
retry_then_commit,3,0.608,3.147
|
|
14
|
+
retry_then_commit,4,0.602,3.144
|
|
15
|
+
retry_then_commit,5,0.609,3.142
|
|
16
|
+
retry_then_commit,6,0.602,3.138
|
|
17
|
+
retry_then_commit,7,0.603,3.137
|
|
18
|
+
retry_then_commit,8,0.602,3.137
|
|
19
|
+
retry_then_commit,9,0.609,3.147
|
|
20
|
+
abort,1,0.294,1.058
|
|
21
|
+
abort,2,0.477,1.089
|
|
22
|
+
abort,3,0.260,1.066
|
|
23
|
+
abort,4,0.479,1.062
|
|
24
|
+
abort,5,0.288,1.060
|
|
25
|
+
abort,6,0.510,1.062
|
|
26
|
+
abort,7,0.285,1.118
|
|
27
|
+
abort,8,0.507,1.117
|
|
28
|
+
abort,9,0.507,1.120
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Transaction state CAS cost (#1013)
|
|
2
|
+
|
|
3
|
+
The state cell prevents a failed commit's completion from reviving a handle that a
|
|
4
|
+
foreign `close()` has aborted. Its lifecycle transitions add atomic read-modify-write
|
|
5
|
+
work on transaction paths, including the read-only transaction abort path.
|
|
6
|
+
|
|
7
|
+
## Work per lifecycle
|
|
8
|
+
|
|
9
|
+
Counts below are for the normal uncontended paths, excluding existing atomics in
|
|
10
|
+
admission, async-work tracking, and teardown. A CAS uses acquire-release ordering on
|
|
11
|
+
success and relaxed ordering on failure.
|
|
12
|
+
|
|
13
|
+
| Path | State-cell work replacing plain state accesses |
|
|
14
|
+
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| Successful `commit()` or `commitSync()` | Two strong CAS operations: admission and successful completion. The subsequent `close()` loads `Committed` and performs no state CAS. |
|
|
16
|
+
| Failed attempt returned to the caller for retry | Two strong CAS operations: admission and revert to `Pending`. Each subsequent attempt pays its own admission and completion/revert. |
|
|
17
|
+
| Explicit abort of a pending transaction | One successful weak CAS to `Aborted`; the subsequent `close()` loads `Aborted` without another state CAS. |
|
|
18
|
+
| Closing a pending or committing transaction | One successful weak CAS in the uncontended case. A racing revert or spurious failure can repeat the loop; observing a terminal state stops it. |
|
|
19
|
+
| Per-operation state gates (`get`, `put`, `remove`) | Relaxed atomic loads, with no CAS. These remain advisory and do not protect `txn` from concurrent teardown. |
|
|
20
|
+
|
|
21
|
+
Setup-error cleanup also attempts `Committing` → `Pending`; a concurrent abort makes
|
|
22
|
+
that CAS fail without reviving the handle. No mutex, allocation, or process-global
|
|
23
|
+
counter was added by the state cell. It is per transaction, but async execution can
|
|
24
|
+
still transfer its cache line between the owning JS thread and a commit worker.
|
|
25
|
+
|
|
26
|
+
## Local measurement
|
|
27
|
+
|
|
28
|
+
[The standalone benchmark](../../benchmark/transaction-state.cc) compares the actual
|
|
29
|
+
`TransactionStateCell` with a single-threaded plain-field reference: unconditional
|
|
30
|
+
admission/success stores, a checked revert, and checked abort stores. Both use the
|
|
31
|
+
same byte-sized enum to isolate atomic access cost (the old production enum was
|
|
32
|
+
int-sized). Compiler barriers keep accesses between phases from being eliminated;
|
|
33
|
+
they emit no hardware fences. This measures only the state operations, initialization,
|
|
34
|
+
and loop overhead, not RocksDB, N-API, rollback, log I/O, or scheduling.
|
|
35
|
+
|
|
36
|
+
Run from the repository root with Clang or GCC:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
clang++ -std=c++20 -O3 -DNDEBUG -Isrc/binding benchmark/transaction-state.cc -o /tmp/transaction-state-bench
|
|
40
|
+
/tmp/transaction-state-bench
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Measured against `b05ca26f05f6fee7255e427b850ccb0b57e97336` on 2026-11-09,
|
|
44
|
+
macOS 27.0.1 arm64, Apple Clang 21.0.0. Each variant gets a 100,000-iteration warmup,
|
|
45
|
+
then nine paired trials of 5,000,000 lifecycles, alternating variant order. No builds
|
|
46
|
+
or tests ran concurrently with the measurement. The shared host was not isolated
|
|
47
|
+
or CPU-pinned; nearby load averages were 1.52 / 1.62 / 1.70, so small timings are
|
|
48
|
+
host-sensitive. Generated assembly retained `casalb` for the atomic transitions
|
|
49
|
+
and ordinary byte loads/stores for the reference.
|
|
50
|
+
|
|
51
|
+
[Raw trials](transaction-state-2026-11-09.csv), nanoseconds per lifecycle:
|
|
52
|
+
|
|
53
|
+
| Lifecycle | Plain median (range) | Atomic median (range) | Difference of medians |
|
|
54
|
+
| --------------------------------------------------- | -------------------: | --------------------: | --------------------: |
|
|
55
|
+
| Successful commit and close | 0.368 (0.353–0.490) | 1.854 (1.697–2.389) | +1.486 ns |
|
|
56
|
+
| One failed attempt, then successful retry and close | 0.603 (0.602–0.609) | 3.140 (3.137–3.147) | +2.537 ns |
|
|
57
|
+
| Explicit abort and close | 0.477 (0.260–0.510) | 1.066 (1.058–1.120) | +0.589 ns |
|
|
58
|
+
|
|
59
|
+
**Performance:** Successful commits add two state CAS operations in place of plain
|
|
60
|
+
stores; failed attempts also pay an admission and a revert CAS, and explicit aborts
|
|
61
|
+
add one successful CAS. The local uncontended state-only comparison measured
|
|
62
|
+
+1.486 ns per successful commit/close and +0.589 ns per explicit abort/close at the
|
|
63
|
+
median, buying an indivisible transition that cannot overwrite a concurrent abort.
|
|
64
|
+
The per-record gates use relaxed loads, not RMW operations. These measurements do
|
|
65
|
+
not establish an end-to-end throughput regression or bound cross-core cache-line
|
|
66
|
+
transfer, contention, or costs on x86/other hosts. In particular, the single-threaded
|
|
67
|
+
benchmark does not measure the JS-thread-to-commit-worker handoff.
|
package/docs/stats.md
CHANGED
|
@@ -189,6 +189,10 @@ which includes all of the stats above plus the following:
|
|
|
189
189
|
| `rocksdb.blobdb.gc.num.new.files` | Number of new blob files generated by garbage collection (legacy stacked BlobDB). | ticker |
|
|
190
190
|
| `rocksdb.blobdb.get.micros` | Distribution of `Get()` latencies (microseconds) (legacy stacked BlobDB). | histogram |
|
|
191
191
|
| `rocksdb.blobdb.key.size` | Distribution of key sizes for BlobDB operations. | histogram |
|
|
192
|
+
| `rocksdb.blobdb.lazy.partial.bytes.saved` | Bytes not read from blob files thanks to partial (byte-range) lazy reads: the value size minus the bytes actually read. | ticker |
|
|
193
|
+
| `rocksdb.blobdb.lazy.partial.read.count` | Number of lazy blob reads that read only a byte range of an uncompressed blob value. | ticker |
|
|
194
|
+
| `rocksdb.blobdb.lazy.read.bytes` | Bytes read from storage while resolving lazy wide-column results (`GetEntityLazy`/`MultiGetEntityLazy`). | ticker |
|
|
195
|
+
| `rocksdb.blobdb.lazy.read.count` | Number of storage reads issued while resolving lazy wide-column results (`GetEntityLazy`/`MultiGetEntityLazy`). | ticker |
|
|
192
196
|
| `rocksdb.blobdb.multiget.micros` | Distribution of `MultiGet()` latencies (microseconds) (legacy stacked BlobDB). | histogram |
|
|
193
197
|
| `rocksdb.blobdb.next.micros` | Distribution of iterator `Next()` latencies (microseconds) (legacy stacked BlobDB). | histogram |
|
|
194
198
|
| `rocksdb.blobdb.num.get` | Number of `Get()` calls (legacy stacked BlobDB). | ticker |
|
|
@@ -87,7 +87,7 @@ separate properties. No promise is made that runtime timers use the same suspend
|
|
|
87
87
|
## Verification
|
|
88
88
|
|
|
89
89
|
The end-to-end route is the real N-API export through `src/index.ts`, parent/worker bracketing on
|
|
90
|
-
Node
|
|
90
|
+
Node and Bun (Deno: local `pnpm test:deno` only, #947), staggered/restarted and concurrent workers (including `workerFirst >= parentBefore + stagger - tolerance`), elapsed
|
|
91
91
|
progression, and built ESM/CJS
|
|
92
92
|
exports. Temporarily substituting a worker-local clock must make those brackets fail, proving
|
|
93
93
|
they reject the motivating defect.
|
package/docs/transaction-log.md
CHANGED
|
@@ -58,7 +58,7 @@ The token is used to validate that the file is indeed a transaction log.
|
|
|
58
58
|
|
|
59
59
|
#### `version`
|
|
60
60
|
|
|
61
|
-
The transaction log file format version. Currently, version `1` is the latest.
|
|
61
|
+
The transaction log file format version. Currently, version `1` is the latest. Opening a database whose newest transaction-log segments include one with a higher version (written by a newer release) fails with an error naming the segment, rather than skipping it, and this release never appends after such a segment. A malformed or unreadable segment is still skipped.
|
|
62
62
|
|
|
63
63
|
#### `timestamp`
|
|
64
64
|
|
|
@@ -84,9 +84,10 @@ The size of the data entry directly following the transaction header.
|
|
|
84
84
|
|
|
85
85
|
Transaction entry related flags.
|
|
86
86
|
|
|
87
|
-
| Flag
|
|
88
|
-
|
|
|
89
|
-
| `TRANSACTION_LOG_ENTRY_LAST_FLAG`
|
|
87
|
+
| Flag | Value | Description |
|
|
88
|
+
| ------------------------------------ | ------ | --------------------------------------------------------------------------------------------------- |
|
|
89
|
+
| `TRANSACTION_LOG_ENTRY_LAST_FLAG` | `0x01` | Indicates the last entry for a transaction |
|
|
90
|
+
| `TRANSACTION_LOG_ENTRY_ABORTED_FLAG` | `0x02` | On a transaction's first entry: the transaction did not commit, and readers skip all of its entries |
|
|
90
91
|
|
|
91
92
|
## Encoding Details
|
|
92
93
|
|
|
@@ -210,11 +211,39 @@ await db.transaction((txn) => {
|
|
|
210
211
|
bytes can name a later position than was flushed, and retention would then treat unflushed
|
|
211
212
|
entries below it as flushed.
|
|
212
213
|
- A reader that starts from the last flushed position, or one already inside a purged file,
|
|
213
|
-
continues at the
|
|
214
|
+
continues at the next retained sequence after that position (or at the next write when nothing is
|
|
215
|
+
retained).
|
|
214
216
|
- Downgrading to a release without this rule after a store's last file was purged is unsafe: older
|
|
215
217
|
releases restart an empty store at sequence 1, below the flushed position, so a replay from
|
|
216
218
|
`txn.state` skips the new writes and retention treats them as flushed. Remove the store's
|
|
217
|
-
`txn.state` before downgrading.
|
|
219
|
+
`txn.state` and `txn.purged` before downgrading.
|
|
220
|
+
|
|
221
|
+
### Purge Watermark
|
|
222
|
+
|
|
223
|
+
Each store keeps `txn.purged`, the record behind `log.getPurgeWatermark()`: an upper bound on every
|
|
224
|
+
entry key in every file the store deleted. Keys are not ordered within or across files (see below),
|
|
225
|
+
so neither file age nor the keys still in the log bound what a purge removed; only the deleted files
|
|
226
|
+
themselves did, and they are gone.
|
|
227
|
+
|
|
228
|
+
- Every purge (`db.purgeLogs()` and the retention that runs when a database opens) raises the
|
|
229
|
+
record to the largest key of each file it is about to delete and syncs it before the first file is
|
|
230
|
+
deleted. A crash in between leaves the record above the files that survived, never below a file
|
|
231
|
+
that was deleted. A record that cannot be written stops the purge and warns through `log.warn`.
|
|
232
|
+
Closing a database and rotating a file delete nothing.
|
|
233
|
+
- The largest key of a file this process wrote is tracked as entries are appended. A file written
|
|
234
|
+
by an earlier process is read once, before deletion, by the same scan that
|
|
235
|
+
[the timestamp floor](#the-timestamp-floor-at-open) uses; one purge run spends at most 2 seconds
|
|
236
|
+
on these reads (`ROCKSDB_JS_PURGE_SCAN_MS`, read once per process), and files it could not read wait
|
|
237
|
+
for the next run. A deployment whose files take longer than that to read raises the budget. A file whose keys cannot be
|
|
238
|
+
proven because it is corrupt is still deleted, and the watermark becomes unknown.
|
|
239
|
+
- The record is reported as known only when every file deletion in the store's history was recorded
|
|
240
|
+
this way. A store created before this release has no record and reports unknown permanently, as
|
|
241
|
+
does a store whose files an older release deleted after the record was written (detected when a
|
|
242
|
+
sequence after the record's is missing), or whose record cannot be read. Files deleted outside
|
|
243
|
+
rocksdb-js cannot be detected in general.
|
|
244
|
+
- `purgeLogs({ destroy: true })` marks the record unknown before it deletes anything, and leaves the
|
|
245
|
+
store open, with its files, if it cannot. A log recreated under the same name starts a new history.
|
|
246
|
+
- Backups capture the record with the files they copy, so a restored store keeps its watermark.
|
|
218
247
|
|
|
219
248
|
### Error Handling
|
|
220
249
|
|
|
@@ -268,7 +297,10 @@ segment's creation, so there is no shortcut: every segment is read.
|
|
|
268
297
|
Retention can purge every segment of the named log. The walk then finds no keys and leaves the clock
|
|
269
298
|
alone, so a backward step after restart can reissue a key that was only in purged segments. With
|
|
270
299
|
age-based retention alone that needs a backward step at least as large as the retention period; a
|
|
271
|
-
`purgeLogs({ before })` cutoff closer to now shrinks it to the time since that cutoff.
|
|
300
|
+
`purgeLogs({ before })` cutoff closer to now shrinks it to the time since that cutoff. A caller that
|
|
301
|
+
needs the guarantee past retention persists its own floor — the largest key it has issued, or any
|
|
302
|
+
bound it has advertised — and restores it with `raiseTimestampFloor()` before the process starts a
|
|
303
|
+
transaction. That call only raises the in-memory clock; persisting the value is the caller's job.
|
|
272
304
|
|
|
273
305
|
Name only a log this process originates. A log a replication receiver writes under an adopted origin
|
|
274
306
|
timestamp is keyed by another node's clock, and seeding from it would ratchet this process's clock
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harperfast/rocksdb-js",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "RocksDB binding for Node.js",
|
|
5
5
|
"homepage": "https://github.com/HarperFast/rocksdb-js",
|
|
6
6
|
"bugs": [
|
|
@@ -38,39 +38,39 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@rollup/plugin-replace": "6.0.3",
|
|
41
|
-
"@types/node": "26.6.
|
|
41
|
+
"@types/node": "26.6.6",
|
|
42
42
|
"@vitest/coverage-v8": "4.1.11",
|
|
43
43
|
"cross-env": "10.1.0",
|
|
44
|
-
"dotenv": "18.0.
|
|
45
|
-
"lefthook": "2.2.
|
|
46
|
-
"lmdb": "3.5.
|
|
44
|
+
"dotenv": "18.0.9",
|
|
45
|
+
"lefthook": "2.2.2",
|
|
46
|
+
"lmdb": "3.5.9",
|
|
47
47
|
"node-gyp": "13.1.0",
|
|
48
|
-
"oxfmt": "0.
|
|
49
|
-
"oxlint": "1.
|
|
48
|
+
"oxfmt": "0.77.0",
|
|
49
|
+
"oxlint": "1.92.0",
|
|
50
50
|
"prebuildify": "6.0.1",
|
|
51
|
-
"semver": "7.8.
|
|
51
|
+
"semver": "7.8.6",
|
|
52
52
|
"tar": "7.5.22",
|
|
53
|
-
"tsdown": "0.23.
|
|
54
|
-
"tslib": "2.
|
|
53
|
+
"tsdown": "0.23.7",
|
|
54
|
+
"tslib": "2.9.1",
|
|
55
55
|
"typescript": "7.0.2",
|
|
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.14.0",
|
|
60
|
+
"@harperfast/rocksdb-js-darwin-x64": "2.14.0",
|
|
61
|
+
"@harperfast/rocksdb-js-linux-arm64-glibc": "2.14.0",
|
|
62
|
+
"@harperfast/rocksdb-js-linux-arm64-musl": "2.14.0",
|
|
63
|
+
"@harperfast/rocksdb-js-linux-x64-glibc": "2.14.0",
|
|
64
|
+
"@harperfast/rocksdb-js-linux-x64-musl": "2.14.0",
|
|
65
|
+
"@harperfast/rocksdb-js-win32-arm64": "2.14.0",
|
|
66
|
+
"@harperfast/rocksdb-js-win32-x64": "2.14.0"
|
|
67
67
|
},
|
|
68
68
|
"engines": {
|
|
69
69
|
"node": "^22.18.0 || >=24.0.0"
|
|
70
70
|
},
|
|
71
71
|
"gypfile": true,
|
|
72
72
|
"rocksdb": {
|
|
73
|
-
"version": "11.
|
|
73
|
+
"version": "11.13.2"
|
|
74
74
|
},
|
|
75
75
|
"scripts": {
|
|
76
76
|
"bench": "cross-env CI=1 node --expose-gc ./node_modules/vitest/vitest.mjs bench --passWithNoTests --outputJson benchmark-results.json",
|