refactor(blockchain): keep the aggregation worker running at all times - #603
refactor(blockchain): keep the aggregation worker running at all times#603MegaRedHand wants to merge 4 commits into
Conversation
🤖 Kimi Code ReviewI'll review this PR which refactors the aggregation system from per-slot sessions to a continuous always-on worker thread. This is a significant architectural change with security and correctness implications for consensus. High-Level AssessmentThe refactoring moves from a session-based model (start at interval 2, deadline, early-start trigger) to a continuous worker that picks jobs greedily. This is cleaner architecturally but introduces new concurrency patterns that need careful review. Critical Issues1. Race Condition in
|
| Priority | Item | File:Line | Action |
|---|---|---|---|
| High | Worker panic = silent aggregation death | aggregation.rs:166 |
Metric + consider actor panic |
| High | Unbounded channel to actor | implied | Verify boundedness or add backpressure |
| Medium | No per-slot work cap on worker | aggregation.rs:run_aggregation_worker |
Add MAX_PROOFS_PER_SLOT or similar |
| Medium | pending_aggregates unbounded growth |
lib.rs:1318 |
Add hard cap or ring buffer |
| Low | usize::MAX sentinel in gate |
aggregation.rs:394 |
Use enum for clarity |
| Low | Metrics name mismatch | metrics.rs:1009 |
Rename or re-label |
| Low | Stale comment | store.rs:363 |
Update reference |
| Low | `Ordering |
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code Review
I could not run the Rust tests here because the environment could not fetch the Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview: PR #603 — always-on aggregation workerOverall this is a well-reasoned redesign and the accompanying docs/tests are thorough. The vote-propagation gate and the apply-on-arrival/publish-later split are both correctly implemented and match the documented boundary semantics (verified via 1. No crash recovery for the long-lived worker thread (reliability regression)
Under the old per-slot-session design, a panicking proof only cost that one slot's session — the next slot spawned a brand-new worker, so aggregation self-healed. With this change, a single panic anywhere in the loop permanently and silently disables aggregation for the rest of the node's uptime (a node that's the network's sole Suggest either:
2.
|
An aggregation session existed to fit XMSS proving into interval 2: snapshot the pool on the actor thread, spawn a worker for at most MAX_AGGREGATION_JOBS jobs, cancel it at a soft deadline, do it again next slot. The prover sat idle for most of the slot and then had one interval to do everything, which is what the early-start window and the job caps were compensating for. One worker thread now starts with the actor and lives as long as it does. It holds its own Store handle, so it re-reads the pool itself: rank the candidates, prove the best one, hand it to the actor, rank again. The actor applies each aggregate on arrival, since the pool the worker re-reads has to account for it or the same group gets proved twice, but buffers the gossip publication until the vote-aggregation interval. What the network sees is unchanged. A plain std::thread rather than a spawn_blocking task: it runs for the life of the process and spends it in leanVM proofs, so the blocking pool would lose a thread permanently and buy nothing, since the loop awaits nothing and reaches the actor through an unbounded channel. What the worker may take up is now a function of where the slot is (JobPolicy), which is what replaces the session machinery: - Early in the slot: backlog work — stale groups, merges of proofs already in the pool — plus a current-slot group that already holds two thirds of the signatures this node expects, so a slot's votes still go out as one wide aggregate rather than several thin ones. - Inside the last EARLY_AGGREGATION_WINDOW before the boundary: that group and nothing else. A backlog job is a recursive merge that can run well past the boundary, and the prover is single-threaded, so starting one there would delay the aggregate the whole slot is waiting on. Idling costs little by comparison, and this window is where the committee's signatures usually cross the threshold anyway. - From the boundary on: everything, however few signatures back it. The actor can also park the worker outright, raising a pause flag around its own block build, which is what the max_jobs=1 proposer cap used to buy. The worker also remembers the coverage it emitted per slot. The actor applies an aggregate only once the message reaches it, so between send and apply the store still shows the job as pending and the next round would re-prove it. lean_aggregation_early_starts_total and lean_aggregation_early_start_lead_seconds go with the window they measured. lean_committee_signatures_aggregation_time_seconds now times one aggregate's proof instead of a session's total, and the aggregate arrival series is sampled at publication, keeping it comparable with the peers' aggregates it shares a histogram with.
18e53b0 to
abce001
Compare
Local devnet: 3 ethlambda nodes, 1 aggregator, 20 slots
Finality kept up: The split between proving and publishing is visible in the timingOffsets into the slot, from the aggregator's log (interval boundaries at t+0 / 800 / 1600 / 2400 / 3200):
The proof lands ~200 ms after the votes do — as soon as the group crosses two thirds, well inside interval 1 — while publication sits on the interval-2 boundary to the millisecond. That is exactly the intent: proving off the grid, publication on it. The gate does what it is forEvery aggregate was The single How it was runDOCKER_TAG=pr603 make docker-build
.claude/skills/devnet-runner/scripts/run-devnet-with-timeout.sh 140Genesis keys came from |
#598 made the slot duration a config-file value, so every timing this branch reads off the wall clock had to stop being a compile-time constant. The two branches disagreed over the same code: #598 threaded `ChainConfig` through the aggregation session's deadline and early-start window, while this branch deletes the session entirely. The session machinery loses, and the always-on worker picks up the configurable grid: - `VOTE_AGGREGATION_OFFSET_MS` becomes `vote_aggregation_offset_ms`, reading the offset off `SlotInterval::Aggregation` rather than multiplying a constant, so `job_policy`'s boundaries scale with the configured cadence. - The worker reads the chain's time grid once at startup and threads it through `next_job` into `job_policy`, replacing the bare `genesis_time_ms` it used to carry. The grid is written once at bootstrap and never rewritten, so one read covers the thread's whole life. - `EARLY_AGGREGATION_WINDOW` stays fixed: it protects wall time for one leanVM proof, and a proof costs the same whatever the slot duration. Its const assert now bounds it against the narrowest grid a config file can ask for, since that is the case where the subtraction could underflow. - The `job_policy` test runs at both the default cadence and 8 s, which is what the merge is really about. Kept from main on top of the branch's own rewrites: the `is_arrival_observable` gate on the two gossip arrival metrics, their `&ChainConfig` signatures, and the `SlotInterval` conversion tests. Dropped `aggregation_deadline_is_one_interval` with the deadline it tested.
CLAUDE.md still described aggregation as an interval-2 duty. Proving now runs continuously on its own thread and interval 2 owns only publication, so a session reading the old text would go looking for a per-slot session that no longer exists. Adds the worker to the architecture patterns: what it owns, why it is a plain thread, the apply-on-arrival/publish-later split, and the two pieces that guard the single prover (JobPolicy and the actor's pause flag).
The worker derived its slot from the wall clock while the actor drives the
interval grid off the store clock, so the two could disagree about which slot
is current: the wall clock drifts behind the monotonic tick cadence inside
VMs, and a long block build leaves the store clock ahead of it. Under a
disagreement `select_best_job` buckets the slot's real group as stale and
proves it below `min_sigs`, or holds a stale group back to a boundary that
already passed. The slot now comes from `store.current_slot()`; only the
sub-interval position, which the store clock cannot express, still comes from
the wall clock, measured from that slot's start and clamped to it.
The worker also ran unconditionally while the node was behind, proving a
backlog nobody is waiting on against the same single-threaded prover the
import path needs to close the gap. It now parks itself under the sync gate,
reading the shared `SyncStatusController` the way it already reads the
aggregator role, plus its own copy of the startup-fixed `gate_duties` flag so
`--disable-duty-sync-gate` keeps the gate observe-only. This is not a
`pause()` guard on purpose: that flag is a plain bool with room for one
holder, which `propose_block` owns, and the constraint is now documented on
`pause` itself.
`pending_aggregates` held a second copy of each produced proof in an unbounded
Vec with no subsumption, so a straggler signature that re-proved a group
queued a near-duplicate up-to-512 KiB proof behind the one it superseded. It
now buffers only the `AttestationData`, keyed by data root, and publication
reads the proof back out of the payload pool via the new
`Store::widest_proof_for_data`. `PayloadBuffer` already dedups and caps, so
re-proving replaces the entry, and what reaches the wire is the widest proof
held for that data.
`emit_agg_start_new_coverage` had moved out of the aggregator-only branch, so
`agg_start_new` mixed aggregator production with what reached a non-aggregator
over gossip. Restored to aggregators only.
Metrics documentation had drifted with the session model it described:
`lean_committee_signatures_aggregation_time_seconds` now times one proof
rather than a whole session, `aggregator_skipped{reason="other"}` counts
failed proofs rather than jobs a cancelled session dropped, and
`reason="not_synced"` fires for the first time, once per vote-aggregation
interval on an aggregator the sync gate is parking. Local aggregates now land
in the histogram's lowest bucket by construction, so the gossip-arrival prose
no longer claims the tail measures local proving.
What
Aggregation stops being a per-slot session. One worker thread starts with the blockchain actor and lives as long as it does, holding its own
Storeclone: rank the pool's candidates, prove the best job, hand it to the actor, rank again. The actor buffers each aggregate and gossips the lot at the vote-aggregation interval.Why
The session existed to fit XMSS proving into one interval. The prover sat idle for most of the slot and then had ~800ms to do everything, which is what the early-start window and the job caps were compensating for. Splitting proving from publishing removes the deadline without moving what the network sees: aggregates still only go out at interval 2.
Changes
aggregation.rsspawn_aggregation_worker+ a loop overselect_best_job, with aJobPolicyderived from the wall clock; sessions, session ids,AGGREGATION_DEADLINE,EarlyAggregationCheck,AggregationDeadline,AggregationDone,MAX_AGGREGATION_JOBSand the publish-alignment all gone.EARLY_AGGREGATION_WINDOWsurvives with a new job: it is now the stretch in which the worker keeps the prover free, not a licence to start earlylib.rsstarted()spawns the worker,stopped()joins it;pending_aggregatesbuffer drained at interval 2; pause flag raised aroundpropose_blockstorage/store.rsmax_gossip_group_count_for_slotdropped with its only caller (the early-start check)architecture.md,slots_and_intervals.md,spec_deviations.mdWhat replaces the session machinery
What the worker may take up is a function of where the slot is (
JobPolicy):EARLY_AGGREGATION_WINDOWEARLY_AGGREGATION_WINDOWbefore T2The floor (
min_current_slot_group_sigs) is the old early-start threshold: two thirds of the votes this node's own subnets are expected to carry. It keeps a slot's votes going out as one wide aggregate rather than several thin ones.The window is the part worth arguing about. A backlog job is a recursive proof merge that can run well past the boundary, and the prover is single-threaded, so starting one there would delay the very aggregate the slot is waiting on. Idling costs little by comparison: the backlog is not going anywhere, and this is the window where the committee's signatures usually cross the floor anyway.
Separately, the actor raises a pause flag around its own block build, which is what the
max_jobs = 1proposer cap used to buy. A proof already in flight is not interrupted.Two details worth a look
sendand the actor's apply, the store still shows the job as pending, so the next round would prove it again. The worker remembers the coverage it emitted per slot (EmittedCoverage) to close that window without becoming a store writer. Coverage is recorded on attempt, so a failing job is not retried at full prover cost either.A plain thread, not
spawn_blockingThe worker runs for the life of the process and spends it in leanVM proofs, so a blocking-pool thread would be parked permanently for nothing: the loop awaits nothing and reaches the actor through an unbounded channel that needs no reactor. Shutdown polls
is_finished()instead of blocking a runtime thread on the proof in flight, and leaves the thread detached pastWORKER_JOIN_TIMEOUT.Metrics
lean_aggregation_early_starts_total,lean_aggregation_early_start_lead_seconds— the window they measured is gone. Neither was documented indocs/metrics.md.lean_committee_signatures_aggregation_time_secondsnow times one aggregate's proof instead of a session's total.lean_gossip_aggregation_arrival_*samples our own aggregates at publication rather than production, keeping them comparable with the peers' aggregates they share a histogram with.lean_aggregator_skipped_total{reason="other"}now counts a job whose proof failed.Test status
make fmt,make lint(clippy-D warnings): cleancargo test --workspace --lib: 250 passed, 0 failedmainalready (fixtures pull rolling latest)