Skip to content

feat(api-server): real-time SSE event stream for block explorers - #2116

Closed
erubboli wants to merge 3 commits into
masterfrom
feat/api-server-event-stream
Closed

erubboli wants to merge 3 commits into
masterfrom
feat/api-server-event-stream

Conversation

@erubboli

@erubboli erubboli commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

Adds a real-time event stream to the api-server so block explorers can display transactions and blocks live. New endpoint: GET /api/v2/stream (Server-Sent Events), delivering three event kinds as named SSE events:

event when payload
tx_seen a transaction reaches the node's mempool (pre-indexing) {tx_id, origin: local|remote}
block a block has been fully indexed into the api-server database {block_id, height, timestamp, tx_ids}
reorg previously indexed blocks are disconnected {common_ancestor_height, removed_block_ids, new_tip_height}

Payloads are kept small; clients hydrate details through the existing REST endpoints.

Design

Block/reorg events — scanner, transactionally consistent. BlockchainState::scan_blocks appends events to a new ml.emitted_events table inside the same RW transaction that indexes the blocks (reorg detection: local best height > common ancestor, with removed block ids captured before the disconnect marks them). After the appends, the scanner issues pg_notify('mintlayer_events', <last id>) inside the transaction, so Postgres delivers the wakeup only on commit: once a block event is delivered, the referenced block is immediately queryable via REST (asserted by tests).

tx_seen events — web server. The web server bridges the node's mempool events from the existing NodeRpcClient WebSocket into TxSeen events (only successful: true transactions; subscription re-established on connection loss).

Web server event pump. A background pump wakes on the Postgres notification (dedicated LISTEN connection — with automatic reconnection bounded by the poll interval, since Postgres only delivers notifications on the listening connection) and forwards events into a tokio::sync::broadcast channel. Periodic polling (default 30 s) is the safety net for missed notifications; the backlog is drained in bounded batches (1000/batch) and the cursor (last_seen_id) only ever moves forward, so a web-server restart cannot produce duplicate deliveries. Events older than the last 10 000 are pruned by the scanner (the stream has no replay, so they are never re-read).

Endpoint. Named events for EventSource.addEventListener, optional ?types=block,reorg filter (400 on invalid values), : keepalive comments (default 30 s), spec-compliant retry: hint as the first frame, x-accel-buffering: no for reverse proxies, and a lag advisory ({"skipped": n}) when a client exceeds the broadcast capacity. No auth, consistent with the other v2 GET endpoints. No replay/Last-Event-ID: clients recover missed events via REST.

Operational notes

  • Storage version bumped 25 → 26 (new ml.emitted_events table). Per the existing convention this triggers the usual full database re-initialization on upgrade (full resync). Required regardless: without the new table the scanner would fail on insert.
  • Three new web-server options with working defaults (no config change needed for existing deployments): --stream-events-broadcast-capacity (1024), --stream-events-poll-interval-secs (30), --stream-events-keepalive-interval-secs (30).
  • In-memory storage backend treats streaming as a no-op (documented in the trait defaults); its test suite stays green.

Testing

  • storage-test-suite (both backends; Postgres via container): events visible only after commit, rollback discards them, ascending monotonic ids, resume-after-last-seen, no-op behavior of the in-memory backend.
  • New unit tests: pump backlog/resume/error-survival, event serde roundtrip, tx_seen mapping (filters successful: false and NewTip), filter parsing/matching.
  • Stack tests: SSE endpoint contract (content-type, framing, named events, filter, 400 on bad input, keepalive), block event ⇒ immediate GET /v2/block/:id consistency, and a Postgres end-to-end test driving the real scanner → ml.emitted_events → LISTEN/NOTIFY pump → SSE, including a forced reorg (exactly one reorg event with correct removed ids/heights, followed by the new fork's block events, no duplicates), and a dedup check.
  • ./do_checks.sh clean (fmt, cargo-deny, cargo-vet, clippy, codecheck); cargo test --release green for every touched crate (stack tests incl. containers re-run after the rebase onto current master).

Review

Ran two rounds of security review (findings fixed: event-pump hot-spin after listener death → reconnect + sleep fallback; unbounded startup reads → batched reads; poison-row stall → skip undecodable rows; config-value panics → clamped; dead retry header → in-stream frame; event-name duplication → single StreamEventType source of truth) and a code-quality review (both blockers and warnings addressed; DRY pass on shared types/test helpers).

Notes for reviewers

  • The second commit (fix: address clippy 1.98 lints across the workspace) is unrelated drive-by work: do_checks.sh fails on current master with clippy 1.98's new lints; the fixes are mechanical and behavior-preserving (reviewers can skip it). Happy to split it into its own PR if preferred.
  • CURRENT_STORAGE_VERSION bump means existing deployments resync on upgrade — called out in the README/CHANGELOG.
  • Known gap: tx_seen is not covered end-to-end against a real node process (the repo has no harness that spins a node RPC server in tests); the bridge mapping and the SSE delivery path are covered separately.
  • The container helper used by the Postgres tests (storage-test-suite/src/podman.rs) now falls back to docker when podman is not installed (identical CLI surface for the commands used).

Mechanical, behavior-preserving fixes for the lints introduced by the
newer clippy (new_without_default, let_and_return, useless_borrows /
redundant references, some_filter, unused imports), so that
do_checks.sh passes with the current toolchain.
Add a streaming endpoint (GET /api/v2/stream) that delivers three event
kinds to explorer clients as named Server-Sent Events (tx_seen, block,
reorg), with a 'types' query filter, keepalives, an x-accel-buffering
response header, a lag advisory for slow clients, and an in-stream
reconnection hint.

Event sources:
* the scanner appends block/reorg events to a new ml.emitted_events
  table inside the same transaction that indexes the blocks, so every
  event is transactionally consistent with the data the explorer can
  immediately fetch over REST; a pg_notify wakeup is sent on commit;
* the web server bridges mempool events from the node's WebSocket RPC
  into tx_seen events (only successfully processed transactions).

The web server runs an event pump that wakes up on the Postgres
notification (LISTEN on a dedicated connection, with automatic
reconnection), falls back to periodic polling, drains the backlog in
bounded batches, and forwards the events into a tokio broadcast
channel consumed by the SSE endpoint. Events older than a retention
window are pruned by the scanner.

Notes:
* the storage version is bumped (25 -> 26) since the schema changed,
  which triggers the usual full re-initialization on upgrade;
* the in-memory storage backend treats streaming as a no-op;
* storage-test-suite gains streaming tests for both backends;
* stack tests cover the SSE endpoint contract and, against a real
  Postgres, the full scanner -> emitted_events -> pump -> SSE flow
  including a reorg scenario;
* the podman-based container helper falls back to docker when podman
  is not installed.
Document the /v2/stream SSE endpoint (event kinds, filtering, wire
format, keepalives, lag advisory, no-replay semantics), the new
web server streaming options, the event flow architecture, and the
storage version 25 -> 26 upgrade (full resync) in the README, and add
the corresponding changelog entries.
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

🔍 OpenCodeReview found 27 issue(s) in this PR.

  • ✅ Successfully posted inline: 12 comment(s)
  • 📋 Routed to summary by policy: 15 comment(s)

style · low

📄 accounting/src/delta/delta_data_collection/mod.rs (L161-L165)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category style)

The T: Eq bound is stricter than necessary for Default: Self::new() only requires K: Ord + Copy, T: Clone. A looser bound (impl<K: Ord + Copy, T: Clone> Default for DeltaDataCollection<K, T>) would allow default() for non-Eq value types too.

💡 Suggested Change

Before:

impl<K: Ord + Copy, T: Clone + Eq> Default for DeltaDataCollection<K, T> {
    fn default() -> Self {
        Self::new()
    }
}

After:

impl<K: Ord + Copy, T: Clone> Default for DeltaDataCollection<K, T> {
    fn default() -> Self {
        Self::new()
    }
}

style · low

📄 chainstate/types/src/block_status.rs (L49-L49)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category style)

The two doc comment lines were merged into one line, so the second sentence no longer reads as a separate doc line. This looks like an unintended side effect of an automated fix. Please restore it to two lines.

💡 Suggested Change

Before:

    /// Advance the last successful validation stage to the specified value.    /// Note that the stage can only be advanced one step at a time.

After:

    /// Advance the last successful validation stage to the specified value.
    /// Note that the stage can only be advanced one step at a time.

style · low

📄 orders-accounting/src/data.rs (L101-L105)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category style)

These manual Default impls are equivalent to #[derive(Default)], since all fields are BTreeMaps which implement Default. Deriving would reduce boilerplate here and across the other structs in this change set (OrdersAccountingData, PoSAccountingData, TokensAccountingData, the in-memory storage structs, etc.), though keeping the manual impls is also fine if consistency with new() initialization is preferred.

💡 Suggested Change

Before:

+impl Default for OrdersAccountingData {
+    fn default() -> Self {
+        Self::new()
+    }
+}

After:

#[derive(Default)]
pub struct OrdersAccountingData { ... }

test · low

📄 api-server/stack-test-suite/tests/v2/stream.rs (L286-L287)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category test)

The "no tx_seen must ever arrive" guarantee is only checked over a fixed 1-second window, which weakens on loaded CI machines: with keepalives arriving every 200ms the loop mostly exercises keepalive frames, and the window is a heuristic rather than a proof. Note the inner next_frame(FRAME_TIMEOUT) also carries its own 5s deadline nested under the 1s outer timeout, mixing two deadlines. Since the filter is applied server-side before delivery, a simpler robust check is to keep reading frames for the window and assert only tx_seen frames never appear (already done) — but consider asserting the block event arrived before the window starts (it does) and enlarging the window slightly, or dropping the nested timeout parameter in favor of just remaining to make the deadline handling explicit.

💡 Suggested Change

Before:

        match tokio::time::timeout(remaining, sse.next_frame(FRAME_TIMEOUT)).await {
            Err(_elapsed) => break, // the observation window is over

After:

        // The inner deadline equals the outer one, so only one deadline governs the read.
        match tokio::time::timeout(remaining, sse.next_frame(remaining)).await {
            Err(_elapsed) => break, // the observation window is over

bug · low

📄 api-server/scanner-lib/src/blockchain_state/mod.rs (L294-L296)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category bug)

block_count as u64 can truncate usize on 32-bit targets, and the subsequent addition in next_block_height has no overflow handling, so an oversized count would silently produce a wrong new_tip_height in the Reorg event. Prefer u64::try_from(...).unwrap_or(u64::MAX) (or checked_add) to fail loudly instead of wrapping.

💡 Suggested Change

Before:

fn next_block_height(base_height: BlockHeight, block_count: usize) -> BlockHeight {
    BlockHeight::new(base_height.into_int() + block_count as u64)
}

After:

fn next_block_height(base_height: BlockHeight, block_count: usize) -> BlockHeight {
    let count = u64::try_from(block_count).unwrap_or(u64::MAX);
    BlockHeight::new(base_height.into_int().saturating_add(count))
}

test · low

📄 api-server/stack-test-suite/tests/postgres_stream.rs (L133-L133)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category test)

The spawned event pump and SSE collector tasks have their JoinHandles dropped. If the event pump dies (e.g., Postgres listener error), the failure is silently swallowed and the test hangs in recv_event until EVENT_TIMEOUT with a misleading 'timed out' message. Consider holding the handles and checking them (e.g., via a select! or a oneshot error channel) so a dead pump fails fast with the actual cause.


test · low

📄 api-server/storage-test-suite/src/basic.rs (L2303-L2308)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category test)

Accepting either an empty list or the full expected set means a supporting backend that regresses to silently returning no stream events would pass this generic suite undetected. Consider a capability flag on the test-suite storage maker so backends that support stream events get strict assertions here, keeping the weak check only for backends that intentionally drop them.


test · low

📄 api-server/storage-test-suite/src/podman.rs (L29-L29)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category test)

The fallback to docker never checks that docker actually exists. On machines with neither podman nor docker installed, the failure surfaces only as a generic spawn-failure assertion inside run_command. Consider probing both commands and emitting a clear diagnostic such as 'neither podman nor docker found on PATH'. The probe also accepts a non-executable file named podman.


maintainability · low

📄 api-server/web-server/src/api/stream.rs (L44-L44)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

The SSE retry hint is hardcoded to 3s while the neighboring keepalive interval is configurable; operators tuning stream_events_keepalive_interval_secs may expect reconnection behavior to follow suit. Either make it configurable alongside the other streaming knobs or document why it is fixed.


maintainability · low

📄 api-server/web-server/src/main.rs (L77-L77)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

The config clamps (max(1)) silently correct zero/nonsense values with no log or validation error. An operator who typos a value gets different behavior than configured without any signal. Consider logging a warning when clamping occurs (or rejecting invalid values at startup), and doing the clamping/validation in one place shared with the config defaults.

💡 Suggested Change

Before:

        let channel = StreamEventsChannel::new(args.stream_events_broadcast_capacity.max(1));

After:

        if args.stream_events_broadcast_capacity == 0 {
            logging::log::warn!("stream-events-broadcast-capacity must be >= 1; using 1");
        }
        let channel = StreamEventsChannel::new(args.stream_events_broadcast_capacity.max(1));

maintainability · low

📄 api-server/api-server-common/src/storage/impls/postgres/listener.rs (L96-L105)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

When the listener connection dies, reconnection failures result in a fixed one-poll_interval sleep followed by another full connect attempt, indefinitely, with no backoff. If the database is down for a long time this creates a steady connect/sleep churn on every wakeup cycle. A simple exponentially increasing delay capped at the poll interval (or a multiple of it) would reduce the load, and reconnect errors are currently swallowed silently — consider logging them.


other · low

📄 api-server/api-server-common/src/streaming.rs (L186-L188)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category other)

For subscribers that hit the broadcast capacity, the only signal is the SSE 'lag' event with a skipped count, and events are then silently skipped with no way for a client to resync other than reconnecting and re-hydrating via REST. That appears intentional (payloads are deliberately small and clients hydrate via REST), but the contract should be documented in StreamEventsChannel::subscribe / the streaming module docs so consumers know events may be dropped under lag rather than assumed lossless.


test · low

📄 api-server/stack-test-suite/tests/postgres_stream.rs (L348-L349)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category test)

The final no-duplicates check relies on fixed sleeps (1s settle + 1s receive timeout). Under heavy CI load, duplicate or late events could arrive after the window (false pass) or the check could be starved of CPU time (less likely). A slightly longer observation window, or draining until the web task is aborted and then asserting the channel is empty, would make this less timing-sensitive.


test · low

📄 api-server/stack-test-suite/tests/postgres_stream.rs (L173-L177)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category test)

The SSE connect loop retries at most 100 times with 100ms sleeps (~10s). The web server takes over an already-bound listener, so startup should be fast, but on heavily loaded CI this fixed bound could flake the test. Consider a larger bound or deriving the timeout from a constant.


maintainability · low

📄 api-server/api-server-common/src/streaming.rs (L223-L228)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

The event pump runs an infinite loop with no cancellation or shutdown mechanism. Once spawned (e.g. from the web server), it cannot be stopped gracefully: it holds the storage source alive and continues issuing database reads (and keeping the listener connection) even after the server begins shutting down. Consider accepting a shutdown signal (e.g. a CancellationToken or watch channel) and selecting on it in wait_for_wakeup, so the task can terminate on shutdown.

💡 Suggested Change

Before:

pub async fn run_event_pump(
    mut source: impl StreamEventSource,
    channel: StreamEventsChannel,
    mut last_seen_id: StreamEventId,
) {
    loop {

After:

pub async fn run_event_pump(
    mut source: impl StreamEventSource,
    channel: StreamEventsChannel,
    mut last_seen_id: StreamEventId,
    shutdown: tokio_util::sync::CancellationToken,
) {
    loop {
        if shutdown.is_cancelled() {
            break;
        }

Comment on lines +3115 to +3121
match serde_json::from_str(&payload) {
Ok(event) => Some((id, event)),
Err(err) => {
logging::log::warn!("Skipping undecodable stream event #{id}: {err}");
None
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Skipping undecodable rows is a reasonable availability trade-off, but the pump advances last_seen_id past these ids, so subscribers permanently and silently miss those events (e.g. after a payload schema change between versions). Since StreamEvent has no version field, consider adding one, or at least surfacing skipped ids so the pump can emit a gap/lag advisory to subscribers rather than only a server-side warning.

Comment on lines +3126 to +3127
/// Delete the stream events that fell out of the retention window.
pub async fn prune_stream_events(&mut self) -> Result<(), ApiServerStorageError> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
Retention pruning is independent of consumer progress: if the event pump is stalled or its reads repeatedly fail (run_event_pump just logs the error and falls back to waiting), events can fall out of the retention window and be deleted before they are ever forwarded to subscribers. Consider tracking the minimum forwarded id (e.g. persisted by the pump) and pruning only below it, or at minimum documenting that events may be lost if the pump falls more than STREAM_EVENTS_RETENTION_COUNT behind.

Suggestion:

Suggested change
/// Delete the stream events that fell out of the retention window.
pub async fn prune_stream_events(&mut self) -> Result<(), ApiServerStorageError> {
/// Delete the stream events that fell out of the retention window.
///
/// Note: pruning is based purely on event age relative to the newest event; it does
/// not take consumer progress into account, so events the pump has not yet forwarded
/// can be permanently lost if the pump falls more than RETENTION_COUNT behind.
pub async fn prune_stream_events(&mut self) -> Result<(), ApiServerStorageError> {

Comment on lines +250 to +253
Err(err) => {
logging::log::error!("{err}");
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
After a read error the pump breaks out of the drain loop and waits for the next wakeup, which is fine, but combined with retention pruning any events not read during the failure window are eventually deleted server-side and never reach subscribers. Consider retrying the read (with backoff) a bounded number of times before falling back to the wakeup interval, or at least documenting that a read error can lead to permanent event loss due to pruning.

Comment on lines +281 to +284
if last_event_id > 0 {
db_tx.prune_stream_events().await?;
db_tx.notify_new_stream_events(last_event_id).await?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
prune_stream_events() uses a fixed-count retention window (deletes everything below max(id) - 10_000) with no consumer watermark. Since events are pruned on every connect_block call, a subscriber whose pump is offline, lagging, or reading slower than block production can have events deleted before it reads them; the pump then silently continues from the next surviving id, producing undetectable gaps in the event stream (e.g. a missed Reorg event leaves the client with stale, disconnected block references). Consider tracking a consumer-side last-read watermark for pruning, or at least surfacing gap detection (id discontinuity) in the event pump when a pruned range is skipped.

Suggestion:

Suggested change
if last_event_id > 0 {
db_tx.prune_stream_events().await?;
db_tx.notify_new_stream_events(last_event_id).await?;
}
if last_event_id > 0 {
// Note: pruning must be tied to consumer progress, not a fixed count, otherwise
// lagging subscribers lose events silently.
db_tx.prune_stream_events().await?;
db_tx.notify_new_stream_events(last_event_id).await?;
}

Comment on lines +311 to +315
for height in (common_block_height.into_int() + 1)..=best_block_height.into_int() {
if let Some(block_id) = db_tx.get_main_chain_block_id(BlockHeight::new(height)).await? {
block_ids.push(block_id);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
capture_main_chain_block_ids issues one get_main_chain_block_id round trip per height, so a deep reorg holds the read-write storage transaction (and the write lock) for O(depth) queries. Consider a batched range query (e.g. SELECT ... WHERE height BETWEEN $1 AND $2 ORDER BY height) to collapse this into a single round trip and shorten the lock hold time.

Comment on lines +86 to +88
Some(types) => StreamEventsFilter::parse(types).map_err(|_| {
ApiServerWebServerError::ClientError(ApiServerWebServerClientError::BadRequest)
})?,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
The parse error is discarded and mapped to a generic BadRequest, yet StreamEventTypeParseError implements a helpful Display listing the valid event types. Clients get no indication of which types value was invalid. Since this is a client-facing input error, surfacing the message (or at least logging it) would substantially improve the developer experience of API consumers.

Suggestion:

Suggested change
Some(types) => StreamEventsFilter::parse(types).map_err(|_| {
ApiServerWebServerError::ClientError(ApiServerWebServerClientError::BadRequest)
})?,
Some(types) => StreamEventsFilter::parse(types).map_err(|err| {
logging::log::debug!("Invalid stream `types` query parameter: {err}");
ApiServerWebServerError::ClientError(ApiServerWebServerClientError::BadRequest)
})?,

Comment on lines +131 to +135
Err(broadcast::error::RecvError::Lagged(skipped)) => {
// Note: the client fell too far behind; tell it what happened and
// continue with the fresh events.
return Some((Ok(lag_event(skipped)), (receiver, filter, retry_sent)));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
On broadcast lag the client is only told how many events were skipped via the lag advisory, but those skipped events may include Reorg or Block events, leaving the client with an inconsistent view of the chain (e.g. it never learns a reorg happened). The shared streaming.rs explicitly states events are persisted with retention (STREAM_EVENTS_RETENTION_COUNT) and the endpoint "does not support replays anyway" — the endpoint should at least document the expected client recovery contract (resync via REST after a lag event), otherwise consider actually replaying the missed ids from storage.

Suggestion:

Suggested change
Err(broadcast::error::RecvError::Lagged(skipped)) => {
// Note: the client fell too far behind; tell it what happened and
// continue with the fresh events.
return Some((Ok(lag_event(skipped)), (receiver, filter, retry_sent)));
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
// Note: the client fell too far behind; tell it what happened and
// continue with the fresh events. Document that clients must resync
// through the REST endpoints after receiving a `lag` event.
return Some((Ok(lag_event(skipped)), (receiver, filter, retry_sent)));
}

Comment on lines +97 to +100
tokio::spawn(streaming::run_database_event_pump(
event_source,
stream_events.clone(),
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The database event pump and the mempool bridge are spawned before the RPC client is constructed and before web_server starts. If construction subsequently fails (e.g. RpcError), the server exits while these tasks keep running against a half-initialized state (the pump holds a dedicated Postgres LISTEN connection that is never shut down). There is also no graceful shutdown wiring for the event listener on normal exit. Consider spawning these tasks only after all fallible setup succeeds, and passing them a shutdown signal (e.g. CancellationToken) tied to server shutdown.

Comment on lines +82 to +84
pub async fn run_database_event_pump(source: impl StreamEventSource, handle: StreamEventsHandle) {
run_event_pump(source, handle.channel, 0).await;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The database event pump starts at last_seen_id = 0, so on every server restart it replays up to STREAM_EVENTS_RETENTION_COUNT (10,000) historical events into the broadcast channel. Any client that connects while this backlog is being drained receives stale Block/TxSeen events interleaved with live ones and has no way to distinguish or deduplicate them, because the SSE frames carry no id: field (and the endpoint doesn't honor Last-Event-ID). Consider starting the pump from the latest stored event id (skipping the backlog, since the endpoint explicitly does not support replays), or attaching SSE event ids so clients can detect duplicates.

Suggestion:

Suggested change
pub async fn run_database_event_pump(source: impl StreamEventSource, handle: StreamEventsHandle) {
run_event_pump(source, handle.channel, 0).await;
}
pub async fn run_database_event_pump(source: impl StreamEventSource, handle: StreamEventsHandle) {
// Start from the newest persisted event so that a server restart does not replay the
// retained backlog to connected clients; the endpoint does not support replays.
let last_seen_id = source.latest_event_id().await.unwrap_or(0);
run_event_pump(source, handle.channel, last_seen_id).await;
}

Comment on lines +141 to +143
tokio::time::sleep(MEMPOOL_RESUBSCRIBE_DELAY).await;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
run_mempool_bridge retries the WebSocket subscription forever with a fixed 1s delay, no backoff, and no cancellation/shutdown propagation. If the node is persistently unreachable (or auth is misconfigured), this hot-loops log spam and reconnect attempts indefinitely. Additionally, the task is spawned in main.rs with the JoinHandle dropped, so a permanently failed bridge is never observed — clients silently stop receiving TxSeen events with no health signal. Consider exponential backoff with a cap, and a shutdown token (or at minimum a fatal-error exit) so the failure is visible.

Suggestion:

Suggested change
tokio::time::sleep(MEMPOOL_RESUBSCRIBE_DELAY).await;
}
}
// Exponential backoff capped at some maximum, plus cancellation via a shutdown token.
tokio::time::sleep(MEMPOOL_RESUBSCRIBE_DELAY).await;
}
}

@erubboli erubboli closed this Sep 16, 2026
@nullPointerEnjoyer
nullPointerEnjoyer deleted the feat/api-server-event-stream branch September 16, 2026 15:05
@nullPointerEnjoyer
nullPointerEnjoyer restored the feat/api-server-event-stream branch September 16, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants