Skip to content

test: parallelize data recovery masternode restarts - #7500

Open
thepastaclaw wants to merge 1 commit into
dashpay:developfrom
thepastaclaw:perf/parallel-test-restarts
Open

test: parallelize data recovery masternode restarts#7500
thepastaclaw wants to merge 1 commit into
dashpay:developfrom
thepastaclaw:perf/parallel-test-restarts

Conversation

@thepastaclaw

Copy link
Copy Markdown

Issue being fixed or feature implemented

feature_llmq_data_recovery.py repeatedly restarts several masternodes serially. Process startup, reindexing, and mnsync waits dominate this restart-heavy functional test, especially under sanitizers.

What was done?

Add a focused DashTestFramework.restart_masternodes() helper and migrate only the data-recovery test to it.

The helper:

  • signals all selected nodes to stop, then waits for all of them;
  • starts nodes and completes mnsync in parallel using the existing ThreadPoolExecutor pattern from start_masternodes();
  • reconnects each node to the controller serially, preserving the repository's historical avoidance of parallel connect_nodes() calls;
  • preserves exclusion, reindex-height waiting, custom restart arguments, and exception propagation;
  • deduplicates overlapping quorum-member lists by node index before scheduling restarts.

No polling changes or other functional-test callsites are included.

How Has This Been Tested?

  • python3 -m py_compile test/functional/test_framework/test_framework.py test/functional/feature_llmq_data_recovery.py
  • test/lint/lint-python.py
  • git diff --check upstream/develop...HEAD
  • Exact-head code review: ship, zero findings
  • Four balanced A/B functional runs using the same local dashd build, alternating baseline and candidate with unique datadirs and port seeds; all runs passed
Variant Runs (seconds) Midpoint
upstream/develop 168.270, 171.687 169.979s
This PR 133.473, 129.478 131.476s

Improvement: 38.503 seconds (22.7%).

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

@thepastaclaw

thepastaclaw commented Jul 30, 2026

Copy link
Copy Markdown
Author

✅ Final review complete — no blockers (commit c46d48a)

@thepastaclaw

thepastaclaw commented Jul 30, 2026

Copy link
Copy Markdown
Author

Changed-test CI timing comparison

Test: feature_llmq_data_recovery.py

Develop baseline: d41edd90 (successful develop CI push run 30402917810; exact base 2e52d33f run 30483914944 failed)
PR run: e28d85d4 (run 30515383710)

CI configuration develop baseline PR delta change
linux64 163s 145s -18s -11.0%
linux64_ubsan 170s 132s -38s -22.4%
linux64_tsan 311s 199s -112s -36.0%
linux64_sqlite 161s 131s -30s -18.6%
linux64_multiprocess 233s 174s -59s -25.3%
Total across applicable configurations 1038s 781s -257s -24.8%

The changed test was faster in all five applicable CI configurations, saving 4m 17s across them in this pair of runs. linux64_nowallet is excluded because the test is skipped when wallet support is not compiled.

These are the functional test runner's per-test durations from the CI logs, not whole-job or whole-suite runtimes.

The develop TSAN execution failed once after 45s and then passed in 311s. The table uses the final successful attempt and excludes that extra failed attempt so the comparison is not inflated by a baseline retry.

@PastaPastaPasta
PastaPastaPasta requested review from UdjinM6 and knst July 30, 2026 16:06
@PastaPastaPasta
PastaPastaPasta marked this pull request as ready for review July 30, 2026 16:07
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The test framework now centralizes masternode restart selection, stopping, parallel startup, optional block-height waiting, synchronization completion, and controller reconnection. The LLMQ data recovery test delegates to this method and provides per-node arguments for data recovery, quorum-vector synchronization, and reindexing.

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

Sequence Diagram(s)

sequenceDiagram
  participant QuorumDataRecoveryTest
  participant DashTestFramework
  participant ThreadPoolExecutor
  participant Masternodes
  QuorumDataRecoveryTest->>DashTestFramework: restart_masternodes(...)
  DashTestFramework->>Masternodes: stop selected nodes
  DashTestFramework->>ThreadPoolExecutor: start selected nodes in parallel
  ThreadPoolExecutor->>Masternodes: start with recovery arguments
  DashTestFramework->>Masternodes: wait for block height and finish sync
  DashTestFramework->>Masternodes: reconnect to controller
Loading

Suggested reviewers: knst, udjinm6

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: parallelizing masternode restarts for the data recovery test.
Description check ✅ Passed The description is directly related to the code changes and accurately describes the new restart helper and test migration.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
test/functional/test_framework/test_framework.py (1)

1838-1850: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated arg-building logic between restart_one and start_masternode.

The wait_block_count branch reconstructs the -masternodeblsprivkey=... + self.extra_args[mn.nodeIdx] + extra_args sequence inline instead of reusing start_masternode (lines 1865-1870). If start_masternode's argument construction ever changes, this inlined copy will silently drift out of sync.

Consider extracting a small helper for building the arg list, shared by both start_masternode and this branch.

♻️ Proposed refactor to share arg-building logic
+    def _masternode_start_args(self, mn: MasternodeInfo, extra_args=None):
+        args = ['-masternodeblsprivkey=%s' % mn.keyOperator] + self.extra_args[mn.nodeIdx]
+        if extra_args is not None:
+            args += extra_args
+        return args
+
     def start_masternode(self, mninfo: MasternodeInfo, extra_args=None):
-        args = ['-masternodeblsprivkey=%s' % mninfo.keyOperator] + self.extra_args[mninfo.nodeIdx]
-        if extra_args is not None:
-            args += extra_args
-        self.start_node(mninfo.nodeIdx, extra_args=args)
+        self.start_node(mninfo.nodeIdx, extra_args=self._masternode_start_args(mninfo, extra_args))
         force_finish_mnsync(mninfo.get_node(self))

And in restart_one:

-            args = ['-masternodeblsprivkey=%s' % mn.keyOperator] + self.extra_args[mn.nodeIdx]
-            if extra_args is not None:
-                args += extra_args
-            self.start_node(mn.nodeIdx, extra_args=args)
+            self.start_node(mn.nodeIdx, extra_args=self._masternode_start_args(mn, extra_args))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/functional/test_framework/test_framework.py` around lines 1838 - 1850,
Extract the masternode argument construction from start_masternode into a shared
helper, including the operator key, self.extra_args[mn.nodeIdx], and optional
extra_args. Update both start_masternode and the wait_block_count branch of
restart_one to use this helper so their startup arguments remain synchronized.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/functional/test_framework/test_framework.py`:
- Around line 1838-1850: Extract the masternode argument construction from
start_masternode into a shared helper, including the operator key,
self.extra_args[mn.nodeIdx], and optional extra_args. Update both
start_masternode and the wait_block_count branch of restart_one to use this
helper so their startup arguments remain synchronized.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 408dfd03-63fc-411d-b572-e356ab6a2fbf

📥 Commits

Reviewing files that changed from the base of the PR and between 2e52d33 and e28d85d.

📒 Files selected for processing (2)
  • test/functional/feature_llmq_data_recovery.py
  • test/functional/test_framework/test_framework.py

Add DashTestFramework.restart_masternodes using the existing
ThreadPoolExecutor start pattern: stop selected MNs first, start and
force_finish_mnsync in parallel, then connect_nodes serially to the
controller. Migrate feature_llmq_data_recovery.restart_mns through the
helper without changing recovery, reindex, exclude, or post-restart
sync semantics.

Benchmark: feature_llmq_data_recovery ~38.5s / 22.7% faster
(baseline 168.270/171.687s vs candidate 133.473/129.478s).
@thepastaclaw
thepastaclaw force-pushed the perf/parallel-test-restarts branch from e28d85d to c46d48a Compare July 30, 2026 16:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/functional/test_framework/test_framework.py (1)

1816-1828: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Key exclude by nodeIdx for consistency with the dedupe set.

Dedupe uses mn.nodeIdx while exclude relies on object identity (MasternodeInfo defines no __eq__). Current callers pass the same instances from self.mninfo, so it works, but a caller that reconstructs MasternodeInfo for the same node would silently skip the exclusion.

♻️ Optional: index-based exclusion
-        if exclude is None:
-            exclude = []
+        excluded_idx = set() if exclude is None else {mn.nodeIdx for mn in exclude}
@@
-            if mn in exclude or mn.nodeIdx in seen:
+            if mn.nodeIdx in excluded_idx or mn.nodeIdx in seen:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/functional/test_framework/test_framework.py` around lines 1816 - 1828,
Update the selection logic around the exclude and seen sets to normalize
excluded MasternodeInfo entries by nodeIdx, then compare each candidate’s
mn.nodeIdx against that index set. Preserve the existing order, deduplication,
and empty-selection behavior in the surrounding selection flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/functional/test_framework/test_framework.py`:
- Around line 1830-1863: The reindex restart path in restart_one must pass an
explicit, reasonably bounded timeout to wait_until when waiting for
wait_block_count, rather than relying on the framework default. Preserve the
existing height predicate and force_finish_mnsync flow while choosing a duration
that accommodates slow TSAN/UBSAN runs and still fails deterministically.

---

Nitpick comments:
In `@test/functional/test_framework/test_framework.py`:
- Around line 1816-1828: Update the selection logic around the exclude and seen
sets to normalize excluded MasternodeInfo entries by nodeIdx, then compare each
candidate’s mn.nodeIdx against that index set. Preserve the existing order,
deduplication, and empty-selection behavior in the surrounding selection flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b3c68ee8-1fed-4a5f-ba80-abc017fdf5a6

📥 Commits

Reviewing files that changed from the base of the PR and between e28d85d and c46d48a.

📒 Files selected for processing (2)
  • test/functional/feature_llmq_data_recovery.py
  • test/functional/test_framework/test_framework.py

Comment on lines +1830 to +1863
self.log.info("Restarting %d masternodes in parallel", len(selected))

# Issue stop RPCs first, then wait — same ordering as stop_nodes()
for mn in selected:
self.nodes[mn.nodeIdx].stop_node(wait_until_stopped=False)
for mn in selected:
self.nodes[mn.nodeIdx].wait_until_stopped()

def restart_one(mn: MasternodeInfo):
extra_args = None if extra_args_cb is None else extra_args_cb(mn)
if wait_block_count is None:
self.start_masternode(mn, extra_args=extra_args)
return
# With -reindex, wait for height before force_finish_mnsync.
self.start_node(
mn.nodeIdx,
extra_args=self._masternode_start_args(mn, extra_args=extra_args),
)
# Default-arg capture avoids late-binding of mn in the wait predicate.
self.wait_until(lambda mn=mn: mn.get_node(self).getblockcount() >= wait_block_count)
force_finish_mnsync(mn.get_node(self))

executor = ThreadPoolExecutor(max_workers=20)
try:
jobs = [executor.submit(restart_one, mn) for mn in selected]
for job in jobs:
job.result()
finally:
executor.shutdown(wait=True)

# Connect serially to the controller only.
for mn in selected:
self.connect_nodes(mn.nodeIdx, 0)
return selected

@coderabbitai coderabbitai Bot Jul 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect prior restart/reindex wait usage and framework wait_until default
rg -nP -C4 '\bdef wait_until\s*\(' test/functional/test_framework/test_framework.py
git log -p -1 --unified=6 -- test/functional/feature_llmq_data_recovery.py | rg -nP -C4 'wait_until|getblockcount|reindex' | head -60

Repository: dashpay/dash

Length of output: 659


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== surrounding code =="
sed -n '1800,1875p' test/functional/test_framework/test_framework.py

echo "== timeout_factor option / parser =="
rg -n -C3 'timeout_factor|timeout' test/functional/test_framework/test_framework.py | head -120

echo "== target diff for function rename/move context =="
set +u
git diff --unified=20 -- test/functional/test_framework/test_framework.py | sed -n '1,260p'
set -u

Repository: dashpay/dash

Length of output: 9642


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== wait_until definition =="
sed -n '895,912p' test/functional/test_framework/test_framework.py

echo "== callers / diffs around start_masternodes/restart_masternodes and wait_block_count =="
rg -n -C4 'restart_masternodes|start_masternodes|wait_block_count|wait_until\(.*getblockcount' test/functional/test_framework/test_framework.py test/functional | head -200

echo "== feature_llmq_data_recovery relevant changes in any available diff/status =="
git diff -- test/functional/feature_llmq_data_recovery.py | sed -n '1,240p' || true

Repository: dashpay/dash

Length of output: 18924


Set an explicit timeout for the reindex height wait.

This path defaults to wait_until’s framework 60s timeout, but the current PR does not show an explicit duration, while full -reindex restarts with TSAN/UBSAN can exceed that. Use an explicit, reasonably bounded timeout here so slow sanitizer configs fail deterministically instead of flapping on the framework default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/functional/test_framework/test_framework.py` around lines 1830 - 1863,
The reindex restart path in restart_one must pass an explicit, reasonably
bounded timeout to wait_until when waiting for wait_block_count, rather than
relying on the framework default. Preserve the existing height predicate and
force_finish_mnsync flow while choosing a duration that accommodates slow
TSAN/UBSAN runs and still fails deterministically.

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.

Thanks — leaving as-is for now. This is a minor maintainability nit on a low-risk test helper; happy to clean it up in a follow-up if it becomes load-bearing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@thepastaclaw, understood — keeping this scoped as-is is reasonable. The current framework timeout remains a known trade-off if reindex restart duration becomes a source of sanitizer flakiness. 🐇

You are interacting with an AI system.

@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

This is a clean, low-risk test-infrastructure refactor that adds a restart_masternodes() helper mirroring the framework's existing parallel-start / serial-stop patterns, and migrates feature_llmq_data_recovery.py to use it while preserving exclusion, reindex, and mnsync semantics. No blocking or consensus-relevant issues were found; the sole valid finding is a minor nitpick about an equality-check inconsistency between the new dedup and exclude logic, which is theoretical risk only given the single current call site.

Review provenance

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

💬 1 nitpick(s)

Comment on lines +1816 to +1826
if exclude is None:
exclude = []
# Preserve caller order but drop duplicates: some tests pass multiset
# concatenations of quorum membership lists (e.g. feature_llmq_data_recovery).
selected = []
seen = set()
for mn in (self.mninfo if mns is None else mns):
if mn in exclude or mn.nodeIdx in seen:
continue
seen.add(mn.nodeIdx)
selected.append(mn)

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.

💬 Nitpick: exclude check uses identity equality while dedup uses nodeIdx

In restart_masternodes(), the new dedup set (seen) is keyed by mn.nodeIdx, but the exclude check (mn in exclude) relies on MasternodeInfo's default identity-based __eq__ — the class has no @dataclass decorator and defines no custom __eq__. This mismatch is newly introduced by this refactor (the prior per-test serial code never needed to dedupe or exclude in one pass). It works correctly today because the only call site (feature_llmq_data_recovery.py) always passes the same MasternodeInfo instances from self.mninfo, so identity equality happens to align with node index. But if a future caller reconstructs an equivalent MasternodeInfo for the same node index and passes it via exclude, the exclusion would silently fail while dedup-by-index would still succeed — a confusing footgun for anyone who copies this pattern. Low risk given the single current call site, but keying both checks on nodeIdx removes the inconsistency for free.

Suggested change
if exclude is None:
exclude = []
# Preserve caller order but drop duplicates: some tests pass multiset
# concatenations of quorum membership lists (e.g. feature_llmq_data_recovery).
selected = []
seen = set()
for mn in (self.mninfo if mns is None else mns):
if mn in exclude or mn.nodeIdx in seen:
continue
seen.add(mn.nodeIdx)
selected.append(mn)
excluded_idx = set() if exclude is None else {mn.nodeIdx for mn in exclude}
selected = []
seen = set()
for mn in (self.mninfo if mns is None else mns):
if mn.nodeIdx in excluded_idx or mn.nodeIdx in seen:
continue
seen.add(mn.nodeIdx)
selected.append(mn)

source: ['claude']

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