Firehose block stream can hang indefinitely with no idle/read timeout → subgraph silently stops indexing until restart
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 52/100
- issue の種類
- バグ
- 明瞭さ
- おおむね明確
- 活発さ
- 静か
- 技術スタック
- grpc, rust
調査の方向性
graph/src/blockchain/firehose_block_stream.rs から始め、特にストリームの確立と受信ループを確認し、その後 graph/src/blockchain/block_stream.rs と core/src/subgraph/runner/mod.rs を調べて、suspension がどのように伝播するかを確認します。既存の timeout と keepalive の動作については graph/src/firehose/endpoints.rs を確認します。通常のストリームが引き続きインデックス作成を続ける中で、アイドル状態の firehose ストリームが手動で再割り当てせずに検出され、復旧されれば完了です。
索引モデルが issue の本文から書いたものです。
説明
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.
- 主要言語
- Rust
- スター
- 3.2k
- フォーク
- 1.1k
- 平均マージ
- 4日 1時間
- マージ済み PR(30日)
- 1
コントリビューションガイド
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
graphprotocol/graph-node のほかの issue
-
current: include emits an all-null bucket for dimensionless aggregations, nulling the whole response オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
graphprotocol/graph-node#6719 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
graphprotocol/graph-node#6673 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
graphprotocol/graph-node#6650 · コメント 1 件 ·
-
難易度 4/5 3〜5日 初心者へのやさしさ 48/100
graphprotocol/graph-node#6722 ·
-
難易度 3/5 1〜2日 初心者へのやさしさ 68/100
graphprotocol/graph-node#6721 ·
graphprotocol/graph-node の issue をすべて見る
似ている issue
-
risk:low runtime status:in-progress type:test
難易度 1/5 1時間未満 初心者へのやさしさ 92/100
zeroclaw-labs/zeroclaw#11023 ·
-
good first issue refactor
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
-
難易度 2/5 1〜3時間 初心者へのやさしさ 86/100
kwakseongjae/auto-hwp#319 ·
-
area:cli bug filter-quality good first issue priority:medium
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
-
難易度 1/5 1時間未満 初心者へのやさしさ 72/100
bevyengine/bevy#25861 ·