Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
25da38c
docs: add early aggregation start design spec
MegaRedHand Jul 1, 2026
5e4cfbf
docs: add early aggregation start implementation plan
MegaRedHand Jul 1, 2026
2b78240
docs: drop new-test steps from plan, add 4-node devnet validation task
MegaRedHand Jul 1, 2026
ee03c4a
docs: apply plan-review fixes (stale publish drain, cached genesis_ti…
MegaRedHand Jul 1, 2026
8ae3b07
feat(storage): add max gossip group count query for early aggregation
MegaRedHand Jul 1, 2026
433ac14
refactor(p2p): extract subscription subnet computation into pure helper
MegaRedHand Jul 1, 2026
1791da9
feat(blockchain): add early-aggregation window and threshold helpers
MegaRedHand Jul 1, 2026
07d7425
feat(blockchain): plumb aggregation subnet set and expected-signature…
MegaRedHand Jul 1, 2026
ff1af32
docs(blockchain): note genesis_time_ms cache covers tick-path recompu…
MegaRedHand Jul 2, 2026
1466af0
feat(blockchain): extend aggregation deadline to a full interval
MegaRedHand Jul 2, 2026
5e7fed5
feat(blockchain): hold early-produced aggregates until the interval-2…
MegaRedHand Jul 2, 2026
88c40bc
polish(blockchain): move early-agg metric helpers to Public API secti…
MegaRedHand Jul 2, 2026
76ed220
feat(blockchain): start aggregation early when 2/3 of subnet signatur…
MegaRedHand Jul 2, 2026
d57248f
docs(blockchain): note the once-per-slot latch hole for unresolvable …
MegaRedHand Jul 2, 2026
235d97f
docs: fix stale session-lifecycle comments flagged by final review
MegaRedHand Jul 2, 2026
d9fe68a
fix(blockchain): fence FlushAggregatePublishes by slot; guard subnet …
MegaRedHand Jul 2, 2026
98fa2ea
feat(blockchain): narrow early-aggregation window to 200ms
MegaRedHand Jul 2, 2026
50adb83
chore: drop internal design/plan docs from the PR
MegaRedHand Jul 2, 2026
8c4dc38
feat(blockchain): set early-aggregation window to 600ms
MegaRedHand Jul 2, 2026
e0907a6
refactor(blockchain): compute early-aggregation threshold in closed form
MegaRedHand Jul 2, 2026
ac628da
refactor(blockchain): read genesis time and early-agg threshold at ru…
MegaRedHand Jul 2, 2026
ed61bdc
refactor(blockchain): delay early aggregates in the worker, drop Flus…
MegaRedHand Jul 2, 2026
4ae261b
refactor(blockchain): pass worker publish time as SystemTime
MegaRedHand Jul 2, 2026
df18f77
refactor: inline single-use early-aggregation helpers; use if-else in…
MegaRedHand Jul 2, 2026
61d5490
refactor(blockchain): inline early_aggregation_slot into the trigger
MegaRedHand Jul 2, 2026
3c1a439
feat(blockchain): gate early-aggregation check on current-slot attest…
MegaRedHand Jul 2, 2026
49bfc88
revert(p2p): drop no-op build_swarm refactor churn
MegaRedHand Jul 2, 2026
ff7f831
Merge branch 'main' into feat/early-aggregation-start
MegaRedHand Jul 2, 2026
72cbf21
feat(blockchain): round the early-aggregation threshold up, not down
MegaRedHand Jul 3, 2026
632979d
refactor(blockchain): type the early-aggregation window as a Duration
MegaRedHand Jul 3, 2026
9f34c9a
Merge branch 'main' into feat/early-aggregation-start
MegaRedHand Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 73 additions & 17 deletions crates/blockchain/src/aggregation.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
//! Committee-signature aggregation: off-thread worker orchestration and the
//! pure functions it runs.
//!
//! The blockchain actor fires one aggregation session per interval 2 via
//! The blockchain actor fires one aggregation session per slot — at interval 2,
//! or up to [`EARLY_AGGREGATION_WINDOW`] early when the 2/3 signature
//! threshold is met — via
//! [`run_aggregation_worker`]. The actor stays on its message loop; the worker
//! runs the expensive XMSS proofs on a `spawn_blocking` thread and streams
//! results back as [`AggregateProduced`] / [`AggregationDone`] messages.

use std::collections::HashSet;
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime};

use ethlambda_crypto::aggregate_mixed;
use ethlambda_storage::Store;
Expand All @@ -19,23 +21,40 @@ use ethlambda_types::{
state::Validator,
};
use spawned_concurrency::message::Message;
use spawned_concurrency::tasks::ActorRef;
use spawned_concurrency::tasks::{ActorRef, Context, send_after};
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};

use crate::metrics;
use crate::{MILLISECONDS_PER_INTERVAL, metrics};

/// Soft deadline for committee-signature aggregation measured from the
/// interval-2 tick. After this much wall time elapses, the actor signals the
/// worker to stop via its cancellation token. The 50 ms budget before the next
/// interval (interval 3 at +800 ms) is reserved for publishing any late-arriving
/// aggregates and for gossip propagation margin.
pub(crate) const AGGREGATION_DEADLINE: Duration = Duration::from_millis(750);
/// Soft deadline for committee-signature aggregation measured from session
/// start. After this much wall time elapses, the actor signals the worker to
/// stop via its cancellation token. A session started exactly at interval 2
/// gets the full interval (interval 3 is one interval later); a session
/// started early (see `maybe_start_early_aggregation`) ends correspondingly
/// earlier. The deadline only stops new jobs from starting — a job mid-proof
/// finishes and publishes right after.
pub(crate) const AGGREGATION_DEADLINE: Duration = Duration::from_millis(800);
/// Upper bound we wait for a prior worker to exit if it is still running when
/// the next session is about to start. Reached only in pathological cases
/// (mismatched timers, stuck proofs); we warn before blocking.
pub(crate) const PRIOR_WORKER_JOIN_TIMEOUT: Duration = Duration::from_secs(2);

/// Width of the early-aggregation window: a session may start at most this
/// long before the interval-2 boundary, provided the signature threshold is
/// met (see the check in `maybe_start_early_aggregation`).
pub(crate) const EARLY_AGGREGATION_WINDOW: Duration = Duration::from_millis(600);

// The window must fit within one interval: `maybe_start_early_aggregation`
// subtracts it from the interval-2 offset, and the interval-1 tick schedules
// the check at `MILLISECONDS_PER_INTERVAL - EARLY_AGGREGATION_WINDOW`. Keep
// this invariant self-enforcing so a future bump to the window can't silently
// underflow either subtraction.
const _: () = assert!(
EARLY_AGGREGATION_WINDOW.as_millis() <= MILLISECONDS_PER_INTERVAL as u128,
"EARLY_AGGREGATION_WINDOW must not exceed one interval"
);

/// A single pre-prepared aggregation group.
///
/// Built on the actor thread from a store snapshot; consumed by an off-thread
Expand Down Expand Up @@ -76,6 +95,9 @@ pub(crate) struct AggregationSession {
/// Slot at which this session was started; used as a fencing id so we can
/// drop late-arriving messages from a prior session.
pub(crate) session_id: u64,
/// Whether the session started before the slot's interval-2 boundary via
/// the early-aggregation trigger.
pub(crate) early: bool,
/// Child of the actor cancellation token; fires either at the deadline or
/// when the actor itself is stopping.
pub(crate) cancel: CancellationToken,
Expand Down Expand Up @@ -107,7 +129,7 @@ impl Message for AggregationDone {
type Result = ();
}

/// Self-message scheduled via `send_after` at interval-2 start. Cancels the
/// Self-message scheduled via `send_after` at session start. Cancels the
/// session's token so the worker stops starting new aggregations.
pub(crate) struct AggregationDeadline {
pub(crate) session_id: u64,
Expand All @@ -116,6 +138,15 @@ impl Message for AggregationDeadline {
type Result = ();
}

/// One-shot self-message scheduled at the interval-1 tick; fires when the
/// early-aggregation window opens (T2 - EARLY_AGGREGATION_WINDOW) to run
/// the threshold check for signatures that all arrived before the window.
/// Arrivals inside the window are checked per insert instead.
pub(crate) struct EarlyAggregationCheck;
impl Message for EarlyAggregationCheck {
type Result = ();
}

/// Build a snapshot of everything needed to aggregate. Runs on the actor
/// thread, touches the store, does no heavy cryptography. Returns `None` when
/// there is nothing to aggregate so callers can avoid spawning an empty worker.
Expand Down Expand Up @@ -501,11 +532,19 @@ pub(crate) fn aggregation_bits_from_validator_indices(bits: &[u64]) -> Aggregati
/// Pulls jobs from the snapshot, runs [`aggregate_job`] for each, and streams
/// successful aggregates back to the actor as [`AggregateProduced`] messages.
/// Emits [`AggregationDone`] when the loop exits (completion or cancellation).
///
/// Publish alignment: aggregates must not reach the actor (and thus gossip)
/// before the interval-2 boundary. `publish_at` is that boundary as a wall-clock
/// instant; a produced aggregate still ahead of it is delivered via
/// [`send_after`] timed to land at the boundary, otherwise it is sent
/// immediately. A normal interval-2 session starts at the boundary, so its
/// aggregates are always past it and sent without delay.
pub(crate) fn run_aggregation_worker(
snapshot: AggregationSnapshot,
actor: ActorRef<crate::BlockChainServer>,
cancel: CancellationToken,
session_id: u64,
publish_at: SystemTime,
) {
let start = Instant::now();
let groups_considered = snapshot.groups_considered;
Expand Down Expand Up @@ -553,12 +592,29 @@ pub(crate) fn run_aggregation_worker(
total_raw_sigs += raw_sigs;
total_children += children;

if actor
.send(AggregateProduced { session_id, output })
.is_err()
{
// Actor is gone; no point producing more.
break;
// Hold the aggregate until the interval-2 boundary (early session), or
// send now if already at/past it. `send_after` is fire-and-forget: it
// spawns a timer that delivers the message and is cancelled only if the
// actor stops, so the produced aggregate is not lost when the worker's
// own loop ends. `duration_since` errs once the boundary has passed,
// which collapses to a zero delay here.
let delay = publish_at
.duration_since(SystemTime::now())
.unwrap_or(Duration::ZERO);
if delay.is_zero() {
if actor
.send(AggregateProduced { session_id, output })
.is_err()
{
// Actor is gone; no point producing more.
break;
}
} else {
send_after(
delay,
Context::from_ref(&actor),
AggregateProduced { session_id, output },
);
}
}

Expand Down
Loading