Skip to content

Stop rescanning the miner store for every gossiped block - #21

Closed
foysavas wants to merge 4 commits into
mainfrom
fix/fork-scoring-wedge
Closed

foysavas wants to merge 4 commits into
mainfrom
fix/fork-scoring-wedge

Conversation

@foysavas

Copy link
Copy Markdown
Contributor

Why

On 2026-10-18 the public testnet had no shared chain: no sequencer answered /status.json, node0 was frozen at tip 4887, and node1, node2, and node3 each mined alone (10196, 15330, 11762) at the difficulty floor, while a non-bootstrap miner gossiped a fourth chain near 18.6k.

Every gossiped miner block ran a fork-choice pass that decoded the whole store several times and did work quadratic in stored blocks, under the one datastore mutex. Nothing was pruned, so after 15 days a pass took about five minutes. Status, the Identify handler on the networking task, and Shoal broadcasts all waited on that lock. Peers stopped seeing each other's blocks and each node mined its own branch. This is the third failure of this shape; earlier fixes made steps of the same pass cheaper without removing the per-block rescan.

What changes

Nothing waits on scoring

  • /status.json comes from a snapshot retaken every 5 s in the background, with status_age_secs. A wedged node answers with a growing age instead of hanging. Before the first snapshot exists the endpoint returns 503.
  • Identify stores peer info off the networking task.
  • Shoal sends wait at most 2 s for the swarm.

Incremental fork choice

  • An exact in-memory mirror of the three miner stores, with a change journal. All multi_store writes update it, and scans read it in the same key order.
  • A tree over that mirror tracks:
    • linked work for each block;
    • the winner and canonical spine tips;
    • the tips of linked branches.
      It relinks only what changed. The target check is memoized per hash and walks back only to the previous epoch.
  • select_best_stored_chain and adopt_connected_extensions decide on the tree, with the same guards and log lines. They fall back to the full scan for cases the tree cannot decide.
  • A block off every linked walk is stored without being scored.

Bounded untrusted gossip

  • Per-peer rate limit on miner blocks: 1/s, burst 120.
  • Parked-block caps: 1024 per author, 8192 in total.
  • Parents are requested from the block's author, not only from bootstrappers, at most once per minute per peer.

Pruning

  • Blocks that are not canonical and sit two epochs under the latest checkpoint are pruned.
  • Chain validation indexes blocks instead of searching per index.

Tests

  • Scoring one gossiped block: about 250 µs (debug build) at both 2k and 20k stored blocks.
  • Randomized, 600 stores each:
    • tree select vs. full scan (including checkpoint floors and missed targets);
    • canonical set vs. verified_spine;
    • adoption leaves no branch that beats the chain;
    • incremental tree vs. rebuilt tree.
  • The randomized tests show two full-scan bugs the tree does not repeat:
    • a block demoted off the spine that is on the winner stays demoted, leaving a hole under the tip;
    • store-order adoption can take a lighter branch and then orphan the heavier one.
  • cargo test -p modality-node -p modality-datastore -p modality-observer -p modality-cli-node, workspace check (including tests and examples) with -D warnings, and clippy -D warnings.
  • Local 3-node hybrid devnet: one genesis, one tip, rounds rising after epoch 2, status answering in under 1 ms with age ≤ 5 s, and a restart on existing data that kept the chain.
  • tests/network/11-hybrid-devnet3 and 04-sync-miner-blocks fail the same way on main:
    • node1 is still in its 30 s chain-info timeout at the test's 15 s check;
    • the 04 test config predates config_version 2.

Follow-ups

  • Gossipsub message validation and peer scoring, so floods also stop propagating.
  • Select's fills_parents rule can still adopt a stored chain that contains a block below its target, whenever that chain extends the local one. Peer batches do not check proofs.

🤖 Generated with Claude Code

foysavas and others added 4 commits October 18, 2026 13:57
On 2026-10-18 the public testnet had no shared chain. Every gossiped miner
block ran a fork-choice pass that decoded the whole store several times and
did work quadratic in stored blocks, all under the one datastore mutex.
Nothing was pruned, so after 15 days a pass took about five minutes.
Status, the Identify handler on the networking task, and Shoal broadcasts
all waited on that lock, so peers stopped seeing each other's blocks and
node1, node2, and node3 each mined alone. A non-bootstrap miner's
unlinkable chain kept feeding the queue.

Nothing waits on scoring:
- /status.json is served from a snapshot a background task retakes every
  five seconds; `status_age_secs` shows how old it is, so a wedged node
  answers with a growing age instead of hanging. No snapshot yet is 503.
- Identify stores peer info off the networking task.
- Shoal draft, certificate, and ack sends wait at most two seconds for the
  swarm.

Fork choice is incremental:
- The datastore keeps an exact in-memory mirror of the three miner stores,
  updated by every multi_store write, with a change journal. Store scans
  read the mirror in the same key order, so results do not change.
- A tree derived from it keeps each live block's linked work, the winner
  and canonical spine tips, and the linked branch tips, and relinks only
  what changed. The target check is memoized per hash and walks back only
  to the previous epoch.
- select_best_stored_chain and adopt_connected_extensions decide on the
  tree with the same guards and log lines, and fall back to the full scan
  where it cannot decide. A block off every linked walk is stored without
  scoring.
- Scoring a gossiped block takes about 250us in a debug build at both 2k
  and 20k stored blocks (test). Randomized tests check select against the
  full scan, the canonical set against verified_spine, adoption against
  the rule that no branch beats the chain, and the incremental tree
  against a rebuilt one. They also show two full-scan bugs the tree does
  not repeat: a demoted block on the winner left a hole under the tip, and
  adoption in store order could take a lighter branch and orphan the
  heavier one.

Untrusted gossip is bounded:
- Miner blocks are rate limited per forwarding peer (1/s, burst 120).
- Parked blocks are capped per author (1024) and in all (8192); the oldest
  still unlinked is dropped.
- Missing parents are requested from the block's author, not only from
  bootstrappers, at most once a minute per peer.

The store stops growing with every fork: blocks that are not canonical
two epochs under the latest sequencer checkpoint are pruned, since fork
choice already refuses any chain that leaves that prefix. Chain validation
indexes blocks once instead of searching per index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Introduced a caching mechanism for verdicts of `mined_header_holds`, utilizing a `OnceLock` and `Mutex` to store results and reduce redundant computations.
- Added a limit to the cache size to prevent excessive memory usage, ensuring efficient management of verdicts during mint checks.
- Refactored the `mined_header_holds` function to leverage the new caching logic, improving performance during header validation processes.
- Updated the `retarget_params` and `with_synced_tree` functions to public visibility for broader access within the module.

These changes enhance the performance of header validation by minimizing repeated calculations and optimizing resource usage.
Upgrading the testnet hosts to the previous commit left all three
sequencers answering status with 503 for over ten minutes. A copy of
node2's store, run offline, showed why: the MOD minter checks a mint by
replaying the contract's whole log, and `mined_headers` grinds every
header of every replayed mint through RandomX again. At epoch 385 that is
about 15,000 RandomX hashes per mint, under the datastore lock, and it
grows with every epoch. Fork choice on the same store took under a
millisecond.

- `mined_header_holds` is a pure function of the header, index and hash
  function, so its verdict is kept per process. A mint now grinds only its
  own headers. The first replay after a start still grinds each header
  once: about 16 s on the copied store in a release build (it never
  finished in a debug build before).
- The networking loop logged every swarm event at info, pings and
  Identify included, to the journal and to `logs/node.log`. Those files
  reached 40-45 GB on the hosts and filled node0's disk. They are debug
  now.
- `examples/replay_store.rs` times the fork-choice, startup, sync, and
  maintenance reads on a copied store; nothing leaves the machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Refactored the `mined_header_holds` function to improve efficiency by caching verdicts, minimizing redundant computations during mint checks.
- Adjusted logging levels for swarm events to reduce disk usage, transitioning from info to debug level, preventing excessive log file growth.
- Enhanced performance of header validation processes and reduced the impact of logging on system resources.

These changes collectively improve the efficiency of header processing and resource management in the system.
@foysavas

Copy link
Copy Markdown
Contributor Author

Landed on main directly: 0c35814 merges 842d45b (testnet release 20261018_182417-842d45b8), and d76ac02 carries the Rust tree of c3f8109 (20261018_201039-c3f81097, tag testnet-mint-memo). The examples/multi-agent-smax commits that rode on this branch are kept on the examples/multi-agent-smax branch.

🤖 Generated with Claude Code

@foysavas foysavas closed this Oct 19, 2026
@foysavas
foysavas deleted the fix/fork-scoring-wedge branch October 19, 2026 20:21
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