Keep the WriteBufferManager stall safeguard across a reopen - #823
Draft
kriszyp wants to merge 10 commits into
Draft
Keep the WriteBufferManager stall safeguard across a reopen#823kriszyp wants to merge 10 commits into
kriszyp wants to merge 10 commits into
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request fixes a critical issue where the memtable history retention safeguard resolved to 0, which was rewritten back to its default (256MB) by RocksDB's transaction wrappers upon database reopen, leading to permanent write stalls. The safeguard has been updated to resolve to 1 instead of 0, and explicit 0 targets are now normalized to 1. Additionally, the PR introduces warnings for configurations exceeding the WriteBufferManager budget or when stalling is enabled after database initialization, and adds comprehensive regression tests. I have no further feedback to provide as there are no review comments.
Contributor
📊 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 2774046 |
`resolveMaxWriteBufferSizeToMaintain` dropped the derived retained-history target to 0 under a stalling WriteBufferManager (#755), but 0 is the one value RocksDB's transaction wrappers reinterpret: `OptimisticTransactionDB::Open` and `TransactionDB::PrepareWrap` both rewrite it to -1, which `SanitizeOptions` expands to `max_write_buffer_number * write_buffer_size` — 256MB per column family. Only families created after an open kept the 0, so the safeguard held on a fresh database and was undone by every restart, which is the permanent stall seen in production (#821, HarperFast/harper#2490). Resolve to 1 instead, the smallest target that survives the rewrite, and normalize an explicit 0 for the same reason — passing it through hands the caller who asked for the least history the most. A positive target still retains the newest flushed memtable per family until that family's next write, so `DBDescriptor::open` also reports a configuration whose known families already reach the budget on the `log.warn` channel, and enabling `allowStall` at runtime warns that already-open families keep their open-time target. The regression runs the reopen in a child process the parent kills on a deadline: a stalled `putSync` blocks the JS thread, so the failure is a hang no in-process timer can surface. Refs #821 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wo1B1gMcEHGrR2F6bsCmX
The manager is a native process-global and Vitest's `threads` pool runs every file in one process, so whichever of write-buffer-manager-stall.test.ts and write-buffer-manager.test.ts runs first creates the singleton for both. `costToCache` is the one setting `config()` refuses to change afterwards, so the files disagreeing on it made the second one throw in its `beforeAll` whenever the file order put the stall file first — which growing that file did. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wo1B1gMcEHGrR2F6bsCmX
…et, coverage - The reopen fixture now runs at the shipped 16MB `writeBufferSize` default with a 128MB budget over four families, so it exercises the real pre-fix target (16 * 16MB = 256MB per family) instead of a scaled-down one. Verified red on the base commit: all three reopen cases SIGKILLed at the parent's deadline. - Reset `writeBufferManagerAllowStall` when the stall file finishes. The manager is a native process-global and Vitest's `threads` pool shares one process, so leaving the stall on made later files open under it — and under a stalling manager the resolver clamps their retained-history window, which broke write-buffer-manager.test.ts's memory-reclamation assertions whenever the file order put the stall file first. - Cover the late-`allowStall` warning, in a child process per arm because only a live false -> true transition on a process-global manager warns. - Say in the quiet-at-the-safeguard-target test that it is the guard's blind spot: what a family retains at that target is one flushed memtable, which the family-count times target comparison does not see. - Drop the incident narration from the new comments, keep the RocksDB mechanics. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wo1B1gMcEHGrR2F6bsCmX
30s had to cover node boot, addon load and 160MB of writes; the property that matters is that a regression fails bounded rather than hanging the job, so 60s buys robustness without giving that up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wo1B1gMcEHGrR2F6bsCmX
The clamp was gated on the manager also being a stalling one, which cannot hold: `writeBufferManagerAllowStall` is mutable at runtime — `DBSettings::Config` propagates it through `SetAllowStall` — while `max_write_buffer_size_to_maintain` is an immutable ColumnFamilyOptions field fixed when a column family is created. Every family opened before the switch therefore stayed at the derived 256MB and no later configuration could lower it. A non-stalling manager needs the bound anyway. Without `allowStall` the same retained history is simply never reclaimed, so the budget stops bounding the memtable memory it exists to bound — measured at ~3GB of retained history on the affected deployment's hot families while running with the stall disabled as a workaround (#821). Keying the clamp on the manager's presence alone makes the runtime `allowStall` transition a non-event, so the warning that reported it is removed with it. The over-budget report now covers both consequences: memory the manager never reclaims, and, under `allowStall`, a permanent stall. `write-buffer-manager.test.ts`'s two memory-reclamation cases ask for an explicit 256MB target: the window they exercise is no longer what a configured manager derives, and they are about WBM charge accounting, not the default. Refs #821 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wo1B1gMcEHGrR2F6bsCmX
`resolveMaxWriteBufferSizeToMaintain` read `DBSettings`'s current `writeBufferManagerSize`, which is mutable, while a database keeps whichever manager it was opened with — `write_buffer_manager` is an immutable DBOptions field and a runtime size of 0 is documented as "no new attachments", not a teardown. A column family created after that change therefore got the unclamped derived 256MB target while its history was still charged to the manager the database was holding, which is the same permanent stall by another route. `buildColumnFamilyOptions` now takes the attachment as a parameter: the open path passes the manager it just attached, and the late-column-family path in DBRegistry passes `db->GetDBOptions().write_buffer_manager`. Regression covers it and fails without the fix (`late: 268435456` against `late: 1`). Also: the over-budget report described a floor as an "up to" ceiling. Refs #821 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wo1B1gMcEHGrR2F6bsCmX
`warnIfHistoryExceedsWriteBufferBudget` still took the budget from `DBSettings`'s global `writeBufferManagerSize`, which a runtime reconfiguration can drop to 0 without resizing the live manager a database is holding. It now reads `buffer_size()` from the manager attached to the database being opened, which is the budget its history is actually charged against; `allowStall` still comes from the settings, which mirror the single manager's live value because `DBSettings::Config` pushes every change through `SetAllowStall` and RocksDB exposes no getter. The late-column-family regression also pins that an explicit positive target is still the caller's to choose on a family created after the clamp applies. Refs #821 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wo1B1gMcEHGrR2F6bsCmX
README said a value of 0 "disables the manager", which contradicts the behavior the late-column-family fix depends on: 0 stops new opens from attaching one, but an already-open database keeps the manager it has and its memtables stay charged against that budget. The stall test's describe block also still claimed Vitest isolates each file in its own process — it gives each file a worker thread inside one shared process, which is the whole reason these two files couple through the native singleton — and referred to a runtime-allowStall warning that no longer exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wo1B1gMcEHGrR2F6bsCmX
The sentence added for this change still said the clamp keys off `writeBufferManagerSize > 0`, which is the global setting rather than what the database holds — and therefore wrong for exactly the late-column-family case this PR tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wo1B1gMcEHGrR2F6bsCmX
Two independent review legs, across five rounds, called the added comments redundant: the resolver docblock repeated the argument already in AGENTS.md, the README, store.ts and db_options.h, and the attached-manager scope was restated in the header and at the DBRegistry call site as well. The docblock is now a pointer plus the three facts the code genuinely cannot state: the wrappers rewrite 0, a positive target still retains one memtable, and the attachment is the database's rather than DBSettings'. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Wo1B1gMcEHGrR2F6bsCmX
kriszyp
force-pushed
the
fix/wbm-history-target-survives-reopen
branch
from
September 4, 2026 05:54
dbac378 to
923401c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The stall safeguard added in #755 has never survived a restart.
resolveMaxWriteBufferSizeToMaintaindropped the derived retained-history target to0, and0is the one value RocksDB's transaction wrappers reinterpret:OptimisticTransactionDB::OpenandTransactionDB::PrepareWrapeach rewrite it to-1, whichSanitizeOptionsexpands tomax_write_buffer_number * write_buffer_size— 256MB per column family with this codebase's defaults. Only families created after an open, throughDB::CreateColumnFamily, kept the0. So the existing fresh-database test passed while every production restart reopened all families at 256MB apiece and wedged (#821, HarperFast/harper#2490).The fix resolves the derived target to
1, the smallest value that survives that rewrite.What changed
1instead of0, and an explicit0now takes the same path rather than being forwarded to the wrappers — passing0through hands the caller who asked for the least history the most. An explicit positive value is still honored untouched.writeBufferManagerAllowStallis mutable at runtime —DBSettings::Configpropagates it throughSetAllowStall— whilemax_write_buffer_size_to_maintainis an immutableColumnFamilyOptionsfield fixed when a column family is created. Gating the clamp on it left every family opened before the switch permanently at 256MB, with nothing able to lower it. A non-stalling manager needs the bound anyway: withoutallowStallthe same history is simply never reclaimed, which is the ~3GB of retained history WBM stall safeguard (#755) is undone on reopen: TransactionDB::Open re-derives max_write_buffer_size_to_maintain=0 to 256 MiB per CF, permanent stall on Harper prod #821 measured on the affected deployment's hot families while it was running with the stall disabled as a workaround.1is not "no history", and the docs now say so.MemTableListVersion::MemtableLimitExceededcompares againstMemoryAllocatedBytesExcludingLast(), which subtracts the entryTrimHistoryis about to pop, so with one entry the comparison is0 >= 1and the newest flushed memtable is retained. It is dropped on that family's next write:CheckMemtableFullschedules a trim whenevermem()->MemoryAllocatedBytes() + imm()->MemoryAllocatedBytesExcludingLast() >= target, which any non-empty mutable memtable satisfies at1, andDBImpl::PreprocessWritedrains that scheduler before itsShouldStall()check. The bound is one flushed memtable per family instead of 256MB per family, and no positive target does better — the residual is independent of the target's magnitude.log.warnchannel and in the database's ownLOG, when the families known at that open already reach the budget. It covers both consequences — memory the manager never reclaims, and a permanent stall underallowStall. It compares by division rather than multiplying, because the caller's target reaches native as an unvalidatedint64, and uses>=because RocksDB stalls atmemory_usage() >= buffer_size(). Its wording states that only the families present at that open are counted, so it is a report and never a proof of safety.writeBufferManagerSizeis mutable too, and dropping it to0is documented as "no new attachments" rather than a teardown, so a database keeps whichever manager it was opened with.buildColumnFamilyOptionstherefore takes the attachment as a parameter: the open path passes the manager it just attached, and the late-column-family path inDBRegistrypassesdb->GetDBOptions().write_buffer_manager. Reading the global instead handed a family created after such a reconfiguration the unclamped 256MB while its history was still charged to the manager the database was holding — the same permanent stall by another route. A review round found this; the regression fails without the fix withlate: 268435456againstlate: 1. The over-budget report takes its budget from the same attached manager'sbuffer_size(); at open those two sources agree, so this is about reading the authoritative value rather than a bug that was reachable single-threaded.src/binding/database/db_settings.cppis untouched in the final diff. An earlier revision warned whenallowStallwas switched on at runtime; keying the clamp on the manager's presence makes that transition a non-event, so the warning went with it.For the human reviewer
The framing changed mid-flight, on your ruling, and the planning verdict never cleared.
Framing-Verdict: better-alternative-exists. The planning gate accepted the value and the layer but named theallowStallgate as a missing approach, on the mutability fact above. That was put to you as the one open question and you chose to drop the gate, so the clamp now applies to any attached manager. The two blockers the same gate raised are also fixed: explicit0was still taking the broken path, and the runtime transition defeated an open-time safeguard.This changes behavior for the configuration Harper runs today. With
writeBufferManagerAllowStall: falseas the current workaround, families go from a 256MB conflict-check window to1— moreERR_TRY_AGAINretries in the forced-flush regime, in exchange for the memory bound the manager exists to provide. The measurements below show no write-path throughput cost from the target itself, but the retry regime is the part worth watching after rollout.1is a large improvement, not a proof. The residual is one flushed memtable per family, so a budget below roughlyfamilyCount * writeBufferSizecan still wedge — and an idle family's retained memtable cannot be trimmed while every writer is stalled, because a write is what schedules the trim. I measured both sides: 7 families with a 1MB write buffer against an 8MB budget still stalls at target1, while 4 families at the shipped 16MB default against a 128MB budget writes 160MB in 660ms. The production shape (28 families, 16MB write buffer, 661MB budget) sits inside that, but the arithmetic does not close for an unbounded family count. The over-budget report therefore cannot fire for the resolved target, and the test asserting the quiet case says in its name that this is the guard's blind spot rather than codifying it as correct. Every review round raised it; the adjudication kept it at major and ruled it belongs in its own issue rather than here. The mechanism isMemTableList::RemoveMemTablecallingTrimHistory(to_delete, 0)whileMemoryAllocatedBytesExcludingLast()excludes the sole entry, so the candidates are a pinned-RocksDB patch passing the active memtable's usage instead of0, or an in-binding post-flush trim. Neither is something this PR can set.One pre-existing shape the warning leans on. An existing database has every column family re-opened with the first opener's
maxWriteBufferSizeToMaintain; unlike compression, per-family explicit values are not restored from the OPTIONS file. That is how this code already behaved and I did not change it, but the over-budget report's arithmetic assumes it. Making per-family targets persist would need the sameLoadLatestOptionsround-trip compression does.Review points I declined, with the reason:
Two rounds called the child-process fixtures a blocker because Node "does not natively parse TypeScript" and
process.execArgvis not propagated. Both were adjudicated factually wrong: native type stripping is unflagged at this repo'senginesfloor,AGENTS.mddocuments.mtsfixtures running with no flags, and all thirteen existing spawn-fixture tests use the identical form. CI confirms it on Node 22, 24 and 26 plus Bun and Deno.db->GetDBOptions()returns the struct by value at two call sites. Both are once per open and once per column-family creation, never a read or write path, and the adjudication downgraded it to a nit on that basis. Theinfo_logone cannot use the localDBOptionsanyway: RocksDB creates the logger during open and stores it on the database, so the local copy's is null.A standing nit across rounds is that the new comments still carry rationale
AGENTS.mdinvariant 10 owns. I made a consolidation pass — the resolver docblock is now a pointer plus the three facts the code cannot state, and the duplicated restatements at the header, theDBRegistrycall site and in the stall test are gone. What remains is deliberate.A leg called the child-process fixtures a blocker because Node "does not natively parse TypeScript", so spawning a
.mtsfile would fail withERR_UNKNOWN_FILE_EXTENSION. This repo'senginesfloor is^22.18.0 || >=24.0.0precisely because that is where native type stripping is unflagged,AGENTS.mddocuments the no-tsxrule, andtest/fixtures/fork-park-timeout.mtsalready does exactly this. CI confirms it on Node 22, 24 and 26 across Linux, macOS and Windows, plus Bun and Deno.An earlier leg flagged the
wbmAlreadyCreatedgate on the runtime-transition warning as a proxy that is only true after the first write. It was true from the first open — the manager is created insideDBDescriptor::open— and the adjudication pass reached the same conclusion. That code is gone from the final diff regardless.Three rounds raised comment mass on the new docblocks. I kept them: each states a why or a constraint the code cannot express — the wrapper rewrite, the
MemoryAllocatedBytesExcludingLastsemantics, the overflow reason for the division, the immutability that motivates the clamp condition. That is whatAGENTS.mdasks comments to carry, and the surrounding file is written the same way.Verification
The regression was watched go red on the base commit, most recently with the resolver reverted to
origin/mainand the tests kept:SIGKILLrather than a red assertion is the point for the stalling cases:put()runsstore.putSync()before returning its promise, so a stalled write blocks the JS thread and no in-process timer or runner timeout can fire (#781). The scenario therefore runs in a child process the parent kills on a deadline, so a regression fails bounded instead of hanging the job. The non-stalling case cannot stall by construction, so it fails as a plain value assertion — which is what pins the new clamp condition.A fourth case covers the attachment path: it opens a database under a manager, clears the global budget, then creates a column family and reads both families' targets out of the
LOGafter the child exits, because RocksDB's info logger is buffered and only flushes the newest block on close.The fixture runs at the shipped 16MB
writeBufferSizedefault with a 128MB budget over four families, so the pre-fix target it faces is the real16 * 16MBrather than a scaled-down stand-in. Both transaction modes get their own case, because the rewrite is duplicated in each wrapper. The assertion reads the per-familymax_write_buffer_size_to_maintainback out of the databaseLOG, which RocksDB rewrites on every open, and requires every family to be at1— the same observation the issue's repro used.Measured, because the #755 benchmark cited in the issue measured the old effective
0and not1. Interleaved arms, fresh database per rep, 4 families, 1MB write buffer, 32MB written, WriteBufferManager off so every target is viable, median of 5:No measurable write-path cost from the positive target in either regime, including the forced-flush case where the extra history bookkeeping was predicted to show up.
Three existing tests changed meaning, and all were corrected rather than weakened.
The conflicting-commit case asserted
ERR_TRY_AGAIN, and with a retained memtable the check can now find the conflict and reportERR_BUSY. It writes twice after the flush so the trim is scheduled and then drained, which puts it back on the minimal-history path the test exists to cover. Both codes refuse the commit, so this was never a safety change, but the specific code is what proves the scenario is the intended one.write-buffer-manager.test.ts's two memory-reclamation cases observe what a retained window charges to the block cache, and under a configured manager that window is no longer the default. They now ask for an explicit 256MB target, which is what the default used to derive to there, so they keep testing WriteBufferManager charge accounting rather than the new default.Separately, a latent cross-file hazard surfaced. The
WriteBufferManageris a native process-global and Vitest'sthreadspool runs every file in one process, so the two WriteBufferManager test files were racing to create the singleton and only worked because of the file order. Growing the stall file changed that order and exposed it:costToCachedisagreed between them andconfig()refuses to change it after creation. Both files now agree on it, and the stall file resets the manager when it finishes.Suites. Rebased onto current
origin/main(19 commits) and re-verified there. Full Vitest suite green locally: 856 passed, 9 skipped, 0 failed. Native GoogleTest: 159/159.pnpm checkclean. CI was green on the pre-rebase head across Node 22, 24 and 26, Bun and Deno, on Linux, macOS and Windows, plus the native suite, stress tests and benchmarks.CI on the rebased head is green as well: 24 pass, 0 fail. Two Windows alternate-runtime jobs failed on the first attempt and both passed on re-run, in areas this change cannot reach —
Test on Bun (windows-latest)inlock-tracker.test.ts's coordinated-retry park case, andTest on Deno (windows-latest)which passed all 787 tests and then exited non-zero on a single[vitest-pool]worker-teardown crash. The diff touches no transaction, park, orphan-GC or verification-table code, those tests configure no WriteBufferManager, and both alternate runtimes use Vitest'sforkspool so they do not share a process with the files that do. Worth knowing that both flaked, since the rebase pulled in 19 upstream commits that rewrotetransaction.cpp,transaction_handle.cppandsrc/transaction.ts.No data-format change and no migration: this only changes an in-memory RocksDB option, so rolling back is mechanically safe and simply restores the permanent-stall defect. It does need rebuilt native prebuilds on every supported platform to take effect.
Refs #821
Review-Coverage: authored=claude; ran=gemini; adjudicated=domain; declined=codex,cursor-grok,cursor-composer; rounds=6 @ 923401c
Human-Review-Need: 3 (decisions: clamp-on-any-manager-not-only-stalling, explicit-zero-normalized-without-manager, warn-rather-than-reject-over-budget, full-size-child-process-regression-tests, attachment-queried-not-cached) @ 923401c