Skip to content

Commit 0b53690

Browse files
committed
feat(rpc): add ?topics= filtering to the events stream
Third PR of the chain-events series: server-side topic filtering for GET /lean/v0/events, split out so the previous PR's transport stayed a review of pure axum plumbing. The filter lives in the bus (EventBus::subscribe(TopicSet) returning an EventSubscription with a receive-side skip loop) rather than in the SSE handler, so future non-SSE consumers get filtering for free and the handler stays transport-only. TopicSet is a Copy bitmask: no allocation and one AND per event; lag is surfaced to the caller rather than swallowed by the skip loop. The signature also survives a later per-topic-channel split behind the facade, since callers only ever see subscribe(TopicSet). The handler parses ?topics=head,block via Topic::from_str (the exact inverse of as_str, so names cannot drift) and returns 400 naming the offending value on an unknown topic. A missing or empty parameter defaults to all topics: the Beacon API makes topics required, lean is friendlier here (documented as a divergence in docs/rpc.md). BroadcastStream no longer fits (it wraps a raw receiver, not the filtered subscription), so the bridge is futures-util's stream::unfold around EventSubscription::recv, preserving the Lagged skip + debug-log behavior; tokio-stream and futures-core are dropped for futures-util, already present in the dependency tree. EventSubscription also gains a non-blocking try_recv (same skip loop as recv, over the receiver's try_recv), so the previous PR's lib.rs unit tests (which assert on already-buffered events without spinning up a runtime) can move onto the filtered type after subscribe()'s signature changed here.
1 parent 8bc49c0 commit 0b53690

6 files changed

Lines changed: 313 additions & 59 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/blockchain/src/events.rs

Lines changed: 154 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
//! a slow subscriber loses events (the bounded broadcast channel overwrites
1010
//! its backlog) rather than back-pressuring consensus.
1111
12+
use std::{fmt, str::FromStr};
13+
1214
use ethlambda_types::primitives::H256;
1315
use serde::Serialize;
1416
use tokio::sync::broadcast;
@@ -37,6 +39,58 @@ impl Topic {
3739
}
3840
}
3941

42+
/// Error returned by [`Topic::from_str`] for a name matching no topic.
43+
#[derive(Debug, Clone, PartialEq, Eq)]
44+
pub struct UnknownTopic(String);
45+
46+
impl fmt::Display for UnknownTopic {
47+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48+
write!(f, "unknown topic: '{}'", self.0)
49+
}
50+
}
51+
52+
impl std::error::Error for UnknownTopic {}
53+
54+
impl FromStr for Topic {
55+
type Err = UnknownTopic;
56+
57+
/// Exact inverse of [`Topic::as_str`].
58+
fn from_str(s: &str) -> Result<Self, Self::Err> {
59+
match s {
60+
"head" => Ok(Topic::Head),
61+
"block" => Ok(Topic::Block),
62+
"justified_checkpoint" => Ok(Topic::JustifiedCheckpoint),
63+
"finalized_checkpoint" => Ok(Topic::FinalizedCheckpoint),
64+
other => Err(UnknownTopic(other.to_string())),
65+
}
66+
}
67+
}
68+
69+
/// Set of topics a subscriber wants.
70+
///
71+
/// A bitmask over [`Topic`] discriminants: `Copy`, no allocation, one AND per
72+
/// received event. `Default` is the empty set; matching everything must be
73+
/// explicit via [`TopicSet::ALL`].
74+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75+
pub struct TopicSet(u16);
76+
77+
impl TopicSet {
78+
/// Every topic, including ones added later.
79+
pub const ALL: TopicSet = TopicSet(u16::MAX);
80+
81+
pub fn contains(self, topic: Topic) -> bool {
82+
self.0 & (1 << topic as u16) != 0
83+
}
84+
}
85+
86+
impl FromIterator<Topic> for TopicSet {
87+
fn from_iter<I: IntoIterator<Item = Topic>>(iter: I) -> Self {
88+
iter.into_iter().fold(TopicSet::default(), |set, topic| {
89+
TopicSet(set.0 | (1 << topic as u16))
90+
})
91+
}
92+
}
93+
4094
/// A consensus event published by the blockchain actor.
4195
///
4296
/// `untagged`: serializing yields only the variant's fields. The topic name
@@ -111,9 +165,52 @@ impl EventBus {
111165
let _ = self.tx.send(event);
112166
}
113167

114-
/// Subscribe a new receiver observing every event emitted from now on.
115-
pub fn subscribe(&self) -> broadcast::Receiver<ChainEvent> {
116-
self.tx.subscribe()
168+
/// Subscribe a filtered view observing matching events emitted from now on.
169+
///
170+
/// The filter lives in the bus rather than in each consumer so every
171+
/// subscriber (SSE or otherwise) gets the same skip semantics.
172+
pub fn subscribe(&self, topics: TopicSet) -> EventSubscription {
173+
EventSubscription {
174+
rx: self.tx.subscribe(),
175+
topics,
176+
}
177+
}
178+
}
179+
180+
/// A topic-filtered view over the chain-event broadcast channel.
181+
///
182+
/// All subscribers share one ring buffer: filtering happens at receive time,
183+
/// so skipped events still occupy channel slots and still count toward a
184+
/// subscriber's lag window.
185+
pub struct EventSubscription {
186+
rx: broadcast::Receiver<ChainEvent>,
187+
topics: TopicSet,
188+
}
189+
190+
impl EventSubscription {
191+
/// Next event matching the filter. Non-matching events are skipped; lag
192+
/// is surfaced to the caller, not swallowed.
193+
pub async fn recv(&mut self) -> Result<ChainEvent, broadcast::error::RecvError> {
194+
loop {
195+
match self.rx.recv().await {
196+
Ok(event) if self.topics.contains(event.topic()) => return Ok(event),
197+
Ok(_) => continue,
198+
Err(err) => return Err(err), // Lagged(n) | Closed
199+
}
200+
}
201+
}
202+
203+
/// Non-blocking variant of [`EventSubscription::recv`], for callers (e.g.
204+
/// tests) that want to assert on already-buffered events without an
205+
/// executor.
206+
pub fn try_recv(&mut self) -> Result<ChainEvent, broadcast::error::TryRecvError> {
207+
loop {
208+
match self.rx.try_recv() {
209+
Ok(event) if self.topics.contains(event.topic()) => return Ok(event),
210+
Ok(_) => continue,
211+
Err(err) => return Err(err), // Empty | Lagged(n) | Closed
212+
}
213+
}
117214
}
118215
}
119216

@@ -129,19 +226,71 @@ mod tests {
129226
}
130227
}
131228

229+
const ALL_TOPICS: [Topic; 4] = [
230+
Topic::Head,
231+
Topic::Block,
232+
Topic::JustifiedCheckpoint,
233+
Topic::FinalizedCheckpoint,
234+
];
235+
132236
#[tokio::test]
133237
async fn subscriber_receives_emitted_event() {
134238
let bus = EventBus::new(CHAIN_EVENT_CHANNEL_CAPACITY);
135-
let mut rx = bus.subscribe();
239+
let mut sub = bus.subscribe(TopicSet::ALL);
136240

137241
bus.emit(head_event(7));
138242

139-
match rx.recv().await.unwrap() {
243+
match sub.recv().await.unwrap() {
140244
ChainEvent::Head { slot, .. } => assert_eq!(slot, 7),
141245
other => panic!("unexpected event: {other:?}"),
142246
}
143247
}
144248

249+
#[tokio::test]
250+
async fn filtered_subscription_skips_unmatched_events() {
251+
let bus = EventBus::new(CHAIN_EVENT_CHANNEL_CAPACITY);
252+
let mut sub = bus.subscribe([Topic::Head].into_iter().collect());
253+
254+
// The block event lands in the channel first but must never surface
255+
// through the head-only subscription.
256+
bus.emit(ChainEvent::Block {
257+
slot: 1,
258+
root: H256::ZERO,
259+
});
260+
bus.emit(head_event(2));
261+
262+
match sub.recv().await.unwrap() {
263+
ChainEvent::Head { slot, .. } => assert_eq!(slot, 2),
264+
other => panic!("unexpected event: {other:?}"),
265+
}
266+
}
267+
268+
#[test]
269+
fn topic_set_collects_and_contains() {
270+
let set: TopicSet = [Topic::Head, Topic::FinalizedCheckpoint]
271+
.into_iter()
272+
.collect();
273+
assert!(set.contains(Topic::Head));
274+
assert!(set.contains(Topic::FinalizedCheckpoint));
275+
assert!(!set.contains(Topic::Block));
276+
assert!(!set.contains(Topic::JustifiedCheckpoint));
277+
278+
let empty = TopicSet::default();
279+
for topic in ALL_TOPICS {
280+
assert!(!empty.contains(topic));
281+
assert!(TopicSet::ALL.contains(topic));
282+
}
283+
}
284+
285+
#[test]
286+
fn topic_from_str_inverts_as_str() {
287+
for topic in ALL_TOPICS {
288+
assert_eq!(topic.as_str().parse::<Topic>().unwrap(), topic);
289+
}
290+
let err = "bogus".parse::<Topic>().unwrap_err();
291+
assert_eq!(err.to_string(), "unknown topic: 'bogus'");
292+
}
293+
145294
#[test]
146295
fn emit_without_subscribers_is_a_noop() {
147296
let bus = EventBus::new(CHAIN_EVENT_CHANNEL_CAPACITY);

crates/blockchain/src/lib.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ use tracing::{debug, error, info, trace, warn};
3131
use crate::block_builder::ProposerConfig;
3232
use crate::store::StoreError;
3333

34-
pub use events::{CHAIN_EVENT_CHANNEL_CAPACITY, ChainEvent, EventBus, Topic};
34+
pub use events::{
35+
CHAIN_EVENT_CHANNEL_CAPACITY, ChainEvent, EventBus, EventSubscription, Topic, TopicSet,
36+
};
3537

3638
pub mod aggregation;
3739
pub mod block_builder;
@@ -1514,7 +1516,7 @@ mod tests {
15141516
fn chain_event_diff_emits_nothing_when_unchanged() {
15151517
let store = test_store();
15161518
let bus = EventBus::new(8);
1517-
let mut rx = bus.subscribe();
1519+
let mut rx = bus.subscribe(TopicSet::ALL);
15181520

15191521
let snapshot = ChainEventSnapshot::capture(&store);
15201522
snapshot.diff_and_emit(&store, &bus);
@@ -1529,7 +1531,7 @@ mod tests {
15291531
let mut store = test_store();
15301532
let genesis = store.head().expect("store head exists");
15311533
let bus = EventBus::new(8);
1532-
let mut rx = bus.subscribe();
1534+
let mut rx = bus.subscribe(TopicSet::ALL);
15331535

15341536
let snapshot = ChainEventSnapshot::capture(&store);
15351537

@@ -1580,7 +1582,7 @@ mod tests {
15801582
fn chain_event_diff_skips_head_with_missing_header() {
15811583
let mut store = test_store();
15821584
let bus = EventBus::new(8);
1583-
let mut rx = bus.subscribe();
1585+
let mut rx = bus.subscribe(TopicSet::ALL);
15841586

15851587
let snapshot = ChainEventSnapshot::capture(&store);
15861588

crates/net/rpc/Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ serde_json.workspace = true
2626
hex.workspace = true
2727
tracing.workspace = true
2828
jemalloc_pprof.workspace = true
29-
tokio-stream = { version = "0.1", features = ["sync"] }
30-
futures-core = "0.3"
29+
futures-util = "0.3"
3130

3231
[dev-dependencies]
3332
ethlambda-types.workspace = true

0 commit comments

Comments
 (0)