Firehose block stream can hang indefinitely with no idle/read timeout → subgraph silently stops indexing until restart
Personne n'a encore pris cette issue.
Évaluation
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Accessibilité débutants
- 52/100
- Type d'issue
- Bug
- Clarté
- Plutôt claire
- Activité
- Calme
- Stack technique
- grpc, rust
- Domaine
- backend, distributed-systems, networking
Piste de recherche
Commencez par graph/src/blockchain/firehose_block_stream.rs, en particulier l’établissement du stream et la boucle de réception, puis examinez graph/src/blockchain/block_stream.rs et core/src/subgraph/runner/mod.rs pour comprendre comment la suspension se propage. Consultez graph/src/firehose/endpoints.rs pour le comportement existant de timeout et de keepalive. La tâche sera terminée lorsqu’un stream firehose inactif sera détecté et rétabli sans réaffectation manuelle, tandis que les streams normaux continueront leur indexation.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Description
Summary
A subgraph fed by a firehose block stream can stop indexing indefinitely and silently if the firehose upstream keeps the HTTP/2 stream open but stops sending frames (no message, no error, no end-of-stream). The subgraph stays health: healthy, is not paused, sets no fatalError, emits zero logs, and its latestBlock is frozen. Only a manual restart / reassignment (which opens a fresh stream) recovers it.
The block-stream receive loop has no per-message idle/read timeout, and HTTP/2 keepalive pings are intentionally disabled, so there is nothing to detect or break a half-open / app-hung upstream.
Affected versions
Confirmed present and unchanged from v0.42.1 through v0.44.0 and current master. The relevant code has not changed since it was introduced.
Symptom (observed in production)
graphman info --status:Paused: false,Health: healthy,fatalError: null.latestBlockfrozen for hours; no forward progress.- No log lines for the deployment anywhere (all pods) for the entire stall.
- Other subgraphs on the same node/chain keep indexing normally (node is fine).
graphman reassign <hash> <node>(fresh runner → fresh stream) recovers it immediately.- Correlated with the firehose's upstream block source being briefly unstable (e.g. a node restart), which can leave individual gRPC streams half-open.
Root cause
1. No idle timeout on the stream receive loop.
graph/src/blockchain/firehose_block_stream.rs (master: establishment timeout at L246, receive loop at L258):
let req = endpoint.clone().stream_blocks(request, &headers);
let result = tokio::time::timeout(Duration::from_secs(120), req).await; // guards ESTABLISHMENT only
match result {
Ok(stream) => {
let mut last_response_time = Instant::now(); // only feeds a metric
for await response in stream { // <-- no timeout / select! around each .next()
...
}
- The
tokio::time::timeout(120s, req)wraps only the initialstream_blocksestablishment future, not the per-frame iteration. - Inside
for await response in streamthere is notokio::time::timeout, noselect!, no idle deadline.last_response_timeis only passed to a metric (observe_response) and never used to trip a timeout. - If the upstream holds the HTTP/2 stream open and sends nothing,
for awaitnever yields, and the hang propagates cleanly up the whole consumer chain with no error and no log:BufferedBlockStream(graph/src/blockchain/block_stream.rs:62) only pumps the stream into an mpsc channel — no timeout of its own.- The runner's
block_stream.next().await(core/src/subgraph/runner/mod.rs:327) has notokio::time::timeoutand noselect!. - The only wakeup for the suspended runner task is its
Cancelable/CancelGuard, which is fired only by unassign/reassign. This is exactly whygraphman reassign(fresh runner → fresh stream) is the only thing that recovers it.
2. HTTP/2 keepalive intentionally disabled (no liveness probe for a half-open stream).
graph/src/firehose/endpoints.rs (~L241–250 on master):
// Note: Do not set `http2_keep_alive_interval` or `http2_adaptive_window`, as these will
// send ping frames, and many cloud load balancers will drop connections that frequently
// send pings.
let endpoint = endpoint_builder
.initial_connection_window_size(Some((1 << 31) - 1))
.connect_timeout(Duration::from_secs(10))
.tcp_keepalive(Some(Duration::from_secs(15)))
// Timeout on each request, so the timeout to establish each 'Blocks' stream.
.timeout(Duration::from_secs(120));
- HTTP/2 keepalive pings are deliberately off, so there is no application-level liveness probe.
tcp_keepalive(15s)detects a fully dead peer (no TCP ACKs) but not a half-open / app-hung peer whose OS keeps the socket alive — the common case when the upstream is mid-restart behind a proxy/LB.connect_timeoutand.timeout(120s)(per the code's own comment) bound only stream establishment, not mid-stream idle.
This is a known, deliberately worked-around limitation, not an oversight. The original comment on the connection-window tuning (PR #3818, 2022-08-08) states:
"We run multiple block streams on a same connection, and a problematic subgraph with a stalled block stream might consume the entire window capacity for its http2 stream and never release it. If there are enough stalled block streams to consume all the capacity on the http2 connection, then all subgraphs using this same http2 connection will stall."
The mitigation chosen at the time was to set the connection window to the maximum (so a few stalled streams don't starve others) — it makes the connection tolerate stalled streams but never recovers an individual stalled stream.
Why existing guards don't cover it
connect_timeout/.timeout(120s)— establishment only (see comment in code).tcp_keepalive(15s)— dead peer only, not half-open.- No
GRAPH_*env var bounds the streaming-loop idle time.GRAPH_FIREHOSE_FETCH_BLOCK_TIMEOUT_SECS(firehose_block_fetch_timeout) only guards the reorg block-refetch RPC, not the main stream loop. GRAPH_KILL_IF_UNRESPONSIVEdoes not catch this: it is a tokio-runtime/threadpool contention watchdog (ping/pong over the whole runtime,node/src/launcher.rs). A runner suspended on an idle.awaitconsumes no thread, so the watchdog stays satisfied. (The reporter of #4146 hadGRAPH_KILL_IF_UNRESPONSIVE=true; it detected nothing.)
Related / prior art (none fixes this)
- #4146 "Indexing stucks until restart" (closed, 2022) — near-identical symptom (stops indexing, no errors, clear logs, different subgraph each time, only a full restart fixes it), but on the RPC poller and closed without a root cause. Symptomatic precedent.
- #3810 — added the firehose
connect_timeout(establishment-only; explicitly not mid-stream). - #3855 "firehose: Set a timeout for grpc requests" — added the
.timeout(120s), motivated explicitly by "we might have seen requests hanging" — but it bounds only stream establishment. - #3822 "firehose: Set tcp keepalive" ("to detect firehose restarts and such") — TCP-level only; misses a half-open / app-hung peer.
- #3818 — connection window max + the "do not set http2_keep_alive_interval" comment quoted above.
- #4190 "Store connection issue prevents subgraph indexing until graph-node is restarted" — same shape (silent stall until restart) on the Postgres writer path.
Note: maintainers have already reached for hang mitigations twice (#3855 request timeout, #3822 TCP keepalive), but both bound only connection establishment / dead-socket detection — neither recovers a mid-stream idle hang.
Proposed fix (either, or both)
- Idle timeout on the receive loop — wrap each
stream.next()intokio::time::timeout(idle_deadline, …)(or aselect!against a reset-on-message deadline) inside the loop; on elapse, drop the stream and reconnect with backoff (the reconnect path already exists). Makeidle_deadlineenv-configurable (e.g.GRAPH_FIREHOSE_STREAM_IDLE_TIMEOUT_SECS), disabled by default to preserve current behavior. - Re-enable a conservative HTTP/2 keepalive —
http2_keep_alive_interval+keep_alive_timeout, gated behind an env var so operators whose network path is not behind a ping-dropping LB (e.g. an in-cluster service) can opt in. This directly negates the failure mode the 2022 comment was avoiding, without forcing it on everyone.
Option 1 is topology-independent and recommended as the primary fix.
Reproduction
- Point a subgraph at a firehose endpoint.
- While it streams, make the firehose upstream hold the gRPC stream open but stop emitting frames (e.g. pause/stall the upstream block source, or interpose a proxy that stops forwarding frames without closing the stream).
- Observe: the subgraph freezes with
health: healthy, no logs, nofatalError, frozenlatestBlock, indefinitely. - Restart/reassign → recovers immediately.
Environment
- graph-node v0.42.1 (also verified unchanged on master / v0.44.0).
- Ethereum mainnet subgraphs consuming a firehose block stream (StreamingFast
firehose-ethereum), eth_call provider separate.
- Langage dominant
- Rust
- Étoiles
- 3.2k
- Forks
- 1.1k
- Merge moyen
- 4 j 1 h
- PR mergées (30 j)
- 1
Guide de contribution
Ouvrir le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de graphprotocol/graph-node
-
current: include emits an all-null bucket for dimensionless aggregations, nulling the whole response Ouverte
Difficulté 2/5 1-3 heures Accessibilité débutants 78/100
graphprotocol/graph-node#6719 ·
-
RUSTSEC-2026-0194: Quadratic run time when checking a start tag for duplicate attribute names Ouverte
Difficulté 2/5 1-3 heures Accessibilité débutants 68/100
graphprotocol/graph-node#6673 ·
-
Difficulté 2/5 1-3 heures Accessibilité débutants 70/100
graphprotocol/graph-node#6650 · 1 commentaire ·
-
Difficulté 4/5 3-5 jours Accessibilité débutants 48/100
graphprotocol/graph-node#6722 ·
-
Difficulté 3/5 1-2 jours Accessibilité débutants 68/100
graphprotocol/graph-node#6721 ·
Toutes les issues de graphprotocol/graph-node
Issues similaires
-
Difficulté 2/5 1-3 heures Accessibilité débutants 75/100
TheLarkInn/aipm#2413 ·
-
documentation
Difficulté 1/5 Moins d'une heure Accessibilité débutants 90/100
alexgorbatchev/simple-ptt#15 ·
-
tooling
Difficulté 2/5 1-3 heures Accessibilité débutants 75/100
-
todo:ticket
Difficulté 2/5 1-3 heures Accessibilité débutants 70/100
-
Difficulté 2/5 1-3 heures Accessibilité débutants 75/100
taikoxyz/taiko-mono#22168 · 1 commentaire ·