Skip to content

fix(net): amortize ChainLock seen-cache pruning - #7482

Open
thepastaclaw wants to merge 1 commit into
dashpay:developfrom
thepastaclaw:fix/chainlock-cache-prune-hysteresis
Open

fix(net): amortize ChainLock seen-cache pruning#7482
thepastaclaw wants to merge 1 commit into
dashpay:developfrom
thepastaclaw:fix/chainlock-cache-prune-hysteresis

Conversation

@thepastaclaw

Copy link
Copy Markdown

Issue being fixed or feature implemented

Dash #7424 bounded ChainlockHandler::seenChainLocks with unordered_limitedmap, but the single-argument constructor makes the prune trigger equal to the 1,024-entry retained size. After the cache fills, every unique insertion creates and sorts a 1,025-element iterator vector and removes one entry while holding ChainlockHandler::cs.

ProcessNewChainLock() records the hash before its stale-height return and before signature verification. A peer can therefore submit unlimited unique stale-height CLSIGs, receive no misbehavior penalty, and trigger the full sort on every message.

What was done?

  • Constructed seenChainLocks with a 1,024-entry retained size and a 2,048-entry prune trigger, so one sort removes a batch of 1,025 entries instead of one.
  • Renamed the constants and test accessors to distinguish the retained size from the temporary prune threshold.
  • Documented unordered_limitedmap's default prune behavior and exposed its configured threshold for focused tests.
  • Updated the generic limited-map and ChainLock tests to verify growth through the hysteresis window, batch pruning back to the retained size, stale CLSIG duplicate suppression, and best-ChainLock lookup after eviction.

Stale CLSIG hashes are still remembered before the early return, and the existing 24-hour cleanup behavior is unchanged.

How Has This Been Tested?

Tested locally on macOS against current develop:

  • ./autogen.sh and ./configure with the existing aarch64-apple-darwin depends prefix
  • make -C src test/test_dash
  • ./src/test/test_dash --run_test=limitedmap_tests,llmq_chainlock_tests
  • ./src/test/test_dash --run_test=limitedmap_tests,llmq_chainlock_tests,llmq_signing_tests,llmq_dkg_tests,evo_islock_tests
  • git diff --check upstream/develop..HEAD
  • COMMIT_RANGE=upstream/develop..HEAD test/lint/lint-whitespace.py
  • test/lint/lint-circular-dependencies.py
  • test/lint/lint-include-guards.py
  • Scoped clang-format verification for the changed ChainLock files
  • Negative control: restoring the single-argument constructor makes the new handler regression test fail; restoring the fix passes it
  • Exact-head pre-PR code-review gate: ship, zero findings

Breaking Changes

None.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone (for repository code-owners and collaborators only)

dashpay#7424 bounded ChainlockHandler::seenChainLocks by switching it to
an unordered_limitedmap constructed as `seenChainLocks{MAX_SEEN_CHAINLOCKS}`.
unordered_limitedmap defaults nPruneAfterSize to nMaxSize, so prune() runs on
every unique insertion past 1024 entries: it allocates a vector of all 1025
iterators, std::sort()s it in full, and erases a single element, all while
ChainlockHandler::cs is held.

ProcessNewChainLock records the CLSIG hash before both the stale-height early
return and VerifyChainLock, and stale-height CLSIGs carry no misbehavior
penalty, so a peer could turn a stream of unique, unverified CLSIG hashes into
a stream of O(n log n) sorts under cs -- CPU amplification on the ChainLocks
relay path.

Construct the cache with an explicit prune-after size of twice the retained
size instead. The map now grows to 2048 entries and is pruned back to 1024 in
one batch, amortising each sort over 1024 evictions rather than one, at the
cost of a larger transient cache. The 2x ratio matches the default already used
by unordered_lru_cache. Note that pruning less often also reduces the chance
that an entry is evicted by the same prune that inserted it, since entries
inserted within one second share a timestamp and tie under prune()'s
comparator.

Stale CLSIG duplicate suppression is unchanged: hashes are still recorded
before the stale-height return, and the 24h time-based Cleanup() is unaffected.

Naming now distinguishes the retained size from the temporary prune threshold
(MAX_SEEN_CHAINLOCKS -> SEEN_CHAINLOCKS_RETAINED_SIZE plus a new
SEEN_CHAINLOCKS_PRUNE_AFTER_SIZE), and the constructor's defaulted
nPruneAfterSize is documented as the footgun it is.

Tests: two new limitedmap cases prove the generic container semantics -- no
prune at retained max+1, growth bounded by the trigger, and a batch prune back
to the retained size on crossing it -- plus the default-threshold behavior.
The ChainLock handler test no longer asserts a strict instantaneous 1024 cap;
it now verifies how the handler wires the cache up and that growth past the
retained size is retained until the trigger is crossed. Verified the handler
test fails when the fix is reverted.
@thepastaclaw

thepastaclaw commented Jul 25, 2026

Copy link
Copy Markdown
Author

✅ Final review complete — no blockers (commit 64cf27d)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final validation — Codex + Sonnet

The change correctly configures the ChainLock seen cache to retain 1,024 entries while allowing growth to 2,048 before batch pruning, amortizing the peer-triggerable sort cost without changing stale CLSIG duplicate suppression or cleanup behavior. The generic container semantics, handler integration, focused tests, commit history, and exact-head diff are internally consistent, with no correctness or Dash-specific integration issues identified.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: gpt-5.6-sol — final-verifier (fallback)
  • Sonnet reviewers: claude-sonnet-5 — general (failed), claude-sonnet-5 — dash-core-commit-history (failed), claude-sonnet-5 — general (failed), claude-sonnet-5 — dash-core-commit-history (completed), claude-sonnet-5 — general (completed)

@PastaPastaPasta
PastaPastaPasta marked this pull request as ready for review July 29, 2026 00:39

@PastaPastaPasta PastaPastaPasta left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK 64cf27d

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c58b1aef-2c64-4d41-ad80-a0594e69b6bd

📥 Commits

Reviewing files that changed from the base of the PR and between 6d04c60 and 64cf27d.

📒 Files selected for processing (5)
  • src/chainlock/handler.cpp
  • src/chainlock/handler.h
  • src/limitedmap.h
  • src/test/limitedmap_tests.cpp
  • src/test/llmq_chainlock_tests.cpp

Walkthrough

unordered_limitedmap now supports a separate prune-after threshold and exposes it through a getter. ChainlockHandler configures seenChainLocks with retained and prune-after sizes, replacing its former single-capacity setting. Testing accessors and chainlock tests were updated to validate delayed batch pruning, default immediate pruning, cache boundedness, and AlreadyHave behavior after eviction.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • dashpay/dash#7424: Introduced the bounded unordered_limitedmap and earlier ChainlockHandler cache size test accessors.

Suggested reviewers: knst, kwvg, pastapastapasta

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: amortizing ChainLock seen-cache pruning.
Description check ✅ Passed The description matches the changeset and explains the cache-pruning and test updates.
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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants