Skip to content

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

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

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

Conversation

@bfoss765

@bfoss765 bfoss765 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b94d0d14-36bd-4ae5-9f7c-99a59120c2f6

📥 Commits

Reviewing files that changed from the base of the PR and between 6be2407 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

📝 Walkthrough

Walkthrough

Changes

Committed storage watermark

Layer / File(s) Summary
Segment cache watermark and eviction
dash-spv/src/storage/segments.rs
SegmentCache tracks committed height, releases eligible clean segments after persistence, preserves dirty and frontier segments, and reloads released data from disk. Tests cover rescans, truncation, clearing, restart behavior, and writes into released segments.
Storage watermark contracts and forwarding
dash-spv/src/storage/blocks.rs, dash-spv/src/storage/filters.rs, dash-spv/src/storage/mod.rs
Block and filter storage expose set_committed_height. Persistent backends and DiskStorageManager forward the watermark to SegmentCache.
Synchronization watermark updates
dash-spv/src/sync/blocks/manager.rs, dash-spv/src/sync/filters/manager.rs
Block drains update the watermark after processing. Filter rescans roll it back, and committed filter batches advance it.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BlocksManager
  participant FiltersManager
  participant DiskStorageManager
  participant PersistentBlockStorage
  participant PersistentFilterStorage
  participant SegmentCache

  BlocksManager->>DiskStorageManager: update committed block height
  DiskStorageManager->>PersistentBlockStorage: forward block watermark
  PersistentBlockStorage->>SegmentCache: set committed height

  FiltersManager->>DiskStorageManager: rollback or advance filter watermark
  DiskStorageManager->>PersistentFilterStorage: forward filter watermark
  PersistentFilterStorage->>SegmentCache: set committed height
Loading

Possibly related PRs

Suggested labels: ready-for-review

Suggested reviewers: xdustinface, zocolini

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: releasing clean storage segments below the committed height during long scans.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.41026% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.57%. Comparing base (6be2407) to head (9acfee3).

Files with missing lines Patch % Lines
dash-spv/src/storage/mod.rs 0.00% 4 Missing ⚠️
dash-spv/src/storage/blocks.rs 96.00% 1 Missing ⚠️
dash-spv/src/storage/filters.rs 66.66% 1 Missing ⚠️
dash-spv/src/storage/segments.rs 99.35% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #932      +/-   ##
==========================================
+ Coverage   75.52%   75.57%   +0.04%     
==========================================
  Files         328      328              
  Lines       78017    78212     +195     
==========================================
+ Hits        58923    59108     +185     
- Misses      19094    19104      +10     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 51.49% <ø> (ø)
rpc 20.00% <ø> (ø)
spv 91.35% <96.41%> (+0.03%) ⬆️
wallet 76.88% <ø> (ø)
Files with missing lines Coverage Δ
dash-spv/src/sync/blocks/manager.rs 97.11% <100.00%> (+0.07%) ⬆️
dash-spv/src/sync/filters/manager.rs 97.96% <100.00%> (+<0.01%) ⬆️
dash-spv/src/storage/blocks.rs 98.83% <96.00%> (-1.17%) ⬇️
dash-spv/src/storage/filters.rs 98.38% <66.66%> (-1.62%) ⬇️
dash-spv/src/storage/segments.rs 96.72% <99.35%> (+0.70%) ⬆️
dash-spv/src/storage/mod.rs 85.10% <0.00%> (-1.05%) ⬇️

... and 4 files with indirect coverage changes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review CodeRabbit has approved this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant