fix(storage): cap LMDB map head-room on Windows to bound page-table memory - #187
Conversation
…emory The LMDB map is auto-sized to the whole disk (current data + all free space). On Windows the kernel eagerly builds page tables for the mapped region -- ~8 bytes per 4 KiB page (~1/512 of the map) as resident, non-pageable memory attributed to no process. A disk-sized map therefore costs ~0.2% of free disk in commit/RAM per node: a 3.7 TB map ~= 7.4 GB, a 10 TB map ~= 20 GB, multiplied by every node sharing the host. Linux populates map page tables lazily, so the sparse disk-sized map is nearly free there -- this overhead is Windows-specific. Cap the disk-headroom term at 32 GiB on Windows and rely on the existing try_resize() to extend the map on demand as data accumulates, keeping page-table overhead proportional to stored data rather than disk capacity. Existing data is always covered, so a capped map can never truncate the DB. Verified on a live node holding 22.6 GiB of chunks: map size 3,719 GiB -> 54.58 GiB commit 7,537 MB -> 181 MB virtual 3,719 GB -> 60 GB Linux behaviour is unchanged (#[cfg(windows)]); unit tests assert both the Windows head-room cap and the disk-sized default on other platforms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses excessive, unaccounted-for kernel memory consumption on Windows caused by LMDB mapping “disk-sized” regions (triggering eager page-table allocation), by capping the auto-sized map head-room on Windows and relying on on-demand resizing as data grows.
Changes:
- Add a Windows-only cap (
WINDOWS_MAP_HEADROOM = 32 GiB) to bound LMDB map head-room and page-table overhead. - Refactor map sizing logic into a unit-testable helper (
map_target_bytes) and updatecompute_map_sizeto use it. - Add unit tests covering the Windows cap behavior, “never truncate existing data” behavior, and non-Windows behavior.
Comments suppressed due to low confidence (1)
src/storage/lmdb.rs:749
- This doc comment links to
WINDOWS_MAP_HEADROOM, but that constant is only defined on Windows (#[cfg(windows)]), so the link will be broken in non-Windows rustdoc builds. Prefer backticks (or define a non-Windows stub constant) to avoid broken intra-doc links.
/// `map = current_db_bytes + max(0, available − reserve)`, with the head-room
/// term capped at [`WINDOWS_MAP_HEADROOM`] on Windows. Existing data
/// (`current_db_bytes`) is always covered so a resize can never truncate the
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
d854968 to
0024319
Compare
dirvine
left a comment
There was a problem hiding this comment.
Review summary
Reviewed exact head 00243192ab001c6a44237a4cc83e6812b822970c with the review panel and local verification.
Verdict: changes requested — one merge-blocking LMDB resize-safety issue.
Blocking: env_lock does not cover every active LMDB transaction
try_resize() takes env_lock.write() and then calls unsafe Env::resize(), with the stated invariant that no transactions are active (src/storage/lmdb.rs:652-678). However, two production read paths open transactions without taking the matching shared lock:
all_keys()—src/storage/lmdb.rs:546-573get_raw()—src/storage/lmdb.rs:588-603
Heed/LMDB explicitly requires that mdb_env_set_mapsize be called only when no transactions are active; the library does not enforce this for readers. The resize implementation can unmap/remap the environment while either untracked transaction or cursor still references it.
The omission predates this PR, but this change makes resize-on-demand a normal Windows lifecycle path (approximately each additional 32 GiB) rather than an exceptional partition-expansion path, so the race becomes materially reachable during concurrent replication/audit activity.
Required fix: clone env_lock into both blocking closures and hold lock.read() from before read_txn() until the transaction, cursor and copied LMDB values have all been dropped. Please also add a regression test proving resize waits for a held all_keys/get_raw transaction before entering env.resize().
Non-blocking review notes
- The reported Windows before/after figures are internally coherent and strongly support the operational fix. The precise explanation in the comments is more certain than the evidence: the pinned LMDB backend uses a reserved file view, and modern Windows does not generally materialise one hardware PTE per untouched 4 KiB page. I suggest describing the observed effect as size-proportional Windows VM/section metadata or commit/private overhead unless the exact kernel allocation was measured.
- T1 is labelled “client-only” in the PR template, while this changes node storage behaviour and deliberately relies on routine resize-on-demand. Please reconsider T2/ADR classification or record an explicit policy exception.
- At review time, the Ubuntu, macOS and Windows test jobs were still pending. The Windows job is material because it executes the
#[cfg(windows)]arithmetic branch.
Verified
cargo fmt --all -- --check— passcargo test storage::lmdb --lib --no-fail-fast— 13 passed, 0 failed- Windows, macOS and Ubuntu builds, Clippy, docs, formatting, security audit and no-logging tests — pass
- Current PR head revalidated immediately before this review
No merge performed.
Address PR #187 review (V2-620): - Blocking (resize safety): all_keys() and get_raw() opened LMDB read transactions without taking the shared env_lock, so try_resize()'s unsafe Env::resize() (held under the exclusive lock) could unmap/remap the environment while a read txn or cursor was still live. This PR makes resize a routine ~per-32-GiB Windows event rather than a rare partition-expansion path, making that pre-existing race materially reachable. Both read paths now hold env_lock.read() across the transaction, matching every other txn site. Adds a regression test asserting both block while the exclusive lock is held. - Rustdoc: intra-doc links to WINDOWS_MAP_HEADROOM (a #[cfg(windows)] const) would be broken on non-Windows doc builds; switched to plain code spans. - Wording: describe the Windows overhead as the measured, size-proportional commit/private cost (~0.2% of mapped size) rather than asserting a specific hardware page-table mechanism that was inferred from the scaling, not directly measured. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review, @dirvine — the resize-safety catch is exactly right. Addressed in Blocking —
Regression test Non-blocking — mechanism wording (done) Agreed the per-PTE explanation was more precise than the evidence supports. The doc comment and commit message now describe the observed effect as size-proportional Windows commit/private overhead (~0.2% of mapped size — measured: ~7.4 GB at a ~3.7 TiB map vs ~181 MB at ~55 GiB) and explicitly note the exact kernel structure was inferred from that scaling, not measured directly. (We intended to confirm via RAMMap's Page Table row, but the node was stopped before that capture — happy to gather it on a follow-up if useful.) Non-blocking — risk tier / ADR Fair point — I flagged the T1-vs-T2 call in the PR body but you're right that routine resize-on-demand makes it node-behavioural. Confirming the classification with the author now; I'll update the tier box and add an ADR if it lands at T2 before marking this ready. CI The build/test/clippy jobs (including the Windows job that exercises the |
Records the decision behind PR #187: on Windows, cap the LMDB map head-room at 32 GiB and grow on demand via try_resize(), so committed/private memory is proportional to stored data rather than disk capacity. Non-Windows keeps the disk-sized map. Documents the resize-cadence trade-off and the env_lock discipline it depends on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Re: risk tier — agreed, reclassified to T2. Added ADR-0007: Cap the Windows LMDB map head-room ( The ADR records the context, the options weighed, the resize-cadence trade-off, and the |
dirvine
left a comment
There was a problem hiding this comment.
Follow-up review
Reviewed exact head 43665bf35562000128d30e057d4448d342eceb94 after the requested changes.
The previous resize-safety blocker is resolved:
all_keys()andget_raw()now holdenv_lock.read()across their complete LMDB transaction/cursor or borrowed-value lifetime.- All runtime LMDB transaction paths are covered by the same shared lock;
try_resize()takes the exclusive lock beforeEnv::resize(). - The regression test passed locally 20/20 repetitions;
cargo test storage::lmdb --lib --no-fail-fastpassed 14/14 and formatting passed. - The mechanism wording was corrected, the PR is classified T2, and ADR-0007 records the policy and lock invariant.
Review-panel consensus found no material blocker. One reviewer preferred a stronger reader-first test hook to prove the full guard lifetime; source inspection establishes that lifetime directly, so I regard this as non-blocking.
All current-head checks are green on rerun, including macOS, Ubuntu and Windows tests. The first Windows E2E attempt had an unrelated transport-initialisation failure; the rerun passed.
Approved. No merge performed.
Linear issue
V2-620
Risk tier
Node storage logic with a behavioural surface — routine resize-on-demand and the
associated
env_lockdiscipline — but no change to the wire protocol, stored-dataformat, payments/economics, or the upgrade mechanism. Classified T2 per review; ADR
linked below.
Compatibility
Semver impact
Test evidence
map_target_covers_existing_data_and_headroomandmap_target_never_truncates_data_when_disk_nearly_fullassert the Windowshead-room cap binds, that existing data is always covered even when the head-room
term saturates to zero, and that non-Windows platforms keep the disk-sized map.
cargo testandcargo clippy(repo-D panic/unwrap/expect) are clean.read_paths_block_while_env_is_being_resizedasserts
all_keys/get_rawhold the sharedenv_lockand block until anin-progress resize (exclusive lock) is released.
loss), and page-table memory fell ~40×. Projected to the reporting user's 3-node
host, ~60 GB collapses to well under 1 GB.
New dependency
none
ADR
ADR-0007: Cap the Windows LMDB map head-room
Mitigation / rollback
Revert the single commit — the change is Windows-only (
#[cfg(windows)]) andbehaviourally transparent (existing DBs open unchanged; the resize-on-demand path is
pre-existing and already exercised), so there is no data migration to undo.