@harperfast/rocksdb-js 2.8.0 → 2.9.1

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/docs/stats.md CHANGED
@@ -73,6 +73,28 @@ By default, `db.getStats()` returns properties only.
73
73
  | `txnlog.totalSizeBytes` | Total on-disk size in bytes of all transaction log files, summed across all logs. | gauge |
74
74
  | `txnlog.transactionsWritten` | Cumulative number of transactions successfully written to the logs (lifetime total), summed across all logs. | ticker |
75
75
  | `txnlog.uncommittedTransactions` | Number of transactions written to a log but not yet committed to RocksDB, summed across all logs. | gauge |
76
+ | `writeBufferManager.bufferSize` | The `WriteBufferManager`'s memtable-memory budget in bytes, read live. `0` when no manager is configured. **Process-wide**, not per-database — see below. | gauge |
77
+ | `writeBufferManager.memoryUsage` | Total memtable memory in bytes charged against the manager. **Process-wide.** | gauge |
78
+ | `writeBufferManager.mutableMemoryUsage` | The share of `writeBufferManager.memoryUsage` held by active (mutable) memtables; the rest is awaiting flush or retained write history. **Process-wide.** | gauge |
79
+ | `writeBufferManager.stallActive` | `1` while the manager is stalling writes, else `0`. **Process-wide.** | gauge |
80
+ | `writeBufferManager.stallActiveMs` | How long the current stall has been active, in milliseconds; `0` when not stalled. Sampled once a second by the stall watchdog. **Process-wide.** | gauge |
81
+
82
+ #### The `writeBufferManager.*` keys are process-wide
83
+
84
+ The `WriteBufferManager` is a **singleton shared by every database opened in this process**,
85
+ `worker_threads` included. These five keys therefore describe the whole process no matter which
86
+ database's `getStats()` returned them — two databases in one process always report identical
87
+ values, and the numbers do not attribute memory to the database you asked. They appear regardless
88
+ of `enableStats`, like the `txnlog.*` and `commitPipeline.*` keys.
89
+ `getWriteBufferManagerStats()` returns the same values plus the manager's
90
+ configuration and its live column-family inventory.
91
+
92
+ `writeBufferManager.stallActive` is the only signal that distinguishes a `WriteBufferManager`
93
+ stall from an idle database. RocksDB's own stall accounting does not cover it:
94
+ `rocksdb.stall.micros`, `rocksdb.db.write.stall` and the `'writeStall'` event all track the
95
+ `WriteController` (L0 and pending-compaction triggers), while a `WriteBufferManager` stall blocks
96
+ writers on a separate queue and touches none of them. Through an eight-hour production wedge all
97
+ three read `0` (HarperFast/rocksdb-js#822).
76
98
 
77
99
  ### Basic + Curated Stats
78
100
 
@@ -433,6 +455,27 @@ which includes all of the stats above plus the following:
433
455
  | `rocksdb.write.self` | Number of writes processed by the calling thread itself (as the write group leader). | ticker |
434
456
  | `rocksdb.write.wal` | Number of writes that were written to the WAL. | ticker |
435
457
 
458
+ #### `flush.reason.*` vs `atomic_flush.request.reason.*`
459
+
460
+ This library always opens databases with RocksDB's `atomic_flush`. That does **not** move
461
+ `WriteBufferManager`-pressure flushes off `rocksdb.flush.reason.write_buffer_manager` and onto
462
+ `rocksdb.atomic_flush.request.reason.write_buffer_manager` — measured on this library, both counters
463
+ move together (HarperFast/rocksdb-js#822).
464
+
465
+ They count different events, though, and neither magnitude can be derived from the other:
466
+
467
+ - `rocksdb.atomic_flush.request.reason.<reason>` counts flush **requests** — scheduling decisions.
468
+ - `rocksdb.flush.reason.<reason>` counts the flushes that actually **ran**.
469
+
470
+ Measured ratios in one process ranged from 1:1 (a single column family) to about 1:2 (two to six
471
+ column families), and requests can outnumber executed flushes when several databases share one
472
+ manager. Treat them as two views of the same pressure, not as a fixed multiple.
473
+
474
+ Neither counter reports a stall. A `0` on both during a `WriteBufferManager` stall is the expected
475
+ reading, not a broken counter: the flush trigger looks only at _mutable_ memory, so a budget held by
476
+ retained history or by memtables already awaiting flush never requests a flush at all.
477
+ `writeBufferManager.stallActive` is what distinguishes that state from an idle database.
478
+
436
479
  ## Transaction Log Stats
437
480
 
438
481
  `log.getStats()` returns a detailed snapshot for a single transaction log store. All sizes are in
@@ -477,7 +520,9 @@ const stats: TransactionLogStats = log.getStats();
477
520
  - `offset: number` The byte offset within that file.
478
521
  - `purge: object`
479
522
  - `oldestFileAgeMs: number` Age in milliseconds of the oldest file on disk.
480
- - `purgeableFiles: number` Number of files eligible for purge under the retention policy.
523
+ - `purgeableFiles: number` Number of files below the retention floor (the `txn.state` sequence,
524
+ or the highest sequence when there is no persisted flush position) that are eligible for purge
525
+ under the retention policy.
481
526
  - `retainedUnflushedFiles: number` Number of files past the retention threshold but retained
482
527
  because they are unflushed.
483
528
  - `lastPurgeMs: number` Timestamp of the last purge scan.
@@ -198,7 +198,9 @@ await db.transaction((txn) => {
198
198
  - Log files are automatically rotated when either the index or data file reaches their configured
199
199
  maximum sizes
200
200
  - Rotation happens on the next write after the size limit is exceeded
201
- - Old log files can be automatically purged based on retention policy
201
+ - Old log files can be automatically purged based on retention policy. The sequence file named by
202
+ `txn.state` and every newer file form the live store's retention floor, so an idle store can keep
203
+ one bounded file past the cutoff until a later write rotates and flushes it.
202
204
 
203
205
  ### Error Handling
204
206
 
@@ -218,10 +220,21 @@ The CLI exposes the same check as `verify-logs [name]`.
218
220
 
219
221
  ## Reading The Transaction Log
220
222
 
221
- Log entries are not guaranteed to be in order, but are guaranteed to have a monotonic timestamp.
222
- When reading the transaction log file, each transaction entry header must be read, then sorted and
223
- indexed. Using this index, queries can find all entries within a time range using a binary search
224
- and seek to get the associated entry data.
223
+ Log entries are not in timestamp order. A transaction claims its timestamp from the process-wide
224
+ monotonic clock when it is constructed but is appended when it commits, so under concurrency a later
225
+ entry can carry a smaller timestamp; an entry that adopted an origin timestamp with
226
+ `txn.setTimestamp()` carries that origin's clock instead. Timestamps are not unique: the monotonic
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 value — including one already
229
+ in the log — to as many transactions as the caller likes. Deduplicating on the timestamp alone is
230
+ therefore never safe; a consumer that needs identity has to supply it (Harper pairs the timestamp
231
+ with the originating node).
232
+
233
+ When reading the transaction log file, each transaction entry header must be read and indexed. The
234
+ index records only the entries whose timestamp is greater than every earlier one in the file — a
235
+ running maxima — and a query seeks to the lower bound of that index, which is guaranteed to sit at
236
+ or before every entry in the requested range. Reading forward from there and filtering is what makes
237
+ range queries correct on an unordered file.
225
238
 
226
239
  ### Sequential Read
227
240
 
@@ -0,0 +1,123 @@
1
+ # Transaction timestamp integrity
2
+
3
+ ## Intent
4
+
5
+ Harper's source-record version and a RocksDB transaction's write identity are separate values.
6
+ Harper's `RecordEncoder` and `PrimaryRocksDatabase` already own the record value layout and decode
7
+ its metadata; rocksdb-js owns the transaction timestamp and uses it as the transaction-log batch
8
+ key. This change only makes overriding that timestamp safe for Harper's two legitimate adopters,
9
+ replication apply and crash replay.
10
+
11
+ ## Invariant
12
+
13
+ Within one transaction attempt, the timestamp cannot change after the transaction leaves `Pending`
14
+ or stages a database write or transaction-log entry. A retry may adopt again only when the failed
15
+ attempt did not durably write its log batch; once a batch is durable, its timestamp remains frozen
16
+ across retry resets. Encoding a transaction timestamp into record bytes remains the producer's
17
+ obligation because native code cannot observe when a producer reads and copies the timestamp.
18
+
19
+ ## Requirements traced to Harper
20
+
21
+ - HarperFast/harper#2065 intentionally allowed a source record's version to differ from the
22
+ transaction timestamp.
23
+ - HarperFast/harper#2409 fixed the immediate subscription regression by carrying both values in
24
+ Harper's audit representation.
25
+ - HarperFast/harper#2411 and HarperFast/harper-pro#790 require replay and replication respectively
26
+ to adopt the origin transaction timestamp through `setTimestamp()` before applying writes.
27
+ - HarperFast/harper#2412 owns the optional second version word in `RecordEncoder`, including its
28
+ encoding, decoding, compatibility gates, and call-site audit.
29
+
30
+ Checked against Harper's source rather than the issue text: `resources/RecordEncoder.ts` decodes the
31
+ value header itself (producing `localTime`, `version` and the metadata flags) and owns every flag bit
32
+ in that word, and `resources/PrimaryRocksDatabase.ts#getEntry()` overrides the base read entirely —
33
+ it calls `super.getSync()`/`super.get()` and reads the encoder's output. So nothing in Harper would
34
+ call a rocksdb-js decoder for the second word. Harper's three `setTimestamp()` call sites
35
+ (`resources/DatabaseTransaction.ts` at the read-transaction, save and retry-replay paths) each set it
36
+ on a freshly constructed transaction before any write, which is what this change makes enforceable.
37
+
38
+ One consequence for #2412 that is not in its call-site audit: `PrimaryRocksDatabase#getEntry()` passes
39
+ the cached entry's `version` as `expectedVersion` to the verification table, and the table keys on the
40
+ value's **first** word. Once `version` becomes the record version, that argument has to become
41
+ `localTime`, or cached reads stop hitting the fast path — and a record version that happened to equal
42
+ the key's current first word would report a stale copy as fresh.
43
+
44
+ ## Approaches considered
45
+
46
+ ### Different layer
47
+
48
+ Teach rocksdb-js to define and decode Harper's second version word. Rejected because Harper's
49
+ `PrimaryRocksDatabase#getEntry()` already overrides the base read path and receives the structured
50
+ entry produced by Harper's `RecordEncoder`; a second decoder in rocksdb-js would duplicate the
51
+ layout and cannot supply Harper's other metadata fields.
52
+
53
+ Reserving Harper's proposed `0x20000` bit in rocksdb-js without decoding it was also considered.
54
+ Rejected because rocksdb-js does not allocate the producer's metadata namespace: Harper already
55
+ defines the other optional-field flags in that word, while rocksdb-js knows `0x10000` only because
56
+ the native verification table gives that specific bit behavior. A bit with no native behavior or
57
+ public rocksdb-js contract belongs beside Harper's encoder and downgrade gate.
58
+
59
+ ### Deeper cause
60
+
61
+ Remove `setTimestamp()` and redesign replay and replication so rocksdb-js always issues a new local
62
+ timestamp. Rejected because Harper's record-to-log lookup and per-origin resume cursor require the
63
+ origin transaction timestamp to remain the transaction-log key on every receiver.
64
+
65
+ A construction-time timestamp option was considered as a safer replacement for the setter. It
66
+ would add another public API and migrate the replay and replication callers, but could not remove
67
+ the existing setter compatibly; therefore it would not eliminate the state it is meant to prevent.
68
+
69
+ ### Do less
70
+
71
+ Make no rocksdb-js change and rely on Harper always calling `setTimestamp()` before staging writes.
72
+ Rejected because the current public API permits a later call, after the producer has encoded the old
73
+ timestamp or the log batch has captured it, which can split one transaction across two identities.
74
+
75
+ For the separate clock-rollback concern, seeding the process clock from the largest durable key
76
+ across the database's transaction logs was considered and rejected on Harper's own log layout.
77
+ `resources/RocksTransactionLogStore.ts` opens one log per origin node — `useLog('local')` for locally
78
+ originated writes and `logById(nodeId)` for each peer — and a receiver deliberately keeps the origin's
79
+ clock as the key in the peer's log (HarperFast/harper-pro#790). A "largest key in the database" seed
80
+ therefore reads adopted peer keys as if they were locally issued: one peer running ahead ratchets this
81
+ node's clock on its next restart, and the node then originates those keys to its own peers. Reading
82
+ the headers only to warn has the same ambiguity — normal peer skew would be reported as a local clock
83
+ rollback. A correct floor has to be told which log is locally originated (a per-log opt-in, or an
84
+ explicit "raise the floor to this value" call Harper makes from its own `local` log), which is a
85
+ different API than the one the issue sketched and is not a prerequisite for the dual-version record
86
+ work.
87
+
88
+ ### Chosen
89
+
90
+ Keep `setTimestamp()` as the adoption API, reject invalid timestamp values, and reject changes once
91
+ the transaction is no longer pending or has staged a write or log entry. Preserve the existing
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
+ flag, and clock-floor-at-open implementation from this PR. The first two belong to Harper's record
95
+ codec; the clock floor addresses a pre-existing restart/clock-rollback concern and, as specified,
96
+ would seed from adopted origin keys as readily as from locally issued ones. The existing
97
+ `latestTimestamp` behavior used to populate new transaction-log segment headers remains unchanged.
98
+
99
+ Document what is true about log keys today, which the issue also asked for: entries are appended in
100
+ commit order rather than timestamp order, an adopted key carries another node's clock, keys are
101
+ unique only within a process, and `findPositionByTimestamp` is a running-maxima seek rather than a
102
+ sorted binary search.
103
+
104
+ ## Verification route
105
+
106
+ The existing transaction tests cover all transaction modes, invalid inputs, post-write and
107
+ post-dispatch rejection, log-entry staging, and a coordinated retry whose log batch is already
108
+ durable. README and AGENTS documentation will state the enforceable transaction-layer rule and the
109
+ producer-owned record boundary. The repository build, native tests, full Vitest suite, and checks
110
+ will run before push.
111
+
112
+ ## Alternatives rejected
113
+
114
+ Freezing the timestamp on the first `getTimestamp()` call would break existing documented and tested
115
+ read-then-override behavior. A getter is observational for callers that do not encode its result,
116
+ and making every read a state transition still could not prove that a producer's record bytes match
117
+ the transaction timestamp. The enforced boundary therefore remains the first native staging event;
118
+ producer ordering is documented explicitly.
119
+
120
+ Retaining the removed clock-floor branch's far-future header bound would change the append path using
121
+ a ten-year policy introduced only for that floor. This change instead restores `latestTimestamp` and
122
+ segment-header behavior exactly to `origin/main`; any provenance-aware clock rollback design belongs
123
+ in separate work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harperfast/rocksdb-js",
3
- "version": "2.8.0",
3
+ "version": "2.9.1",
4
4
  "description": "RocksDB binding for Node.js",
5
5
  "homepage": "https://github.com/HarperFast/rocksdb-js",
6
6
  "bugs": [
@@ -32,38 +32,38 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@harperfast/extended-iterable": "1.0.3",
36
- "msgpackr": "2.0.6",
37
- "ordered-binary": "1.6.1"
35
+ "@harperfast/extended-iterable": "^1.0.3",
36
+ "msgpackr": "^2.0.6",
37
+ "ordered-binary": "^1.6.1"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@rollup/plugin-replace": "6.0.3",
41
- "@types/node": "26.2.0",
41
+ "@types/node": "26.5.0",
42
42
  "@vitest/coverage-v8": "4.1.11",
43
43
  "cross-env": "10.1.0",
44
44
  "dotenv": "17.4.2",
45
- "lefthook": "2.1.10",
45
+ "lefthook": "2.1.12",
46
46
  "lmdb": "3.5.6",
47
- "node-gyp": "13.0.1",
48
- "oxfmt": "0.64.0",
49
- "oxlint": "1.79.0",
47
+ "node-gyp": "13.0.2",
48
+ "oxfmt": "0.67.0",
49
+ "oxlint": "1.82.0",
50
50
  "prebuildify": "6.0.1",
51
51
  "semver": "7.8.5",
52
52
  "tar": "7.5.22",
53
- "tsdown": "0.22.14",
53
+ "tsdown": "0.23.0",
54
54
  "tslib": "2.8.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.8.0",
60
- "@harperfast/rocksdb-js-darwin-x64": "2.8.0",
61
- "@harperfast/rocksdb-js-linux-arm64-glibc": "2.8.0",
62
- "@harperfast/rocksdb-js-linux-arm64-musl": "2.8.0",
63
- "@harperfast/rocksdb-js-linux-x64-glibc": "2.8.0",
64
- "@harperfast/rocksdb-js-linux-x64-musl": "2.8.0",
65
- "@harperfast/rocksdb-js-win32-arm64": "2.8.0",
66
- "@harperfast/rocksdb-js-win32-x64": "2.8.0"
59
+ "@harperfast/rocksdb-js-darwin-arm64": "2.9.1",
60
+ "@harperfast/rocksdb-js-darwin-x64": "2.9.1",
61
+ "@harperfast/rocksdb-js-linux-arm64-glibc": "2.9.1",
62
+ "@harperfast/rocksdb-js-linux-arm64-musl": "2.9.1",
63
+ "@harperfast/rocksdb-js-linux-x64-glibc": "2.9.1",
64
+ "@harperfast/rocksdb-js-linux-x64-musl": "2.9.1",
65
+ "@harperfast/rocksdb-js-win32-arm64": "2.9.1",
66
+ "@harperfast/rocksdb-js-win32-x64": "2.9.1"
67
67
  },
68
68
  "engines": {
69
69
  "node": "^22.18.0 || >=24.0.0"
@@ -76,7 +76,7 @@
76
76
  "bench": "cross-env CI=1 node --expose-gc ./node_modules/vitest/vitest.mjs bench --passWithNoTests --outputJson benchmark-results.json",
77
77
  "bench:bun": "cross-env CI=1 bun --bun bench",
78
78
  "bench:deno": "cross-env CI=1 deno run --allow-all --sloppy-imports ./node_modules/vitest/vitest.mjs bench",
79
- "build": "pnpm build:bundle && pnpm rebuild",
79
+ "build": "pnpm build:bundle && pnpm run rebuild",
80
80
  "build:binding": "node-gyp build",
81
81
  "build:binding:debug": "node-gyp build --coverage --debug --verbose",
82
82
  "build:bundle": "node scripts/clean-dist.mjs && tsdown -c tsdown.config.ts",