Add FlushOptions.allowWriteStall, and settle flush()/compact() promises on read-only databases - #775
Draft
kriszyp wants to merge 7 commits into
Draft
Add FlushOptions.allowWriteStall, and settle flush()/compact() promises on read-only databases#775kriszyp wants to merge 7 commits into
kriszyp wants to merge 7 commits into
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request addresses a hang issue (#774) where flush and compact operations on read-only databases would return without settling their promises. It ensures that callback-style native methods always settle their callbacks. Additionally, it introduces support for the allowWriteStall option in both synchronous and asynchronous flush operations, allowing callers to control whether a manual flush should wait for or cause a write stall. New tests and documentation have been added to verify and describe these behaviors. 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 0c43e4b |
kriszyp
marked this pull request as ready for review
August 12, 2026 02:20
added 7 commits
August 21, 2026 11:49
…-only promise `DBDescriptor::flush()` constructed a default `rocksdb::FlushOptions`, so every flush ran with `allow_write_stall = false` — meaning RocksDB waits until a flush is possible *without* causing a write stall. That wait is unbounded and is taken on the calling thread, which for the async `flush()` is a libuv worker: a database sitting in a stall condition (immutable-memtable backlog, L0 stop trigger, pending-compaction-bytes limit, an exhausted WriteBufferManager budget) hands the caller a promise that never settles while the event loop stays alive. Callers whose flush is a durability gate they are already blocked on would rather stall writers than wait a stall out, so `flush()`/`flushSync()` now take an options bag carrying `allowWriteStall`. Default is unchanged. Also fixes #774, which is the same class one layer down: `Flush` and `Compact` returned without invoking *either* callback on a read-only database, so `await db.flush()` there was an unconditional permanent hang. They now resolve, matching the no-op `flushSync` already performed. Refs HarperFast/harper-pro#678
…y branch Cross-model review findings: - DBDescriptor::close() was the one internal flush() caller and inherited the waiting default, so a close under an exhausted write-buffer budget could wedge shutdown with nothing logged — the exact hazard the new AGENTS invariant describes. It now passes true; close has stopped accepting work, so stalling writers there costs nothing. - Option validation ran after the read-only short-circuit, so the same argument threw on a writable handle and was silently accepted on a read-only one. Parsed ahead of the branch in both Flush and FlushSync, with coverage. - The stalling-WBM case claimed behavioral coverage it does not have; renamed and the claim replaced with why a behavioral assertion is not available here. - Trimmed comments that narrated code or addressed the reviewer; the allow_write_stall semantics now live once, in the FlushOptions JSDoc.
Completes the mode-dependent-validation fix: Compact still resolved its read-only no-op before checking argv, so a bad start/end key was refused on a writable handle and silently accepted on a read-only one. Parsing now runs first, into locals that move into the state afterward — which also means a rejected buffer no longer throws past the AsyncCompactState allocation and leaks it. Not reachable through the public API (RocksDatabase.compact takes an options object and never forwards a raw non-buffer), so there is no accompanying test.
…r compact Adjudicated review round 3: - The JSDoc read as though the stall cost were scoped to the caller. A flush covers every column family on a process-global descriptor shared across worker_threads, so opting in stalls every other table and every other handle on that path. Said so. - flushBeforeBackup goes through BackupEngine, which builds its own default FlushOptions we cannot reach, and takes that unbounded wait while holding the exclusive .backup.lock — a stalled database turns a backup into a hang that blocks every other backup/delete/purge on the directory. Not introduced here and not fixable from this layer; recorded in AGENTS invariant 13 as a known uncovered path. - Covered the compact reorder via the native handle, which is the only place a malformed key is reachable (RocksDatabase.compact always materializes Buffers). - Trimmed comments that narrated review history.
CI caught this: Test on Bun (windows-latest) failed deterministically (both
in-job retries, while the same job is green on nine recent runs from other
branches). The vitest worker died in transaction-log.test.ts at 'should write to
same log from multiple workers' — 30 of 70 tests ran — a test that drives
concurrent worker_threads writers against one shared DB path while looping
purgeLogs({ destroy: true }).
Mechanism: allow_write_stall = true makes the close-time flush switch memtables
immediately instead of waiting for a stall-free moment, so it fires
OnFlushBegin/OnFlushCompleted into TransactionLogStores that the concurrent purge
is destroying.
Trading a hypothetical shutdown wedge for a real crash is not a trade worth
making, and the option plumbing this PR exists for does not need it. Close keeps
the waiting default; the wedge is documented in AGENTS invariant 13 as still
open, with the constraint that any fix must avoid flushing into the teardown
race.
The incident narrative belongs in AGENTS invariant 13, not inline.
kriszyp
force-pushed
the
kris/flush-options
branch
from
August 21, 2026 17:58
930b8ea to
0c71a3e
Compare
kriszyp
marked this pull request as draft
August 21, 2026 17:58
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.
What
flush()andflushSync()now take an optional{ allowWriteStall }bag, mapping torocksdb::FlushOptions::allow_write_stall. The default is unchanged.DBDescriptor::flush()constructed a defaultrocksdb::FlushOptions, and that was the only occurrence of the type in the binding, so every flush ran withallow_write_stall = falseand no caller could say otherwise. False is the waiting behavior — despite how the name reads, it means the flush waits until it can run without causing a write stall — and that wait has no timeout at any layer. It is taken on the calling thread, which for the asyncflush()is a libuv worker, so a database sitting in a stall condition hands its caller a promise that never settles while the event loop stays alive, with no error and nothing logged. The stall conditions that produce it are ordinary: immutable-memtable backlog, the L0 stop trigger,soft_pending_compaction_bytes_limit, or a WriteBufferManager budget that cannot drain.A caller whose flush is a durability gate it is already blocked on would rather stall writers for the duration than wait a stall out. That is the situation in harper-pro#678, where the base-copy cursor flush is awaited from the copy's
onCommit.This adds an option; it changes no existing behavior.
DBDescriptor::close()is the one internal caller and keeps the waiting default — see the CI finding below for why that is deliberate rather than an oversight.Also fixes #774
Same class of defect one layer down, found while tracing the above.
Database::FlushandDatabase::Compactreturned without invoking eitherresolveorrejectwhen the database was opened read-only, soawait db.flush()there was an unconditional permanent hang — no stall required. They now resolve, matching the no-opflushSyncalready performed. Recorded as AGENTS invariant 12: a callback-style native method owes its caller exactly one settled callback on every path, and for a promise an earlyreturnis not a no-op.Argument validation in all three methods now precedes the read-only short-circuit, so the same argument gets the same verdict in either mode. In
Compactit also precedes theAsyncCompactStateallocation, so a rejected key buffer can no longer throw past thenewand leak it.Verification
Route (b) new test file plus (a) extension of the suite that owns this area.
test/flush-options.test.ts(new) — the option is accepted and flushes durably on both entry points; no-args /{}/ explicitfalsestill work; a non-object bag or non-boolean value throws rather than being silently ignored, in both read-only and read-write mode; the two read-only settle cases from flush() and compact() never settle their promise on a read-only database #774; and the compact reorder, exercised through the native handle sinceRocksDatabase.compactalways materializes realBuffers and can't reach the validation.Test timed out in 10000ms. A regression here is a hang rather than an assertion failure — the promise never settles and no in-test timer can surface it — so the per-test timeout is the assertion. Restored and rebuilt afterward.test/write-buffer-manager-stall.test.tsextended by one case, since it is the only file that builds a stallingWriteBufferManager(allowStallis fixed when the singleton is created, so it needs its own process). It is labelled plumbing-only on purpose — see decision 4.pnpm check(type-check + oxlint + oxfmt) clean.What CI caught, and what it means
An earlier revision of this branch had
close()opt in (flush(true)), on the reasoning that a closing database has stopped accepting work so a stall costs it nothing. CI refuted that, and it is the most useful thing to know about this change.Test on Bun (windows-latest)failed deterministically — both in-job retries, while the same job is green on nine recent runs from other branches. No test reported a failure; a vitest worker died. Diffing per-file test counts against the passing Bun/ubuntu job located it:transaction-log.test.tsran 30 of 70 tests and died at "should write to same log from multiple workers", which drives concurrentworker_threadswriters against one shared DB path while loopingpurgeLogs({ destroy: true }).Mechanism:
allow_write_stall = truemakes the flush switch memtables immediately rather than waiting for a stall-free moment, so it firesOnFlushBegin/OnFlushCompleted(db_descriptor.cpp:189) intoTransactionLogStores that the concurrent purge is destroying. Reverting only that line turned CI fully green, which is the confirmation.Two things follow, and they matter more than the option itself:
worker_threads, so other handles are still live. Any future attempt to stop close from wedging has to avoid flushing into that teardown race — recorded in AGENTS invariant 13.TransactionLogEventListeneris registered indbOptions.listeners(db_descriptor.cpp:969), so those callbacks fire on every flush — including RocksDB's own background flushes, continuously, in normal operation. The option changes when a manual flush runs, not whether the callbacks happen, so it is not a new crash class. What made close special is that it flushed during teardown, when the stores are being destroyed. Moderate confidence: reasoned from registration and the listener'sweak_ptrdiscipline, not from a constructed race.Open concern, not fixed here
flushBeforeBackupflushes inside RocksDB'sBackupEngine, which builds its own defaultFlushOptionsthis layer cannot reach — the same unbounded wait, and it is taken afterrunCreateBackupacquires the exclusive.backup.lock. A stalled database therefore turns a backup into an indefinite hang that also blocks every other backup/delete/purgeon that directory, cross-process and cross-container, until the process dies. Pre-existing and not introduced here; recorded in AGENTS invariant 13 as a known uncovered path and worth its own issue.For the human reviewer
false(waiting). Every existing caller is unchanged, which also means the never-settling promise described above remains the out-of-the-box behavior. The alternative is defaulting Harper'sflush()totrueand making the unbounded wait opt-in. Flipping later is a one-line change but a semver-visible behavior break. A "no" here costs nothing today and closes off the argument that upstream's default is a trap we're propagating.flush({ allowWriteStall: true })into a concurrentpurgeLogs({ destroy: true })in a way a background flush cannot, this needs a guard before merge.src/database.tswhere the type already exists. That costs error-message quality and duplicates the surface for every future flush option. Cheap to move, but the tests currently assert the native message.allowWriteStallchanges RocksDB's behavior, so nothing would catch the flag being dropped between TS andFlushOptions. Building a deterministic stall fixture is possible, but Fix permanent write stall when the derived memtable-history target exceeds the WriteBufferManager budget #755 removed the stall this fixture could produce on demand, and the failure mode of such a test is a wedged libuv thread rather than a red assertion. If you want the coverage anyway, that is a reasonable call to overrule me on.Worth knowing while reading: opting a flush in is database-wide.
DBDescriptor::flushcovers every column family on a descriptor that is process-global and shared acrossworker_threads, so the stall reaches every other table and every other handle on that path, not just the caller's. The JSDoc says so now; it did not in the first draft, and it is the thing most likely to surprise a call site.Review coverage
Four rounds. Round 1 (codex + gemini + Harper domain, adjudicated) returned CHANGES on three findings, all fixed: the close path inheriting the waiting default, mode-dependent argument validation, and a stall test that claimed behavioral coverage it did not have. Round 3 (full, adjudicated) settled at severity minor and produced the decisions above. Round 4 (delta) is codex LGTM.
Honest degradations:
cursor-grokandcursor-composerwere auto-pruned on every round; the gemini leg produced no output on round 2. Gemini raised four findings across rounds that were refuted rather than fixed — three claiming uninitializedargvslots (napi_get_cb_infopads unsupplied slots withundefined, andCompact/Gethave used the pattern since before this PR), and one claiming an async-flush use-after-free (AsyncFlushStateholds ashared_ptr<DBHandle>whosedescriptoris itself ashared_ptr, and close gates onregisterAsyncWork). The review-need grade below is pinned at 4 by a run of mine that pruned the adjudicator and so left those unrefuted; the adjudicated severity is minor.Generated by Claude Opus 5.
Review-Coverage: authored=unknown; ran=none; rounds=1 @ 0c71a3e
Human-Review-Need: 4 @ 0c71a3e