Skip to content

Commit bb2acdd

Browse files
committed
feat(rpc): add GET /lean/v0/events SSE stream
Second PR of the chain-events series: the transport for the event bus introduced in the previous PR. Kept separate from the mechanism so the axum plumbing reviews on its own; topic filtering (?topics=) follows in the next PR, so this stream is unfiltered. Each connection subscribes its own receiver and forwards events as SSE frames: the topic name goes on the event: line via ChainEvent::topic() (no hardcoded name match to drift from the enum) and the data: line carries the flat JSON payload, so the topic is never duplicated inside the body. A lagged client skips past the events the bounded channel overwrote (debug log, stream continues) rather than ending the stream: the stream is best-effort by contract and clients re-sync via the blocks endpoints. Keep-alive comments hold idle connections open through proxies.
1 parent 23825f1 commit bb2acdd

6 files changed

Lines changed: 152 additions & 7 deletions

File tree

Cargo.lock

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

bin/ethlambda/src/main.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,10 @@ async fn main() -> eyre::Result<()> {
237237
// metric's startup value.
238238
let sync_status = SyncStatusController::default();
239239

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.
240+
// Chain-event bus: the blockchain actor is the sole publisher; each SSE
241+
// client (`GET /lean/v0/events`) subscribes its own receiver through the
242+
// clone handed to the RPC server below. With no subscribers attached, the
243+
// receiver-count guard in `emit` makes every emission a no-op.
244244
let events = EventBus::new(CHAIN_EVENT_CHANNEL_CAPACITY);
245245

246246
let blockchain_config = BlockChainConfig {
@@ -253,7 +253,7 @@ async fn main() -> eyre::Result<()> {
253253
enable_proposer_aggregation: options.enable_proposer_aggregation,
254254
max_attestations_per_block: options.max_attestations_per_block,
255255
},
256-
events,
256+
events: events.clone(),
257257
};
258258

259259
let blockchain = BlockChain::spawn(store.clone(), validator_keys, blockchain_config);
@@ -299,6 +299,7 @@ async fn main() -> eyre::Result<()> {
299299
aggregator,
300300
sync_status,
301301
local_peer_id,
302+
events,
302303
rpc_shutdown,
303304
)
304305
.await

crates/net/rpc/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ 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"
2931

3032
[dev-dependencies]
3133
ethlambda-types.workspace = true

crates/net/rpc/src/events.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//! `GET /lean/v0/events` — Server-Sent Events stream of chain events.
2+
//!
3+
//! The [`ethlambda_blockchain::BlockChainServer`] actor publishes
4+
//! [`ethlambda_blockchain::ChainEvent`]s on the [`EventBus`]; this read-only
5+
//! handler subscribes a new receiver per connection and forwards each event as
6+
//! one SSE frame. The flow is strictly one-directional (actor → bus → SSE), so
7+
//! RPC never writes into the actor.
8+
//!
9+
//! Framing: the topic name goes on the SSE `event:` line
10+
//! ([`ethlambda_blockchain::ChainEvent::topic`]) and the `data:` line carries
11+
//! the event's flat JSON payload; the topic is never repeated inside the body.
12+
13+
use std::convert::Infallible;
14+
15+
use axum::{
16+
Extension, Router,
17+
response::{Sse, sse::Event},
18+
routing::get,
19+
};
20+
use ethlambda_blockchain::EventBus;
21+
use ethlambda_storage::Store;
22+
use futures_core::Stream;
23+
use tokio_stream::{
24+
StreamExt,
25+
wrappers::{BroadcastStream, errors::BroadcastStreamRecvError},
26+
};
27+
28+
async fn get_events(
29+
Extension(events): Extension<EventBus>,
30+
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
31+
let stream = BroadcastStream::new(events.subscribe()).filter_map(|res| {
32+
// A slow client falls behind and the bounded broadcast channel
33+
// overwrites events it never read; skip past the gap rather than
34+
// ending the stream. The stream is best-effort by contract: clients
35+
// re-sync via the blocks endpoints after a gap.
36+
let ev = match res {
37+
Ok(ev) => ev,
38+
Err(BroadcastStreamRecvError::Lagged(skipped)) => {
39+
tracing::debug!(skipped, "SSE client lagged; dropped chain events");
40+
return None;
41+
}
42+
};
43+
Some(Ok(Event::default()
44+
.event(ev.topic().as_str())
45+
.json_data(&ev)
46+
.inspect_err(|err| tracing::warn!(%err, "Failed to serialize SSE chain event"))
47+
.ok()?))
48+
});
49+
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())
50+
}
51+
52+
pub(crate) fn routes() -> Router<Store> {
53+
Router::new().route("/lean/v0/events", get(get_events))
54+
}
55+
56+
#[cfg(test)]
57+
mod tests {
58+
use axum::{Extension, body::Body, http::Request};
59+
use ethlambda_blockchain::{ChainEvent, EventBus};
60+
use ethlambda_storage::{Store, backend::InMemoryBackend};
61+
use std::sync::Arc;
62+
use tower::ServiceExt;
63+
64+
use crate::test_utils::create_test_state;
65+
66+
#[tokio::test]
67+
async fn events_streams_head_with_flat_payload() {
68+
let events = EventBus::new(16);
69+
let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state());
70+
let app = crate::test_utils::test_api_router(store).layer(Extension(events.clone()));
71+
72+
// Issue the request first so the handler subscribes its receiver
73+
// before we publish — `emit` drops events with no live receivers.
74+
let resp = app
75+
.oneshot(
76+
Request::builder()
77+
.uri("/lean/v0/events")
78+
.body(Body::empty())
79+
.unwrap(),
80+
)
81+
.await
82+
.unwrap();
83+
assert_eq!(resp.status(), axum::http::StatusCode::OK);
84+
85+
events.emit(ChainEvent::Head {
86+
slot: 3,
87+
root: Default::default(),
88+
parent_root: Default::default(),
89+
});
90+
91+
let mut body = resp.into_body().into_data_stream();
92+
let chunk = tokio_stream::StreamExt::next(&mut body)
93+
.await
94+
.unwrap()
95+
.unwrap();
96+
let text = String::from_utf8_lossy(&chunk);
97+
98+
// Topic on the `event:` line...
99+
assert!(
100+
text.contains("event:head") || text.contains("event: head"),
101+
"missing head event name in frame: {text}"
102+
);
103+
// ...and a flat payload: fields at the top level, no `event`/`data`
104+
// wrapper keys inside the JSON body (the #460 double-tag bug).
105+
assert!(
106+
text.contains("\"slot\":3"),
107+
"missing top-level slot in frame: {text}"
108+
);
109+
assert!(
110+
!text.contains("\"data\":") && !text.contains("\"event\":"),
111+
"payload is not flat: {text}"
112+
);
113+
}
114+
}

crates/net/rpc/src/lib.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::net::{IpAddr, SocketAddr};
22

33
use axum::{Extension, Router};
4-
use ethlambda_blockchain::SyncStatusController;
4+
use ethlambda_blockchain::{EventBus, SyncStatusController};
55
use ethlambda_storage::Store;
66
use ethlambda_types::aggregator::AggregatorController;
77
use tokio_util::sync::CancellationToken;
@@ -12,6 +12,7 @@ pub(crate) const SSZ_CONTENT_TYPE: &str = "application/octet-stream";
1212
mod admin;
1313
mod base;
1414
mod blocks;
15+
mod events;
1516
mod fork_choice;
1617
mod genesis;
1718
mod heap_profiling;
@@ -64,11 +65,13 @@ pub async fn start_rpc_server(
6465
aggregator: AggregatorController,
6566
sync_status: SyncStatusController,
6667
peer_id: String,
68+
events: EventBus,
6769
shutdown: CancellationToken,
6870
) -> Result<(), std::io::Error> {
6971
let api_router = build_api_router(store, config.version, peer_id)
7072
.layer(Extension(aggregator))
71-
.layer(Extension(sync_status));
73+
.layer(Extension(sync_status))
74+
.layer(Extension(events));
7275
let metrics_router = metrics::start_prometheus_metrics_api();
7376
let debug_router = build_debug_router();
7477

@@ -115,6 +118,7 @@ fn build_api_router(store: Store, version: &'static str, peer_id: String) -> Rou
115118
Router::new()
116119
.merge(base::routes())
117120
.merge(blocks::routes())
121+
.merge(events::routes())
118122
.merge(fork_choice::routes())
119123
.merge(admin::routes())
120124
.merge(node::routes(version, peer_id))

docs/rpc.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ If `--api-port` and `--metrics-port` are equal, all routers are merged onto a si
2828
| `GET` | `/lean/v0/states/finalized` | SSZ | Latest finalized `State` |
2929
| `GET` | `/lean/v0/blocks/finalized` | SSZ | Latest finalized `SignedBlock` |
3030
| `GET` | `/lean/v0/checkpoints/justified` | JSON | Latest justified `Checkpoint` |
31+
| `GET` | `/lean/v0/events` | SSE | Live stream of chain events |
3132
| `GET` | `/lean/v0/blocks/{block_id}` | JSON | Block by root or slot |
3233
| `GET` | `/lean/v0/blocks/{block_id}/header` | JSON | Block header by root or slot |
3334
| `GET` | `/lean/v0/fork_choice` | JSON | Fork-choice tree with per-block weights |
@@ -83,6 +84,26 @@ SSZ-encoded `SignedBlock` at the latest finalized checkpoint. The genesis/anchor
8384
{ "slot": 128, "root": "0x1a2b…" }
8485
```
8586

87+
### `GET /lean/v0/events`
88+
89+
Server-Sent Events stream (`Content-Type: text/event-stream`) of live chain events published by the blockchain actor. Four event types:
90+
91+
| Event | Payload | Emitted when |
92+
|-------|---------|--------------|
93+
| `head` | `{ "slot": 128, "root": "0x…", "parent_root": "0x…" }` | Fork choice selects a new head |
94+
| `block` | `{ "slot": 128, "root": "0x…" }` | A block is imported into the store |
95+
| `justified_checkpoint` | `{ "slot": 120, "root": "0x…" }` | The justified checkpoint advances |
96+
| `finalized_checkpoint` | `{ "slot": 96, "root": "0x…" }` | The finalized checkpoint advances |
97+
98+
The topic name travels only on the SSE `event:` line; the `data:` line carries the flat JSON payload. Example frame:
99+
100+
```
101+
event: head
102+
data: {"slot":128,"root":"0x1a2b…","parent_root":"0x3c4d…"}
103+
```
104+
105+
Events are fanned out over a bounded broadcast channel (`CHAIN_EVENT_CHANNEL_CAPACITY`). A client that reads too slowly skips past the events it missed: they are dropped for that subscriber rather than back-pressured onto the actor, so treat the stream as best-effort and re-sync via the blocks endpoints after a gap. Keep-alive comments are sent periodically to hold idle connections open.
106+
86107
### `GET /lean/v0/blocks/{block_id}` and `/header`
87108

88109
`block_id` is either:
@@ -195,6 +216,7 @@ When the binary boots with `HIVE_LEAN_TEST_DRIVER=1` (any of `1`/`true`/`yes`),
195216
| Kind | `Content-Type` |
196217
|------|----------------|
197218
| JSON | `application/json; charset=utf-8` |
219+
| SSE | `text/event-stream` |
198220
| SSZ | `application/octet-stream` |
199221
| Prometheus metrics | `text/plain; version=0.0.4; charset=utf-8` |
200222
| HTML | `text/html; charset=utf-8` |

0 commit comments

Comments
 (0)