Make a WriteBufferManager write stall observable - #824
Conversation
A WriteBufferManager stall is a second, entirely separate stall mechanism, and
nothing in RocksDB reports it: `DBImpl::WriteBufferManagerStallWrites` parks
writers on the manager's own queue without touching the `WriteController`, so
`rocksdb.stall.micros`, the `WRITE_STALL` histogram and `OnStallConditionsChanged`
(hence `isWriteStalled()` and the `'writeStall'` event) all read 0 for its entire
duration. An eight-hour production wedge was invisible to every one of them.
Two additive surfaces, no write-path behavior change:
- `writeBufferManager.{bufferSize,memoryUsage,mutableMemoryUsage,stallActive,
stallActiveMs}` on `db.getStats()` / `db.getStat()`, and
`RocksDatabase.getWriteBufferManagerStats()` which adds the manager's
configuration and its live column-family inventory. Both read the manager
through an atomic pointer published at construction, so a scrape never takes
a lock or materializes the manager.
- A process-wide watchdog thread, started only while a manager exists with
`allowStall`, that samples `IsStallActive()` once a second and writes one warn
line per stall episode (stderr plus a `log.warn` event) carrying the budget,
usage, mutable share, live column-family count and the effective per-CF
`max_write_buffer_size_to_maintain`. It needs its own thread because every
other tick in the process is blocked by the condition it reports.
Refs #822
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GxngG4PtHJ7BZzYjWi33Jk
Pre-push review round 1 (gemini + harper-domain): - `~DBSettings` now joins the watchdog. `process.exit()` skips N-API env cleanup, and destroying a joinable `std::thread` calls `std::terminate()` — an observability feature must not turn a clean exit into SIGABRT. Mirrors `~CommitWorker`. - A per-start generation counter retires a stale watchdog. `join()` releases `watchdogMutex` before `join()` returns, so a concurrent start could clear `watchdogStopRequested` and leave the retiring thread looping forever with its joiner blocked on it. - The inventory walk takes `databasesMutex` with `try_lock` and reports `inventoryAvailable: false` instead of waiting. `PurgeAll` holds that lock across a close whose flush waits out a write stall, so blocking there would silence the alarm and hang `getWriteBufferManagerStats()` during exactly the incident both exist to report. - The episode is retired when either channel carried the line, not `stderr` alone: with fd 2 closed and a listener attached, the old gate re-reported every second for the whole stall. - A refused `ROCKSDB_JS_WBM_STALL_WARN_MS` now says so on stderr rather than silently falling back to 5s. - docs/stats.md: a blank line detached the five new rows from the table, so GitHub rendered them as literal text. - The stall test asserts the `'log.warn'` event as well as stderr, anchors its `STALLED` sentinel (`NEVER_STALLED` contained it), and cleans up with the same retry/`KEEP_FILES` discipline as its neighbour. Refs #822 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxngG4PtHJ7BZzYjWi33Jk
Pre-push review round 2 (gemini LGTM; harper-domain findings):
- `GlobalEvents`'s emitter is now leaked. Block-scope statics are destroyed in
reverse construction order and it is normally constructed after `DBSettings`,
so on the `process.exit()` path it would be destroyed while `~DBSettings` is
still joining the watchdog — whose report path emits through it.
- `config({ writeBufferManagerAllowStall: false })` now stops the watchdog. It
previously left a permanent 1 Hz thread and reported `watchdogRunning: true`
alongside `allowStall: false`, contradicting the documented lifetime.
- `watchdogRunning` is published when the thread is started rather than when it
is first scheduled, so enabling stalling and reading straight back no longer
reports the watchdog absent.
- Dropped the added comments that restate the identifier below them, and made the
child fixture's `CLEARED` outcome terminal — it previously printed `STALLED`
as well, which the parent reads as "a stall was reached".
Refs #822
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GxngG4PtHJ7BZzYjWi33Jk
Pre-push review round 3 (codex + harper-domain). The `allowStall` falling edge joined the watchdog from inside `Config()`'s `writeBufferManagerMutex` critical section, so a report blocked on a full stderr pipe would hold that lock — wedging the caller's event loop and every concurrent `DBDescriptor::open` behind it. The join now happens after the critical section closes: the same stop/join split, for the same reason, as the teardown path in `binding.cpp`. Refs #822 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxngG4PtHJ7BZzYjWi33Jk
Pre-push review round 4 (codex + gemini) on the previous commit's unlocked join: - Another environment enabling stalling between the mutex release and the join found the retiring thread still started, declined to start one, and was then stopped by the retiring caller — leaving `allowStall: true` with no alarm. The falling edge now re-reads the live setting after joining and restarts if it is still on. - The sample path abandons a report once a stop has been requested, so a joiner waits behind a write already in progress rather than one about to start. Refs #822 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxngG4PtHJ7BZzYjWi33Jk
Pre-push review round 5 raised the reverse question about the exit path: whether `~DBSettings`'s join can run after `DBRegistry`'s static instance is gone, since the watchdog's inventory walk touches it. It cannot — static destructors run in reverse order of construction completion, and the registry's instance is a class static registered before `main` while `DBSettings` is first touched by `config()` — but that is an argument, not a guarantee. Registering the join with `atexit()` at watchdog start makes it one: the handler runs ahead of every destructor registered earlier. `~DBSettings` stays as the backstop; both are idempotent. Refs #822 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxngG4PtHJ7BZzYjWi33Jk
There was a problem hiding this comment.
Code Review
This pull request introduces a process-wide WriteBufferManager stall watchdog to detect and report sustained write stalls that are otherwise invisible to RocksDB's default counters. It adds a background watchdog thread, exposes new APIs and metrics via getWriteBufferManagerStats(), and integrates these metrics into db.getStats(). The feedback suggests replacing Date.now() with performance.now() in the test fixture fork-wbm-stall-watchdog.mts to ensure a monotonic clock is used for measuring deadlines and elapsed time.
📊 Benchmark Resultsget-sync.bench.tsgetSync() > random keys - small key size (100 records)
getSync() > sequential keys - small key size (100 records)
ranges.bench.tsgetRange() > small range (100 records, 50 range)
realistic-load.bench.tsRealistic write load with workers > write variable records with transaction log
transaction-log.bench.tsTransaction log > read 100 iterators while write log with 100 byte records
Transaction log > read one entry from random position from log with 1000 100 byte records
worker-put-sync.bench.tsputSync() > random keys - small key size (100 records, 10 workers)
worker-transaction-log.bench.tsTransaction log with workers > write log with 100 byte records
Results from commit 6994f4e |
The Windows CRT opens stderr in text mode, so the watchdog's `fprintf(stderr, "…\n")` lands as `\r\n` while Node's `console.log` on stdout does not translate. Splitting both on `\n` alone left the stderr line with a trailing `\r`, and the assertion that the `'log.warn'` event carries the same payload compared it against an untranslated copy. Failed on Bun and Deno for windows-latest. Refs #822 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxngG4PtHJ7BZzYjWi33Jk
Gemini's PR review: the child fixture timed its observation window with Date.now(), which a system clock adjustment can move under it. performance.now() is monotonic. Refs #822 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxngG4PtHJ7BZzYjWi33Jk
| * } | ||
| * ``` | ||
| */ | ||
| static getWriteBufferManagerStats(): WriteBufferManagerStats { |
There was a problem hiding this comment.
This exposes a second surface for the same API.
import { RocksDatabase, getWriteBufferManagerStats } from '@harperfast/rocksdb-js';
const wbm1 = RocksDatabase.getWriteBufferManagerStats();
const wbm2 = getWriteBufferManagerStats();
I think we should remove this static method version and be more like registryStatus and only have a top-level export.
| coolTransactionLogs, | ||
| currentThreadId, | ||
| fileLockRelease, | ||
| getWriteBufferManagerStats, |
There was a problem hiding this comment.
getWriteBufferManagerStats() is the only top-level export with the "get" prefix, yet provides similar readonly view as registryStatus() does. For consistency, we should either drop the "get" or add the "get" to registryStatus(). I'm thinking we do the latter. I kinda of like the "get" and I should probably have done it in the first place. Ticket created: #840.
| * walks the database registry, while everything above it is a handful of atomic | ||
| * loads. `getStats()` is a scrape path and takes the cheap half. | ||
| */ | ||
| struct WriteBufferManagerStats final { |
There was a problem hiding this comment.
I don't love putting the WriteBufferManagerStats logic in db_settings. db_settings is for incoming database settings, not APIs returning data and wiring up watchdogs. I think all of this write buffer stats stuff should go in a database/db_stats.* (or whatever) files with it's own singleton and Init().
A
WriteBufferManagerstall is a second, entirely separate stall mechanism from the one RocksDB reports on.DBImpl::WriteBufferManagerStallWritesparks writers on the manager's own queue without touching theWriteController, sorocksdb.stall.micros, theWRITE_STALLhistogram andOnStallConditionsChanged— and thereforedb.isWriteStalled()and the'writeStall'event — all read0for its entire duration. Through an eight-hour production wedge every one of them did, while reads kept working and diagnosis needed gdb.This makes the condition visible. It is additive: no write-path behavior changes, and nothing about
allowStall, WriteBufferManager sizing, or the retained-history resolution (#821) is touched.1. The manager's live state, on two surfaces.
db.getStats()/db.getStat()gain five keys under awriteBufferManager.prefix —bufferSize,memoryUsage,mutableMemoryUsage,stallActive,stallActiveMs— added unconditionally, the same treatment the existingtxnlog.*andcommitPipeline.*keys get, so they appear withenableStats: false. They are routed through the same prefix branch inDBHandle::getStatthose two use, because a single-key read that fell through to the RocksDB statistics path would throw "Statistics are not enabled" during exactly the incident an operator is reaching for it in.RocksDatabase.getWriteBufferManagerStats()returns the same values plus the manager's configuration and its live column-family inventory. Both read the manager through anstd::atomic<WriteBufferManager*>published at construction, so a metrics scrape takes no lock and never materializes the manager as a side effect.These are process-wide values coming back from a per-database call — the manager is a singleton shared by every database in the process,
worker_threadsincluded — which is stated in the docs, the JSDoc, and the stat-name reference.2. A watchdog that logs one warn line per stall episode, carrying the budget, usage, mutable share, live column-family count and the effective per-CF
max_write_buffer_size_to_maintain— effective, not requested, because #821's whole finding is thatTransactionDB::Openrewrites a requested0into 256 MiB per column family, so the requested value would hide the fact the line exists to expose. Every one of those was needed to explain the wedge and every one required the RocksDBLOGor a debugger.It owns a thread because every other tick in the process is blocked by the condition it reports:
CommitWorkerparks indb->Write()(therocksdb-commitlanes are in #822's backtrace),logWorkeris event-driven off commits the stall prevents,ParkTimeoutRegistry's thread is per-descriptor and only exists after a VT conflict, RocksDB's stall callbacks never fire, and a JS timer cannot run on a thread parked instore.putSync(). One thread per process, started lazily only while a manager exists withallowStall(ShouldStall()short-circuits otherwise, so no stall is reachable and no thread is started), sampling one relaxed atomic per second.Three lifecycle constraints on that thread are the places to look hardest, each of which the review found the hard way:
databasesMutex -> writeBufferManagerMutex -> watchdogMutex, becauseDBRegistry::OpenDBholds the first acrossDBDescriptor::open. So the start path never joins a retiring thread — the thread it would join may be waiting for exactly that lock — and a per-start generation counter retires stale threads instead.databasesMutexand reportsinventoryAvailable: falserather than waiting:PurgeAllholds that lock across a close whose flush waits out a write stall (AGENTS.md note 16), so blocking there would silence the alarm and hanggetWriteBufferManagerStats()during the incident both exist to report.allowStallfalling edge so a report blocked on a fullstderrpipe holds no lock any database open needs. The falling edge then re-reads the live setting, because a concurrent re-enable in that unlocked window would otherwise be left with stalling on and no alarm behind it.~DBSettingsand anatexithandler registered at watchdog start both join, so theprocess.exit()path (which skips the module cleanup hook) neither destroys a joinable thread nor outlives the registry; the global event emitter is leaked for the same ordering reason.The decision FSM itself is Node-free and GoogleTest-covered, so the sampling logic is proved without threads or RocksDB.
For the human reviewer
The step-6 planning gate ran before any code and cleared:
Framing-Verdict: chosen-approach-sound. Six pre-push review rounds followed (gemini + codex + harper-domain); the decisions below are the ones a reviewer should weigh rather than defects left open.Decisions taken, each reversible:
stderras well as the'log.warn'event. Harper registers nolog.warnlistener today, so an event-only line reaches nothing without a Harper change. The cost is unstructured output a log pipeline cannot format; route the event and ignorestderrif you would rather have it structured. @kriszyp ruled on this, and corrected the rationale I had offered for it: WBM stalls have not been observed freezing Harper's JS threads (noputSyncon active paths; the observed symptom is every thread complaining about slow commits), so the load-bearing argument forstderris the missing listener, not a blocked event loop. A direct consumer of this library's synchronous write path can still block its own loop, which is why the sampler still cannot be a JS timer.getStats(). That is what makes a stall visible in Harper'ssystem_informationwith no Harper change. A scraper summing them across databases will N-count; the alternative was leaving them only on the static accessor, where nothing scrapes them.ROCKSDB_JS_WBM_STALL_WARN_MS, read once per process), not aconfig()knob like every other WriteBufferManager setting. Adding the knob later is additive.0disables the watchdog thread entirely, and with itstallActiveMs— silencing the log also silences that gauge, because only the thread populates it.stderrinstead of doing it silently.columnFamilieswill not reconcile with a count of open handles.Accepted residuals, all raised by the review and deliberately not fixed:
config({ writeBufferManagerAllowStall: false })joins the retiring watchdog synchronously. The join is outsidewriteBufferManagerMutex(so it blocks no database open) and the sample path abandons a report once a stop is requested, but a report already inside itsstderrwrite is unbounded on a full pipe. The same unbounded wait already exists on the teardown path, where the split stop/join keeps it behind the flush rather than in front of it.atexit()registration fails, the watchdog keeps running and~DBSettingsis the backstop join — it is not the only pre-destruction join, so the failure is not silent in the way the finding describes.test/native/wbm_stall_watchdog_test.cc; reaching a recovering stall end-to-end needs a scenario whose retained history can drain, which this one deliberately cannot.Not implemented, by instruction: issue item 3 (
SetAllowStall(false)after N seconds to release queued writers). The hook is now obvious — the watchdog is the only thing in the process that knows how long a stall has lasted, so a bounded escape hatch is a second threshold in the same FSM — but it converts a hang into unbounded memtable growth, which is a maintainer's call.A finding the task anticipated that did not hold. The context expected that with
atomic_flush(which this library always sets) a WBM-pressure flush is recorded underATOMIC_FLUSH_REQUEST_REASON_WRITE_BUFFER_MANAGERinstead ofFLUSH_REASON_WRITE_BUFFER_MANAGER, and asked for adocs/stats.mdcorrection if so. Measured here, both counters move: 13/13 with one column family, roughly 1:2 with two to six, and requests can outnumber executed flushes when several databases share a manager. Sorocksdb.flush.reason.write_buffer_manageris not the wrong counter, and the production0was genuine — no manager-pressure flush was ever requested, because the trigger looks only at mutable memory. The docs carry the measured relationship rather than the anticipated correction; writing the anticipated note would have shipped a false statement.Verification
pnpm test, Node 26 / Linux). Three earlier full runs had different files time out each time — all passed in isolation, on a box running several agents concurrently.pnpm test:native), including 14 new ones covering the watchdog FSM (rising edge, threshold crossing, one-shot, retry-until-acknowledged, recovery re-arm, flapping, threshold0), the env-var parse (clamp, reject, out-of-range) and the report formatter.'log.warn'event carrying the same line once, and — from the child's main thread while a worker is blocked inputSync—stallActive, a risingstallActiveMs, and agreement betweengetWriteBufferManagerStats(),getStats()andgetStat().process.exit()with a live watchdog exits 0, verified directly; without the join it isstd::terminate()by construction.allowStallruntime edges start and stop the watchdog, verified by test and by hand (on → off → back on).pnpm check(type-check, lint, format) clean.Refs #822
🤖 Generated with Claude Code
https://claude.ai/code/session_01GxngG4PtHJ7BZzYjWi33Jk
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=8 @ 0adf538
Human-Review-Need: 3 @ 0adf538