Skip to content

Expose RocksDB WriteBufferManager with cost-to-cache support - #584

Merged
cb1kenobi merged 4 commits into
mainfrom
feat/write-buffer-manager
May 26, 2026
Merged

cb1kenobi merged 4 commits into
mainfrom
feat/write-buffer-manager

Conversation

@kriszyp

@kriszyp kriszyp commented May 26, 2026

Copy link
Copy Markdown
Member

Adds a process-wide WriteBufferManager singleton in DBSettings, lazy-created on first DB open and shared across every database in the process. Memtable memory is bounded by a single configurable limit rather than each DB managing its own budget. With costToCache: true, memtable charges show up as pinned entries in the block cache, giving a single observability metric for read cache + memtable footprint.

Why

In Harper Fabric's shared-host deployment, each container opens many RocksDB databases (one per Harper user database/system DB). Memtable memory accumulates per-DB and per-column-family — for a customer like Canopy with 14 tables, the maintain window alone (max_write_buffer_size_to_maintain × CFs) can account for ~1.8 GB of resident anonymous memory. Today there's no process-level cap. WriteBufferManager fixes that.

What this changes

New RocksDatabase.config() options:

Option Type Default Description
writeBufferManagerSize number 0 Bytes the WBM caps across all DBs in the process. 0 disables. Runtime-mutable via SetBufferSize.
writeBufferManagerCostToCache boolean false Charge memtable bytes against the shared block cache. Fixed at first WBM creation.
writeBufferManagerAllowStall boolean false Stall writes once the cap is reached. Runtime-mutable via SetAllowStall.

Implementation notes

  • DBSettings::getInstance() now uses C++11 magic statics — the previous unique_ptr lazy-init raced on concurrent first-call from libuv worker threads. Independent of WBM, this also fixes a pre-existing risk.
  • WBM configuration fields are std::atomic so JS-thread Config() writes are safe against worker-thread reads in getWriteBufferManager().
  • WBM creation is mutex-protected with double-checked locking on the atomic size.
  • costToCache is fixed at first creation (already-open DBs hold shared_ptr refs; the cache reservation manager is configured at construction). Subsequent attempts to change it emit a stderr warning.
  • allowStall IS updateable on a live WBM via SetAllowStall — Config() propagates runtime changes.
  • Setting writeBufferManagerSize = 0 at runtime means "no new attachments," not teardown. RocksDB asserts SetBufferSize(new_size > 0).

What's verified by tests

The 11 tests cover the structural claims and document the limitations:

  • ✓ Negative size rejected with RangeError
  • ✓ Default-off path still works (no WBM attached, writes succeed)
  • ✓ WBM attaches to opened DBs; multiple DBs share one WBM
  • ✓ Runtime resize via SetBufferSize doesn't disrupt writes
  • ✓ Active memtable memory IS reclaimed immediately after flush — cur-size-active-mem-table drops by 10x
  • ✓ With costToCache, memtable charges show up in block-cache-usage (≥1 MB delta for 2 MB writes)
  • ✓ WBM charges appear as pinned entries (the cache cannot LRU-evict them)
  • ✓ The structural cap on memtable memory is writeBufferManagerSize, NOT blockCacheSize
  • ✓ Cache usage grows with memtable bytes (visible to operators via a single property)
  • ✓ Older WBM charges are released when newer memtables push them out of RocksDB's maintain window

Honest caveats (covered in test comments)

  • The "memory returns dynamically when load drops" claim is partial. Active memtable charges release on flush; maintain-window-pinned memtables release only when newer writes push them out. With OptimisticTransactionDB defaults, ~128 MB / CF stays pinned until churned.
  • OS-level RSS reduction is glibc-dependent. RocksDB's accounting drops correctly; whether glibc returns memory to the kernel depends on arena fragmentation. Tests assert against RocksDB properties, not RSS.

Cross-model review findings addressed

Gemini — flagged the singleton race in getInstance() (fixed: magic statics) and data races on the WBM config fields (fixed: std::atomic + mutex). Suggested per-DB opt-out which is left for a follow-up.

Codex — caught that allowStall changes after WBM creation were silently dropped (fixed: now propagates via SetAllowStall).

Related

Complements #575 (per-CF write buffer options) — that PR exposes the per-CF knobs, this one adds the process-wide cap that makes them safe to tune up.

Generated by Claude. — Claude Sonnet 4.7

Adds a process-wide WriteBufferManager singleton in DBSettings, lazy-
created on first DB open and shared across every database in the
process. Memtable memory is bounded by a single configurable limit
rather than each DB managing its own budget.

New `RocksDatabase.config()` options:
- `writeBufferManagerSize` — bytes of memtable memory the WBM caps
  across all DBs (0 disables); runtime-mutable via SetBufferSize
- `writeBufferManagerCostToCache` — when true, memtable charges show
  up in `block-cache-usage` as pinned entries, giving a single
  observability metric for read cache + memtable footprint
- `writeBufferManagerAllowStall` — when true, writes stall once the
  cap is reached instead of letting memtables grow past it

Implementation notes:
- DBSettings::getInstance() now uses C++11 magic statics for thread-
  safe lazy initialization (previously raced on concurrent first-call
  from libuv worker threads).
- Configuration fields are `std::atomic` so JS-thread Config() writes
  are safe against worker-thread reads in getWriteBufferManager().
- WBM creation is mutex-protected; double-checked locking on the
  atomic size guards against teardown between load and lock.
- `costToCache` and `allowStall` are fixed at first WBM creation
  (already-open DBs hold shared_ptr refs; the WBM can't be swapped).
  Config() writes after creation are ignored with an stderr warning.
- Setting size=0 at runtime is a "no new attachments" signal, not a
  teardown — RocksDB asserts SetBufferSize(>0).

Verified by 11 tests covering: negative-size rejection, default-off
path, costToCache wiring, multi-DB sharing, runtime resize, active
memtable reclamation after flush, block-cache-usage charge visibility,
the actual structural cap (writeBufferManagerSize, not blockCacheSize),
and maintain-window cycling.

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
@kriszyp
kriszyp requested a review from cb1kenobi May 26, 2026 04:13
@github-actions

github-actions Bot commented May 26, 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.80K ops/sec 40.33 38.87 658.624 0.115 123,981
🥈 rocksdb 2 11.73K ops/sec 85.25 81.92 22,088.233 0.873 58,654

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.75K ops/sec 34.78 33.61 1,996.725 0.124 143,755
🥈 rocksdb 2 11.71K ops/sec 85.38 82.89 642.986 0.057 58,561

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.11K ops/sec 39.82 36.14 1,902.53 0.290 125,556
🥈 rocksdb 2 17.38K ops/sec 57.55 50.92 2,218.061 0.150 86,876

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 488.30 ops/sec 2,047.925 70.18 54,232.927 16.15 977
🥈 lmdb 2 26.07 ops/sec 38,359.327 392.593 1,216,476.391 136.228 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 35.54K ops/sec 28.14 13.77 13,273.486 0.558 177,701
🥈 lmdb 2 443.97 ops/sec 2,252.416 197.274 25,200.228 1.49 2,220

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 670.96K ops/sec 1.49 1.31 4,551.103 0.189 3,354,794
🥈 lmdb 2 469.80K ops/sec 2.13 1.19 8,261.039 0.518 2,349,006

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 838.47 ops/sec 1,192.642 1,043.917 1,814.295 0.300 1,677
🥈 lmdb 2 1.11 ops/sec 899,149.113 825,269.587 955,042.121 2.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 21.56K ops/sec 46.38 30.31 425.605 0.496 43,119
🥈 lmdb 2 836.81 ops/sec 1,195.011 54.01 9,286.818 5.75 1,674

Results from commit 3a404d1

instance = std::unique_ptr<DBSettings>(new DBSettings());
}
return *instance;
static DBSettings instance;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Cool!

Comment thread src/binding/database/db_settings.cpp Outdated

// costToCache can't be retro-applied; warn if the user attempts it.
if (wbmAlreadyCreated && costToCacheProvided && newCostToCache != oldCostToCache) {
std::fprintf(stderr,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A library should not write to stdio. Instead, we can throw and the user can retry the config() call. In order to throw, we need to reorder this function as to do this check before mutating any state. I've got a PR that targets the branch to show what I'm talking about: #585.

@cb1kenobi
cb1kenobi merged commit e1819ac into main May 26, 2026
41 of 42 checks passed
@cb1kenobi
cb1kenobi deleted the feat/write-buffer-manager branch May 26, 2026 22:19
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.

2 participants