Skip to content

fix(dash-spv): release clean storage segments below the committed height during long scans - #946

Open
bfoss765 wants to merge 1 commit into
devfrom
fix/spv-segment-eviction
Open

fix(dash-spv): release clean storage segments below the committed height during long scans#946
bfoss765 wants to merge 1 commit into
devfrom
fix/spv-segment-eviction

Conversation

@bfoss765

Copy link
Copy Markdown
Contributor

Supersedes #932 — same change, recreated on a dashpay/rust-dashcore branch per repo policy (no more personal-fork PRs). Commits and authorship unchanged; full review history on #932.

The problem

A long backfill pins every item it ever downloads.

SegmentCache holds 50,000 items per segment and only evicts once more than
MAX_ACTIVE_SEGMENTS (10) segments are resident. A 350k-block scan spans just
7-8 segments, so it never trips the LRU. Every compact filter and every full
decoded block (GCS false positives — roughly 12k blocks at a 27k-script watch
set) stays in RAM until the process dies.

persist was already writing segments to disk and marking them Clean, but it
never freed them. The data was durable and redundant in memory at the same time.

This is an out-of-memory class bug, not a speculative improvement.

Field evidence

A tester's mainnet wallet performs a forced rescan of roughly 345,000 filters
— a direct match for the case described above.

  • On builds without this fix, the app was repeatedly killed by Android's
    low-memory killer at 1.4–2.1 GB RSS, at least three times in a single day,
    with some sessions lasting as little as one minute.
  • On a build carrying this fix, memory peaked at 2.34 GB during the scan
    and fell back to ~1.0 GB once the scan completed — segments being released
    — after which the process ran 5h57m uninterrupted with zero low-memory kills.

Confound, stated plainly: that build bundled several other fixes, so the
survival time is not attributable to this change in isolation. What is
attributable is the memory shape — a peak during the scan followed by a
release afterwards — which is precisely this fix's mechanism. There is no
dedicated eviction instrumentation in those logs, so the shape is the evidence,
not a counter.

The fix

Add a caller-declared committed-height watermark. After persist, any
segment lying entirely at or below the watermark is dropped from memory, leaving
the on-disk file as the source of truth. The next read reloads it through the
lazy path already used for any non-resident segment.

This is safe because Clean is reachable only via Segment::load from an
existing file, or via a successful Segment::persist. A clean segment is
therefore byte-identical to its backing file, and releasing it is invisible to
readers.

Always kept:

  • dirty segments (their contents exist only in memory),
  • the frontier segment (still being written),
  • partially committed segments.

The watermark may move backwards — a wallet rescan rolls it back before
re-reading lower heights — which only narrows the release window. truncate_above
clamps it and clear drops it, so it never outlives its data.

Where the watermark comes from

It is wired where each cache's owner already knows the answer:

  • blocks — from the in-order take_next_ordered_block drain, which applies
    blocks to every interested wallet before advancing;
  • filters — from batch commit, which happens only once every matched block in
    the batch has been downloaded and applied.

Deliberately out of scope

Header caches are left alone. Their random-access path is the separate
header_hash_index map, which segment release cannot shrink, and their ranges
are read at arbitrary depths during scanning. The mechanism is generic, so they
can opt in later with a single call.

Compatibility

The new trait methods carry default no-op bodies, so out-of-tree implementors are
unaffected and the change is drop-in for the platform AAR build.

Tests

Six new tests in dash-spv/src/storage/segments.rs cover the release path and
its guards:

  • test_committed_blocks_are_released_but_still_loadable
  • test_release_committed_segments_reloads_from_disk
  • test_release_skips_dirty_frontier_and_partial_segments
  • test_released_segments_serve_a_rescan_reread
  • test_committed_height_follows_truncate_and_clear
  • test_store_into_released_segment_preserves_existing_items

cargo test -p dash-spv is green (587 passed, 0 failed, with SKIP_DASHD_TESTS=1
for the targets that require a dashd binary). cargo fmt and strict clippy
(--all-features --all-targets -D warnings) are clean.

Summary by CodeRabbit

  • New Features

    • Improved storage efficiency by releasing committed block and filter data from memory while keeping it available on disk.
    • Committed data reloads transparently when needed, preserving transaction details and handling gaps correctly.
    • Synchronization now updates storage progress as blocks and filters are successfully committed.
  • Bug Fixes

    • Improved handling of rescans, rollbacks, truncation, cache clearing, and writes to previously released data.

…ght during long scans

A long backfill pinned every item it ever downloaded. `SegmentCache` holds
50_000 items per segment and only evicts once more than `MAX_ACTIVE_SEGMENTS`
(10) are resident, so a 350k-block scan — which spans just 7-8 segments —
never tripped the LRU. Every compact filter and every full decoded block
(GCS false positives, ~12k blocks at a 27k-script watch set) stayed in RAM
until the process died. `persist` wrote segments to disk and marked them
Clean but never freed them.

Add a caller-declared committed-height watermark. After `persist`, any
segment lying entirely at or below it is dropped from memory, leaving the
on-disk file as the source of truth; the next read reloads it through the
lazy path already used for any non-resident segment.

This is safe because `Clean` is reachable only via `Segment::load` from an
existing file or a successful `Segment::persist`, so a clean segment is
byte-identical to its backing file and releasing it is invisible to readers.
Dirty segments (contents exist only in memory), the frontier segment (still
being written), and partially committed segments are always kept.

The watermark is wired where each cache's owner already knows the answer:
- blocks, from the in-order `take_next_ordered_block` drain, which applies
  blocks to every interested wallet before advancing;
- filters, from batch commit, which happens only once every matched block
  in the batch has been downloaded and applied.

It may move backwards — a wallet rescan rolls it back before re-reading
lower heights — which only narrows the release window. `truncate_above`
clamps it and `clear` drops it so it never outlives its data.

Header caches are deliberately left alone: their random-access path is the
separate `header_hash_index` map, which segment release cannot shrink, and
their ranges are read at arbitrary depths during scanning. The mechanism is
generic, so they can opt in later with one call.

New trait methods carry default no-op bodies, so out-of-tree implementors
are unaffected and the change is drop-in for the platform AAR build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@bfoss765, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 81f6245e-c741-4351-88af-8f466ba21dd8

📥 Commits

Reviewing files that changed from the base of the PR and between 5a80bd7 and 9acfee3.

📒 Files selected for processing (6)
  • dash-spv/src/storage/blocks.rs
  • dash-spv/src/storage/filters.rs
  • dash-spv/src/storage/mod.rs
  • dash-spv/src/storage/segments.rs
  • dash-spv/src/sync/blocks/manager.rs
  • dash-spv/src/sync/filters/manager.rs

Comment @coderabbitai help to get the list of available commands.

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.

1 participant