Skip to content

Commit 23825f1

Browse files
committed
feat(blockchain): add chain-event bus (head/block/justified/finalized)
First PR of the chain-events series (re-cut of #460), shipping only the pub-sub mechanism so the real design decisions (event shape, publisher model, back-pressure policy) are reviewable on their own. The SSE transport and topic filtering follow in separate PRs; the bus stays unconsumed until the endpoint lands. The bus is a facade over a single bounded tokio broadcast channel: the blockchain actor is the sole publisher, and emit never blocks or fails, since a slow subscriber lags and drops instead of back-pressuring consensus; a receiver-count guard makes emission free when nobody listens. Threading &EventBus (never Option) through the store functions keeps the sole-publisher property visible in signatures, and EventBus::disabled() removes the Option noise from test call sites. ChainEvent serializes untagged so payloads stay flat: the topic name travels out-of-band via ChainEvent::topic(), fixing #460's double-tagged SSE payload before any transport exists. The justified_checkpoint emission lives at its advance site in on_block_core instead of an old/new diff in update_head: update_head never modifies latest_justified (its update_checkpoints call passes None for it), so a diff there could never fire.
1 parent f80d5e7 commit 23825f1

8 files changed

Lines changed: 327 additions & 32 deletions

File tree

bin/ethlambda/src/main.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ use serde::Deserialize;
5252
use tracing::{error, info, warn};
5353
use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt};
5454

55-
use ethlambda_blockchain::{BlockChain, BlockChainConfig, SyncStatusController};
55+
use ethlambda_blockchain::{
56+
BlockChain, BlockChainConfig, CHAIN_EVENT_CHANNEL_CAPACITY, EventBus, SyncStatusController,
57+
};
5658
use ethlambda_rpc::RpcConfig;
5759
use ethlambda_storage::{
5860
MAX_RESUMABLE_DB_STATE_AGE, StorageBackend, Store, backend::RocksDBBackend,
@@ -235,6 +237,12 @@ async fn main() -> eyre::Result<()> {
235237
// metric's startup value.
236238
let sync_status = SyncStatusController::default();
237239

240+
// Chain-event bus: the blockchain actor is the sole publisher. No consumer
241+
// subscribes yet — the RPC SSE endpoint (follow-up PR) will attach here;
242+
// until then the receiver-count guard in `emit` makes every emission a
243+
// no-op.
244+
let events = EventBus::new(CHAIN_EVENT_CHANNEL_CAPACITY);
245+
238246
let blockchain_config = BlockChainConfig {
239247
aggregator: aggregator.clone(),
240248
sync_status_controller: sync_status.clone(),
@@ -245,6 +253,7 @@ async fn main() -> eyre::Result<()> {
245253
enable_proposer_aggregation: options.enable_proposer_aggregation,
246254
max_attestations_per_block: options.max_attestations_per_block,
247255
},
256+
events,
248257
};
249258

250259
let blockchain = BlockChain::spawn(store.clone(), validator_keys, blockchain_config);

crates/blockchain/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ tokio-util = { version = "0.7", default-features = false }
2929
rayon.workspace = true
3030
thiserror.workspace = true
3131
tracing.workspace = true
32+
serde.workspace = true
3233

3334
hex.workspace = true
3435

3536
[dev-dependencies]
3637
ethlambda-test-fixtures.workspace = true
37-
serde = { workspace = true }
3838
serde_json = { workspace = true }
3939
hex = { workspace = true }
4040
libssz.workspace = true

crates/blockchain/src/events.rs

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
//! Chain-event pub-sub bus.
2+
//!
3+
//! The [`crate::BlockChainServer`] actor is the **sole publisher**: it emits a
4+
//! [`ChainEvent`] whenever consensus state changes (block import, head move,
5+
//! justification, finalization). Consumers subscribe read-only receivers and
6+
//! never write back into the actor, keeping the write flow one-directional.
7+
//!
8+
//! The bus is intentionally best-effort: emission never blocks the actor, and
9+
//! a slow subscriber loses events (the bounded broadcast channel overwrites
10+
//! its backlog) rather than back-pressuring consensus.
11+
12+
use ethlambda_types::primitives::H256;
13+
use serde::Serialize;
14+
use tokio::sync::broadcast;
15+
16+
/// Wire-visible topic names for chain events.
17+
///
18+
/// These are the names consumers address events by (the SSE `event:` line and,
19+
/// later, `?topics=` filtering), kept separate from [`ChainEvent`] so the
20+
/// payloads stay flat JSON with the topic travelling out-of-band.
21+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22+
pub enum Topic {
23+
Head,
24+
Block,
25+
JustifiedCheckpoint,
26+
FinalizedCheckpoint,
27+
}
28+
29+
impl Topic {
30+
pub fn as_str(self) -> &'static str {
31+
match self {
32+
Topic::Head => "head",
33+
Topic::Block => "block",
34+
Topic::JustifiedCheckpoint => "justified_checkpoint",
35+
Topic::FinalizedCheckpoint => "finalized_checkpoint",
36+
}
37+
}
38+
}
39+
40+
/// A consensus event published by the blockchain actor.
41+
///
42+
/// `untagged`: serializing yields only the variant's fields. The topic name
43+
/// travels out-of-band (via [`ChainEvent::topic`], e.g. on the SSE `event:`
44+
/// line), so the JSON body stays flat with no `event`/`data` wrapper.
45+
#[derive(Clone, Debug, Serialize)]
46+
#[serde(untagged)]
47+
pub enum ChainEvent {
48+
/// Fork choice selected a new head.
49+
Head {
50+
slot: u64,
51+
root: H256,
52+
parent_root: H256,
53+
},
54+
/// A block was imported into the store.
55+
Block { slot: u64, root: H256 },
56+
/// The justified checkpoint advanced.
57+
JustifiedCheckpoint { slot: u64, root: H256 },
58+
/// The finalized checkpoint advanced.
59+
FinalizedCheckpoint { slot: u64, root: H256 },
60+
}
61+
62+
impl ChainEvent {
63+
pub fn topic(&self) -> Topic {
64+
match self {
65+
ChainEvent::Head { .. } => Topic::Head,
66+
ChainEvent::Block { .. } => Topic::Block,
67+
ChainEvent::JustifiedCheckpoint { .. } => Topic::JustifiedCheckpoint,
68+
ChainEvent::FinalizedCheckpoint { .. } => Topic::FinalizedCheckpoint,
69+
}
70+
}
71+
}
72+
73+
/// Capacity of the chain-event broadcast channel.
74+
///
75+
/// Chosen so a briefly-stalled subscriber is skipped past (lagged) rather than
76+
/// back-pressuring the actor. Lagged subscribers re-sync via the blocks
77+
/// endpoints.
78+
pub const CHAIN_EVENT_CHANNEL_CAPACITY: usize = 256;
79+
80+
/// Cloneable handle to the chain-event broadcast channel.
81+
///
82+
/// Threaded as `&EventBus` (never `Option`) through the store's processing
83+
/// functions: emission sites call [`EventBus::emit`], and paths that must not
84+
/// surface events pass [`EventBus::disabled`].
85+
#[derive(Clone)]
86+
pub struct EventBus {
87+
tx: broadcast::Sender<ChainEvent>,
88+
}
89+
90+
impl EventBus {
91+
pub fn new(capacity: usize) -> Self {
92+
let (tx, _) = broadcast::channel(capacity);
93+
Self { tx }
94+
}
95+
96+
/// Dormant bus: emits go nowhere. For tests and eventless call paths.
97+
pub fn disabled() -> Self {
98+
Self::new(1)
99+
}
100+
101+
/// Publish an event to all current subscribers.
102+
///
103+
/// Never blocks, never fails: without subscribers this is a no-op, and a
104+
/// send error (every subscriber dropped since the guard) is ignored.
105+
pub fn emit(&self, event: ChainEvent) {
106+
if self.tx.receiver_count() == 0 {
107+
return;
108+
}
109+
let _ = self.tx.send(event);
110+
}
111+
112+
/// Subscribe a new receiver observing every event emitted from now on.
113+
pub fn subscribe(&self) -> broadcast::Receiver<ChainEvent> {
114+
self.tx.subscribe()
115+
}
116+
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::*;
121+
122+
fn head_event(slot: u64) -> ChainEvent {
123+
ChainEvent::Head {
124+
slot,
125+
root: H256([1u8; 32]),
126+
parent_root: H256([2u8; 32]),
127+
}
128+
}
129+
130+
#[tokio::test]
131+
async fn subscriber_receives_emitted_event() {
132+
let bus = EventBus::new(CHAIN_EVENT_CHANNEL_CAPACITY);
133+
let mut rx = bus.subscribe();
134+
135+
bus.emit(head_event(7));
136+
137+
match rx.recv().await.unwrap() {
138+
ChainEvent::Head { slot, .. } => assert_eq!(slot, 7),
139+
other => panic!("unexpected event: {other:?}"),
140+
}
141+
}
142+
143+
#[test]
144+
fn emit_without_subscribers_is_a_noop() {
145+
let bus = EventBus::new(CHAIN_EVENT_CHANNEL_CAPACITY);
146+
// No subscriber attached: must neither error nor panic.
147+
bus.emit(head_event(1));
148+
}
149+
150+
#[test]
151+
fn disabled_bus_accepts_emits() {
152+
let bus = EventBus::disabled();
153+
bus.emit(head_event(1));
154+
bus.emit(ChainEvent::Block {
155+
slot: 2,
156+
root: H256::ZERO,
157+
});
158+
}
159+
160+
#[test]
161+
fn topic_maps_every_variant() {
162+
let root = H256::ZERO;
163+
let cases = [
164+
(head_event(1), Topic::Head),
165+
(ChainEvent::Block { slot: 1, root }, Topic::Block),
166+
(
167+
ChainEvent::JustifiedCheckpoint { slot: 1, root },
168+
Topic::JustifiedCheckpoint,
169+
),
170+
(
171+
ChainEvent::FinalizedCheckpoint { slot: 1, root },
172+
Topic::FinalizedCheckpoint,
173+
),
174+
];
175+
for (event, topic) in cases {
176+
assert_eq!(event.topic(), topic);
177+
assert_eq!(event.topic().as_str(), topic.as_str());
178+
}
179+
}
180+
181+
/// The JSON body must be the variant's fields only: the topic name travels
182+
/// out-of-band, so no `event`/`data` wrapper keys may appear (the #460
183+
/// double-tag bug).
184+
#[test]
185+
fn serialization_is_flat_untagged_json() {
186+
let json = serde_json::to_value(head_event(3)).unwrap();
187+
188+
assert_eq!(json["slot"], 3);
189+
assert!(json["root"].is_string());
190+
assert!(json["parent_root"].is_string());
191+
assert!(json.get("event").is_none());
192+
assert!(json.get("data").is_none());
193+
}
194+
}

crates/blockchain/src/lib.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ use tracing::{debug, error, info, trace, warn};
3030
use crate::block_builder::ProposerConfig;
3131
use crate::store::StoreError;
3232

33+
pub use events::{CHAIN_EVENT_CHANNEL_CAPACITY, ChainEvent, EventBus, Topic};
34+
3335
pub mod aggregation;
3436
pub mod block_builder;
3537
pub(crate) mod coverage;
38+
pub mod events;
3639
pub(crate) mod fork_choice_tree;
3740
pub mod key_manager;
3841
pub mod metrics;
@@ -61,6 +64,9 @@ pub struct BlockChainConfig {
6164
pub subscribed_subnets: HashSet<u64>,
6265
/// Proposer-side block-building policy.
6366
pub proposer_config: ProposerConfig,
67+
/// Chain-event publication bus. The actor is the sole publisher;
68+
/// consumers subscribe read-only receivers.
69+
pub events: EventBus,
6470
}
6571

6672
/// Milliseconds per interval (800ms ticks).
@@ -136,6 +142,7 @@ impl BlockChain {
136142
gate_duties,
137143
subscribed_subnets,
138144
proposer_config,
145+
events,
139146
} = config;
140147

141148
metrics::set_is_aggregator(aggregator.is_enabled());
@@ -169,6 +176,7 @@ impl BlockChain {
169176
pre_merge_coverage: None,
170177
sync_status: SyncStatusTracker::new(gate_duties),
171178
sync_status_controller,
179+
events,
172180
}
173181
.start();
174182
let time_until_genesis = (SystemTime::UNIX_EPOCH + Duration::from_secs(genesis_time))
@@ -255,6 +263,10 @@ pub struct BlockChainServer {
255263
/// (the RPC `/lean/v0/node/syncing` endpoint). Written from
256264
/// `update_sync_status` with the same `SyncStatus` fed to the metric.
257265
sync_status_controller: SyncStatusController,
266+
267+
/// Chain-event publication bus. The actor is the sole publisher; consumers
268+
/// only subscribe, preserving the one-directional write flow.
269+
events: EventBus,
258270
}
259271

260272
impl BlockChainServer {
@@ -331,7 +343,7 @@ impl BlockChainServer {
331343
.is_some();
332344

333345
// Tick the store first - this accepts attestations at interval 0 if we have a proposal
334-
store::on_tick(&mut self.store, timestamp_ms, is_proposer);
346+
store::on_tick(&mut self.store, timestamp_ms, is_proposer, &self.events);
335347

336348
// Per-interval duties for this tick. Intervals 0 (block publish) and 3
337349
// (safe-target update) are driven inside `store::on_tick` above, so they
@@ -857,7 +869,7 @@ impl BlockChainServer {
857869

858870
/// Run block import and refresh metrics.
859871
fn process_block(&mut self, signed_block: SignedBlock) -> Result<(), StoreError> {
860-
store::on_block(&mut self.store, signed_block)?;
872+
store::on_block(&mut self.store, signed_block, &self.events)?;
861873
metrics::update_head_slot(self.store.head_slot());
862874
let latest_justified_slot = self
863875
.store

0 commit comments

Comments
 (0)