Skip to content

Dedicated per-database commit thread for async transaction commits#694

Draft
kriszyp wants to merge 3 commits into
mainfrom
kris/commit-thread
Draft

Dedicated per-database commit thread for async transaction commits#694
kriszyp wants to merge 3 commits into
mainfrom
kris/commit-thread

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 8, 2026

Copy link
Copy Markdown
Member

Summary

Async Transaction.commit() no longer posts a napi_create_async_work item to
the libuv threadpool for each commit. Instead, every database gets a dedicated
commit thread
(owned by its DBDescriptor) that runs the txn-log writeBatch

  • RocksDB Commit() + commitFinished, and marshals completion back to the
    originating env via a per-env threadsafe function.

Each async commit used to hold one of the (default 4) libuv slots for the entire
commit — slots shared with async get() on block-cache miss, fs, dns, and
crypto. Under slow commits (write stalls, large values/blobs, txn-log
contention) a write burst pins all the slots and starves exactly that unrelated
async work — the degraded-mode behavior seen in real incidents. Moving commits
onto a dedicated per-DB thread removes that coupling categorically.

Modes (ROCKSDB_JS_COMMIT_THREAD)

  • unset / default — single lane: one commit thread per database runs the
    log write and RocksDB commit back to back per transaction, in dispatch order.
  • 0 / false — legacy: the previous libuv async-work path (kill switch;
    same build gives a clean A/B).
  • 2 — two-lane pipeline: a transaction-log lane writes the log batch,
    then forwards to the commit lane, letting the stages overlap across
    transactions. Measured slower than single-lane on synthetic loads (the
    per-txn handoff outweighs the overlap for small commits — see benchmarks),
    but kept selectable for evaluation on real workloads, where per-lane queue
    depths also show which stage bottlenecks.

The commit path is split into executeLogWork / executeCommitWork stages
shared by all modes, and the worker drains its whole queue per wakeup — the
structural hook for a future aggregated-writev of pending log batches (in
either mode).

Why one thread per database (not a pool)

  • Preserves stable per-DB commit order (the replication transaction log assigns
    logSequenceNumber and records the RocksDB seq in commit order).
  • Serializes the txn-log writeMutex for free.
  • Measured throughput parity-or-better; RocksDB group-commit only matters for
    WAL-fsync'd loads (WriteOptions.sync is never set → false).
  • Deadlock-safe: RocksDB commits never block on one another's locks (pessimistic
    locks are taken at put time; optimistic validation doesn't block).

Env-teardown safety

The commit thread is owned by the shared descriptor and outlives any single env,
so a worker env can be torn down (worker.terminate()) while its commits are
still queued/executing. Completion tsfns are therefore held on the descriptor,
keyed by env, under commitMutex
, and a dying env's tsfn is released from the
module env-cleanup hook (DBRegistry::ReleaseCommitCompletionsByEnv) — which
Node runs before it frees that env's tsfns. The commit thread calls the tsfn
under the same mutex, so it either delivers safely or observes the entry gone
and drops the completion. This is the same discipline EventEmitter::notify
uses for HarperFast/harper#1370; a per-commit napi_acquire_threadsafe_function
does not close the window (env teardown doesn't honor the acquire count).

Regression test: test/commit-teardown.test.ts (worker fires in-flight commits,
parent terminates it mid-flight). A ROCKSDB_JS_COMMIT_DELAY_MS seam widens the
window so it reproduces deterministically. Verified load-bearing: disabling only
the cleanup-hook release aborts/segfaults every run; restored, it is clean.

Observability

Two new gauges in db.getStats() / db.getStat() (documented in
docs/stats.md): commitPipeline.commitQueueDepth and
commitPipeline.logQueueDepth. Commit threads are named (rocksdb-commit,
rocksdb-txnlog) via a new setThreadName platform helper.

Benchmarks (same build A/B/C, pool=4, withLog)

Concurrent commit throughput (commits/s):

K legacy (=0) single lane (default) two-lane (=2)
1 (serial await) 56.4k 60.8k 52.7k
4 102.2k 111.7k 101.2k
16 102.4k 110.8k 102.8k
64 105.0k 110.6k 102.1k

Heavy starvation leg (K=16 chains, 200 puts × 4 KB + txn log, probes during the
burst):

during burst legacy single lane
fs.stat p50 15.1 ms 0.12 ms (~125×)
fs.stat p99 223.7 ms 2.6 ms
pbkdf2 p50 19.4 ms 5.3 ms
commits/sec 804 844

Small serial commit breakdown: async 1-put 64.5k → 74.7k/s (+16%), with
txn-log 54.1k → 62.3k/s (+15%); commitSync and direct putSync unchanged.

The two-lane numbers are why single-lane is the default: the per-txn cross-lane
handoff (~3–5 µs mutex + wakeup) exceeds the log∥commit overlap for small
commits, and heavy commits showed no overlap win either.

Tests

  • Node full suite: passing with the default, ROCKSDB_JS_COMMIT_THREAD=0,
    and ROCKSDB_JS_COMMIT_THREAD=2.
  • Bun and Deno full suites, pnpm test:stress, pnpm check (type/lint/fmt): green.
  • New: test/commit-teardown.test.ts (worker env teardown with in-flight
    commits, all modes).

Where to focus review

The TSFN lifecycle and close/teardown ordering are the risk surface:
DBDescriptor::{register,dispatch,finish,release}CommitCompletion +
finishClose() + the binding.cpp env-cleanup hook, and
CommitWorker::enqueue/shutdown. A cross-model review (Codex + Claude domain
pass; Gemini leg failed to run) already drove four fixes now in the diff:
env-gone drop paths close the txn handle (no orphaned rocksdb::Transaction
accumulation on the shared descriptor), executeCommitWork fails the commit
instead of silently resolving success if the handle is torn out mid-pipeline,
a commitCompletionsClosed flag stops a commit racing finishClose from
re-creating an unreleasable tsfn (falls back to the legacy path), and
DestroyDB now closes before unlinking the registry entry (CloseDB's
discipline) so env-cleanup hooks can always find the descriptor.

Open (known, deliberately out of scope):

  • Narrow residual: OpenDB's wait predicate resets entry.descriptor while a
    close is in flight, so an env-cleanup hook in exactly that window can miss
    the descriptor (backstopped by finishClose's release pass, which runs
    after the commit thread is joined). Shared with the pre-existing listener
    cleanup path — follow-up issue rather than this PR.
  • Pre-existing (surfaced by the review, unchanged semantics): commitFinished
    finalizes the txn-log entry when RocksDB Commit() returns a hard non-Busy
    error — replication-relevant, filed as a follow-up issue.

Strained-database validation

The tables above are from an unstrained DB where the JS thread is the
bottleneck (per-thread CPU: JS ~101% in every mode, commit thread ≤71%). The
mode ranking was therefore re-validated on a 46 GB database (9.3M keys,
mixed 1–8 KB values) under sustained load that accumulates real compaction
debt during measurement (1–2.6 GB pending compaction, up to 8 concurrent
compactions, tens of seconds of cumulative write-stall time; JS thread drops
to ~50% — the native side becomes the bottleneck). 120 s legs, P=16, pool=4,
identical pristine DB copy per leg:

leg avg commits/s worst 15s window worst fs.stat p99 (libuv probe)
legacy (=0) 39.3k 36.4k 51 ms
single lane (default) 43.7k (+11%) 36.7k 3.4 ms (15×)
two-lane (=2) 34.2k 23.2k 12.7 ms

The single-lane default wins throughput in both regimes, and the starvation
elimination holds on real strain — during write-stall windows legacy commits
pin the libuv pool (fs.stat p99 ~50 ms) while the dedicated thread leaves it
untouched. Two-lane loses in both regimes (the per-txn handoff compounds
under load); it stays selectable for real-workload evaluation but the
synthetic evidence is consistently against it.

Context

Root-cause fix for Harper write-path libuv starvation. Related follow-up
issues: #691 (dedicated read thread pool), #692 (RocksDB Merge operator for
commutative increments). A stacked PR adds commitMany (batched JS↔native
barrier, full per-txn isolation preserved).

Library-repo docs updated in-tree (docs/stats.md, AGENTS.md); no
HarperFast/documentation PR needed (no Harper-facing surface).

Generated by an LLM (Claude Fable 5), driven and reviewed by Kris.

🤖 Generated with Claude Code

kriszyp and others added 2 commits July 8, 2026 10:39
Async Transaction.commit() no longer posts one napi_create_async_work item
(libuv threadpool slot) per commit. Each database's DBDescriptor owns a
dedicated commit thread that runs the txn-log writeBatch + RocksDB commit +
commitFinished in dispatch order, so slow commits (write stalls, large
batches, txn-log contention) cannot occupy libuv slots and starve unrelated
async work (fs, dns, crypto, async gets). Measured: fs.stat p50 during a
heavy write burst 15.1ms -> 0.12ms at pool=4, with commit throughput at
parity or better in every leg.

ROCKSDB_JS_COMMIT_THREAD selects the mode: 0/false = legacy libuv path
(kill switch, same build A/B), default = single lane, 2 = experimental
two-lane txnlog->commit pipeline (kept selectable for real-workload
evaluation; slower than single lane on synthetic loads). The commit path is
split into executeLogWork/executeCommitWork stages shared by all modes, and
the worker drains its whole queue per wakeup (future aggregated-writev hook).

Completions are marshalled to the originating env via per-env tsfns held on
the descriptor under commitMutex; the commit thread calls them under that
mutex and a dying env's tsfn is released from the module env-cleanup hook
(DBRegistry::ReleaseCommitCompletionsByEnv), the same env-teardown
discipline as EventEmitter::notify (a per-commit tsfn acquire is not
sufficient - env teardown does not honor acquire counts). Regression test:
test/commit-teardown.test.ts with the ROCKSDB_JS_COMMIT_DELAY_MS seam;
crashes 3/3 without the cleanup-hook release.

Also adds commitPipeline.logQueueDepth/commitQueueDepth gauges to
db.getStats(), named commit threads (rocksdb-commit, rocksdb-txnlog) via a
new setThreadName platform helper, and docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Env-gone drop paths (worker terminate with commits in flight) now close
  the transaction handle so the shared descriptor does not accumulate
  orphaned RocksDB transactions until database close; close() is
  cross-thread safe (skips napi_delete_reference off the owning thread).
- executeCommitWork fails the commit (Aborted) instead of silently
  resolving success when the handle/descriptor is torn out between the
  stages (reachable via DBHandle::close()'s timed-out async-work wait).
- registerCommitCompletion refuses (commitCompletionsClosed, set under
  commitMutex by finishClose's release pass) once the descriptor's
  completion plumbing has shut down; a commit racing another env's close
  falls back to the legacy libuv path instead of re-creating a tsfn that
  would never be released (which pinned that env's event loop forever).
- DestroyDB now claims the descriptor (beginClose) and leaves the registry
  entry in place until finishClose completes, mirroring CloseDB, so the
  env-cleanup hooks can always reach the descriptor and a concurrent
  OpenDB waits instead of re-opening a path whose files are being
  destroyed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

get-sync.bench.ts

getSync() > random keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.42K ops/sec 40.95 39.70 556.252 0.076 122,100
🥈 rocksdb 2 11.50K ops/sec 86.97 83.81 4,562.48 0.182 57,492

getSync() > sequential keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.51K ops/sec 35.07 33.88 739.832 0.106 142,563
🥈 rocksdb 2 12.20K ops/sec 81.97 79.70 590.34 0.051 60,999

ranges.bench.ts

getRange() > small range (100 records, 50 range)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 25.89K ops/sec 38.62 35.47 1,606.285 0.282 129,470
🥈 rocksdb 2 16.56K ops/sec 60.38 52.37 1,125.618 0.121 82,803

realistic-load.bench.ts

Realistic write load with workers > write variable records with transaction log

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 400.97 ops/sec 2,493.954 91.41 27,452.919 7.12 803
🥈 lmdb 2 26.18 ops/sec 38,198.333 419.451 1,204,552.718 136.841 64.00

transaction-log.bench.ts

Transaction log > read 100 iterators while write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 38.17K ops/sec 26.20 11.78 13,530.578 0.572 190,835
🥈 lmdb 2 441.38 ops/sec 2,265.601 154.132 32,260.36 1.65 2,207

Transaction log > read one entry from random position from log with 1000 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 722.87K ops/sec 1.38 1.19 4,129.404 0.189 3,614,393
🥈 lmdb 2 460.26K ops/sec 2.17 1.16 3,012 0.310 2,301,284

worker-put-sync.bench.ts

putSync() > random keys - small key size (100 records, 10 workers)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 835.70 ops/sec 1,196.597 1,020.134 1,889.729 0.309 1,672
🥈 lmdb 2 1.16 ops/sec 865,206.529 817,389.143 892,021.024 1.89 10.00

worker-transaction-log.bench.ts

Transaction log with workers > write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 22.68K ops/sec 44.09 29.50 573.588 0.579 45,359
🥈 lmdb 2 801.74 ops/sec 1,247.288 265.357 15,469.663 5.59 1,605

Results from commit 22c78bc

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a dedicated, per-database commit pipeline (CommitWorker) for async transaction commits to prevent libuv threadpool starvation, including an experimental two-lane pipeline mode and robust lifecycle management for thread-safe function (tsfn) completions during environment teardown. The review feedback suggests truncating thread names to 15 characters on Linux to prevent pthread_setname_np from failing with ERANGE, and using 'bun' in process.versions instead of direct property access to avoid TypeScript compilation errors under strict type checking.

Comment thread src/binding/core/platform.cpp Outdated
Comment thread test/commit-teardown.test.ts Outdated
Comment thread test/fixtures/fork-commit-teardown.mts Outdated
…nux thread names

- The teardown test's 40 rounds x 2 iterations exceed the 120s timeout on
  macOS/Windows CI runners (slow worker spawns + the delayed commit backlog
  each round leaves on the shared commit thread). Scale rounds/iterations by
  platform like the notify-teardown test does.
- pthread_setname_np on Linux fails with ERANGE (name not set at all) for
  names over 15 chars; truncate in setThreadName so long names still stick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant