Log why a substreams request is slow, while it is still running - #863
Open
sduchesneau wants to merge 3 commits into
Open
Log why a substreams request is slow, while it is still running#863sduchesneau wants to merge 3 commits into
sduchesneau wants to merge 3 commits into
Conversation
Contributor
Author
An eth_call retrying against an unreachable endpoint is a single wasm extension call that can last minutes. Tier2 only reported external calls once they returned, and only ever emitted progress when a block completed, so tier1 saw an idle job with no external call metrics until the whole segment gave up. ExternalCallMetric now carries the failures, the calls still waiting, how long the oldest has waited and the block it holds up. Tier2 reports every 10s while a block is in flight, and counts the time already spent by a call that has not returned, so a hung call shows up as time spent rather than as nothing at all. The tier2 response function needed a mutex: the snapshots are sent from a ticker goroutine while the block loop keeps sending, and gRPC forbids concurrent Send on one stream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sduchesneau
force-pushed
the
feature/log_request_stats
branch
from
August 7, 2026 15:51
3622ffa to
ef849a8
Compare
Waiting before handling each block blocks the receive loop, which is what a genuinely slow consumer does: the gRPC flow-control window fills and the server blocks on sending. Sleeping in a goroutine would exert none of that back-pressure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sduchesneau
force-pushed
the
feature/log_request_stats
branch
2 times, most recently
from
August 7, 2026 16:01
00bb9ef to
42f2e5b
Compare
sduchesneau
marked this pull request as ready for review
August 7, 2026 16:12
The final "substreams request stats" line only lands once a request is over, which is too late to answer "why is my substreams slow?" while it still runs. Emit a bounded summary per tier1 request: where the blocks the client receives come from, how far each stage got and what its jobs are doing, what the external calls cost, the error that killed the last job, and how long we spent blocked writing to the consumer. Every rate and delta covers a fixed trailing 5 minutes accumulated in time buckets, so it means the same thing whatever the emission interval is set to, and two consecutive lines are always comparable. A short hints list names the likely bottleneck when one is detected. They are deliberately hard to trigger: a hint that fires on a healthy request is worse than no hint at all. In particular a full cache lead and a throttled scheduler are the steady state of a healthy production request, not symptoms, so the consumer is only blamed for time actually spent blocked in SendMsg, and a slow segment is projected from the rate a job is holding rather than waited out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sduchesneau
force-pushed
the
feature/log_request_stats
branch
from
August 7, 2026 17:49
42f2e5b to
8090745
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The final
substreams request statslog only lands once a request is over — too late to answer "why is my substreams slow?" while it is still running. Tier1 now emits a bounded summary per request: once after 1 minute, then every 5 minutes.Three commits:
The line
A line from a request mid-backprocessing, trimmed to one stage and two hints:
{ "trace_id": "35cc603abf24977a434f5090f2447b6d", "output_module": "graph_out", "production_mode": true, "phase": "parallel_processing", "elapsed": "42m0s", "last_sent_block": 12369738, "last_block_in_cache": 12450000, "blocks_sent": 118, "blocks_sent_5m": { "blocks": 118, "blocked": "5ms", "avg_per_block": "39µs", "longest_stall": "59µs" }, "stages": [ { "stores": ["uni:store_pool_count", "uni:store_pools_created", "uni:store_tokens"], "mappers": ["uni:map_pools_created"], "ready_up_to": 12600000, "squash_wait_segments": 37, "jobs": { "start": 12369621, "end": 12800000, "completed": 12, "completed_5m": 12, "running": 1, "oldest_running": 12600000, "oldest_running_at": 12600512, "oldest_running_age": "9m0s", "avg_dur": "4m0s" } } ], "external_calls": [ { "module": "uni:map_pools_created", "extension": "rpc:eth_call", "count_total": 4, "total_time": "14m0s", "count_5m": 0, "time_5m": "5m0s", "avg_5m": "0s", "calls_still_running": true, "at_block": 12600512 } ], "last_job_error": { "stage": 2, "age": "1m30s", "count_total": 1, "count_5m": 1, "error": "… running wasm extension \"rpc::eth_call\": timeout while doing eth_call, waiting for rpc provider for 3m0s (29 attempt(s), last error: … dial tcp [::1]:8545: connect: connection refused)" }, "hints": [ "module \"uni:map_pools_created\" spent 5m0s of the last 5m0s inside rpc:eth_call calls without a single one completing on block 12600512: at least one call has been waiting for the whole window, so the endpoint behind it is unreachable or far too slow and the segment will eventually time out on it", "stage 2 covered 739 of the 10000 blocks of segment [12450000, 12460000) in 22m0s (1.79s per block): at that rate the segment needs about 4h58m0s, where 1 to 10 minutes is expected" ] }phase— where the client's blocks come from right now:streaming_first_segment(a tier2 worker streaming the first mapper segment live, the usual start of a production request),parallel_processing(the exec-out cache),linear_processing.stages[]— thestoresandmappersthe stage executes,ready_up_to(the lowest contiguous block across its modules — a stage is only as advanced as its least advanced one; for stores it stops at the last squashed segment),squash_wait_segments, and ajobsblock: the range the plan says it must cover, completions, and where the oldest running job started and got to.external_calls[]— per module and extension, withcalls_still_runningand theat_blockit is holding up.last_job_error— the worker error text, which tier1 had been logging once and dropping.blocks_sent_5m—blockedis total time insideSendMsg; the outliers are namedlongest_stallbecauseSendMsgreturns as soon as the message fits the gRPC flow-control window and blocks only when it fills, so a single stall measures the buffer as much as the client.What it looks like in practice
Three situations reproduced against a local stack.
1 — A tier2 segment that will never finish in time
Called out in the first minute from the rate the job is holding, not fifteen minutes later from its age:
2 —
eth_calls hung while the first segment streams from tier2This is the case the whole PR started from. Note
in_flight: 4andoldest_in_flighton a module executing inside a tier2 job — that is the new proto fields at work. Before them, tier2 reported counts and totals only: the four hung calls contributed no time, the job looked idle, and nothing was known until the segment timed out minutes later.count_5m: 0withtime_5m: 9m30sis the signature of calls that started before the window and never came back.3 — A slow reading client
Reproduced with
substreams tools simulate-slow-reader:blocked(89% of the window) is what triggers the hint.longest_stallis deliberately not the trigger:SendMsgreturns as soon as the message fits the gRPC flow-control window and blocks only when it fills, so a 38s stall measures the buffer draining at the client's pace as much as the client itself.The window is decoupled from the emission interval
Every
_5mvalue covers a fixed trailing 5 minutes, accumulated in 30s time buckets addressed by absolute time. Reading sums the in-window buckets and does not consume them, so two consecutive lines are always comparable andSUBSTREAMS_PROGRESS_LOG_INTERVALchanges only how often the line is printed, never what the numbers mean. On a request younger than 5 minutes the values cover its lifetime, whichelapsedmakes explicit.Hints
Nine, capped at 6 per line. They are deliberately hard to trigger — a hint that fires on a healthy request is worse than no hint at all. Two cases are worth calling out because the obvious signal is wrong:
maxParallelJobs * 3/2segments), so a healthy request sits at that ceiling permanently. The consumer is blamed only for time actually spent blocked insideSendMsg(≥35% of the window).jobs_throttled_5mis reported as context and never generates a hint.A slow segment is projected from the rate a job is holding (
age × segmentSize / progressBlocks) rather than waited out, so a segment that will need half an hour is called out in its first minute instead of fifteen minutes later.Tier2 reporting (proto change)
pbssinternal.ExternalCallMetricgains four additive fields:An
eth_callretrying against an unreachable endpoint is a single wasm extension call that can last minutes, and tier2 only emitted progress when a block completed — so tier1 saw an idle job with no external call metrics until the segment finally timed out. Tier2 now reports every 10s while a block is in flight, and counts the time already spent by a call that has not returned.Sending progress from a ticker goroutine also required a mutex in
tier2ResponseHandler— it was doing bare concurrentstreamSrv.Send, which gRPC forbids. Latent bug, now load-bearing.Testing it
Waits before handling each block, blocking the receive loop — the gRPC window fills and the server blocks on sending. Sleeping in a goroutine would exert no back-pressure.
Cost
Stages.computeStageStatsfolds the contiguous-prefix and pending-partials computation into the existing once-per-second pass over the segment matrix — no additionalO(segments × stages)walk. Everything else is O(1) per record call. The line's size is bounded regardless of run length: 20 modules, 12 stages, 5 external-call rows, 6 hints, one 900-char job error.Notes
substreams request statsfinal log is untouched (it feeds reporting systems).go test ./...andgo test -racepass.go test -race ./test/fails identically on cleandevelop— pre-existing, unrelated.🤖 Generated with Claude Code