rpc/jsonrpc: push fresh witnesses via debug_subscribe("executionWitnesses") - #22407
Conversation
Pure refactor splitting the witness-building pipeline (buildAccessedState through the append-and-sort tail) into a shared DebugAPIImpl.buildWitnessResult method, so the on-demand handler and the upcoming eager cache builder produce byte-identical results. Adds a determinism-and-sort guard test.
Background worker that eagerly builds legacy-mode debug_executionWitness results into the shared witnessCache as canonical headers arrive, reusing the buildWitnessResult seam so cached bytes are byte-identical to on-demand. - shouldBuild: pure tip-gate (single-block advance to the freshest unbuilt tip) - decideCommittedHead / waitCommittedHead: Fork-1 safe-commit gate that polls the committed head via a fresh temporal RO tx per attempt, matching the hash before building and treating a mismatch as a reorg-away - RunWitnessCacheBuilder: coalesce-to-latest loop that reconciles the cache on every batch and builds only the newest tip-gated header
…uilder Add witness.cache.blocks (default 0, capped 96) and witness.cache.maxmb (default 1024) flags, thread them through HttpCfg, and wire the eager witness-cache builder into the embedded node. The cache is embedded-RPC only and gated on the DB-persisted commitment-history flag; standalone rpcdaemon and mcp pass nil. APIList gains the shared *witnessCache param; the builder-owned DebugAPIImpl shares that same pointer.
rpc/jsonrpc: add witness_cache_* counters (hit/miss, build_ok, build_fail_verify/other, evict, coalesce_drop), bytes/entries_resident gauges, and a build_duration histogram, wired at the serve, build, and eviction sites. Classify verify failures via an errWitnessVerifyFailed sentinel wrapped around the shared build seam. Enrich the witness cache flag help text with the raw-size cap context.
rpc/jsonrpc: builder resolves the block by the canonical hash it validated, not by number, so the built witness is provably keyed under (num, hash). Tests: fix canonical-bypass subtest that could never fail (discarded error + nil-safe assertion); assert hit/miss counter deltas on serve; cover newWitnessCache clamp/boundary and decodeHeaderRefs/processHeaderBatch newest-selection; join the builder goroutine before DB teardown in builder tests to avoid a use-after-close race under -race. docs/plans: correct RunWitnessCacheBuilder signature (drop notifications arg).
Store the marshaled JSON once in the builder and serve a cache hit through MarshalFastJSON, skipping the per-hit struct marshal (~26ms for a 15MB witness -> ~11ns, zero allocs). Byte-identical to the on-demand response.
Replace the hand-rolled number-keyed witness cache with the same hashicorp lru.Cache used for the block cache, keyed by block hash. Hash keying makes reorgs self-evicting — a reorged hash is never requested again and ages out — so the reconcile path is gone. Memory is bounded by the block count; drops the byte cap and --witness.cache.maxmb.
Gate eager building on whether the tip's hash is already cached rather than a high-water block number, so a reorged head (a new hash at an already-built height) is rebuilt instead of falling through to on-demand forever. Removes the frozen high-water var.
- witness_feed: drop unreachable recordDrop in send()'s final select; under the single-producer-under-lock invariant the re-send always succeeds, and a drop is already counted at the drain site. - witness_feed_test: assert the overflow drop count and that no subscribers leak after the concurrent workers stop. - witness_subscription_test: cover the no-notifier (ErrNotificationsUnsupported) path and pin the wire JSON key names. - plan doc: reconcile stale ctx.Done() note with the shipped notifier.Closed() lifecycle.
- key feed subscribers by channel identity; drop the id counter - fan out via common.PrioritizedSend; drop the drop-counter/rate-limit machinery - de-embed the LRU: store() is the only insert path, so cache implies publish; feed access goes through cache accessors - fold the subscription pump into subscribeRPC (guard moved to callers), dropping the deprecated notifier.Closed() usage - wire-dispatch test now covers real unsubscribe teardown; drop the redundant disabled-wiring test and LocalNotifier teardown proxy
# Conflicts: # cmd/mcp/main.go # node/eth/backend.go # rpc/jsonrpc/daemon.go # rpc/jsonrpc/debug_api_test.go # rpc/jsonrpc/debug_execution_witness.go # rpc/jsonrpc/witness_cache.go # rpc/jsonrpc/witness_cache_builder.go # rpc/jsonrpc/witness_cache_builder_test.go # rpc/jsonrpc/witness_cache_test.go # rpc/jsonrpc/witness_cache_wiring_test.go
main enabled the modernize linter after this branch forked, so the feed test's manual loop counters and WaitGroup.Add/go pairs now fail lint.
There was a problem hiding this comment.
Pull request overview
This PR adds a new debug_subscribe("executionWitnesses") WebSocket subscription that pushes freshly built execution witnesses to subscribers as the eager witness cache completes them, avoiding an extra debug_executionWitness round-trip per new block for tip-following consumers.
Changes:
- Add a witness push feed (
witnessFeed) and wire it into the eager witness cache insert path so every cached witness is also published. - Implement
DebugAPIImpl.ExecutionWitnessessubscription endpoint with encoding validation (currently JSON-only) and explicit erroring when the embedded cache is unavailable. - Generalize the shared subscription pump (
subscribeRPC) so it can be reused beyond filter-backed subscriptions; add focused tests for feed + subscription wiring.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| rpc/jsonrpc/witness_subscription.go | Adds the executionWitnesses debug subscription endpoint and payload types. |
| rpc/jsonrpc/witness_subscription_test.go | Tests encoding validation, nil-cache behavior, notifier behavior, and end-to-end WS delivery. |
| rpc/jsonrpc/witness_feed.go | Introduces a non-blocking fan-out feed with drop-oldest behavior for slow subscribers. |
| rpc/jsonrpc/witness_feed_test.go | Tests fan-out, unsubscribe behavior, overflow drop policy, and concurrency. |
| rpc/jsonrpc/witness_cache.go | Reworks witness cache into a struct that includes an internal push feed and a single store+publish path. |
| rpc/jsonrpc/witness_cache_wiring_test.go | Validates builder/serve-side share the same cache+feed and that builder publishes reach subscribers. |
| rpc/jsonrpc/witness_cache_test.go | Updates cache tests for the new struct-based cache layout. |
| rpc/jsonrpc/witness_cache_builder.go | Routes cache inserts through a shared storeWitness method that also publishes to the feed. |
| rpc/jsonrpc/witness_cache_builder_test.go | Adds a test ensuring storeWitness both caches and publishes verbatim bytes. |
| rpc/jsonrpc/eth_filters.go | Updates subscribeRPC signature and moves the filters == nil guard into callers. |
| rpc/jsonrpc/debug_api_test.go | Updates cache-hit tests to use the embedded LRU field. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Design and tests look solid — the fan-out is small, the ownership of the pre-marshaled bytes is clear, and the wire-dispatch test covers teardown. One item must be resolved before merge: the type this PR restructures was rewritten on main. Merge main first —
|
|
Correction to my earlier comment, with the actual merge result.
// buildAndCache — normal commitment-history path
api.witnessCache.Add(hash, &ExecutionWitnessResult{cachedJSON: enc}) // publish lost
witnessCacheEntriesResidentGauge.SetInt(api.witnessCache.Len())
// tryHeadCaptureBuild — head-capture path
api.storeWitness(num, hash, enc) // publish landed hereThe cause: main's Net effect after a naive merge: a normal node stops pushing entirely — the main use case — while only head-capture nodes push. No conflict marker warns about it. It compiles as long as Worth adding a test that pins the invariant rather than relying on review — assert both build paths publish, so a future refactor of either one cannot silently unhook the feed. |
# Conflicts: # rpc/jsonrpc/debug_api_test.go # rpc/jsonrpc/witness_cache.go # rpc/jsonrpc/witness_cache_builder_test.go # rpc/jsonrpc/witness_cache_test.go
Route buildAndCache through storeWitness so both build paths publish, and pin that with TestBuildPathsPublish — the merge with main auto-merged the storeWitness hunk onto the head-capture path and left the durable one inserting directly, with no conflict marker. Add witness_feed_drop_total and witness_feed_subscribers, guard witnessFeedBuffer against a cap under 2 (PrioritizedSend would fall through to a blocking send under the feed lock), correct the subscribeRPC doc comment, and narrow the drop rationale: a node without commitment history cannot recompute a dropped block.
|
Merged main (1c9c4a9) and resolved the findings in 2875e2c. The auto-merge hazardConfirmed exactly as you described: after the merge,
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (2)
rpc/jsonrpc/witness_subscription.go:37
- The comment refers to
debug_subscription("executionWitnesses"), but the RPC entrypoint isdebug_subscribe. This is likely a typo and can confuse readers when grepping for the API name.
// WitnessNotification is one debug_subscription("executionWitnesses") payload: the
// completed block's number and hash plus its witness as raw pre-marshaled JSON.
rpc/jsonrpc/witness_subscription.go:53
validateWitnessEncodingreturns a plainerror, which will be encoded as JSON-RPC error code -32000. Since this is a user parameter validation failure, it should return*rpc.InvalidParamsErrorso clients get the standard -32602 invalid params code.
case "", "json":
return nil
default:
return fmt.Errorf("unsupported witness encoding %q (supported: json)", opts.Encoding)
}
A rejected encoding is a parameter failure, so it belongs on -32602 like the other param validation in the package, not the generic -32000.
|
Copilot suppressed two comments; taking one:
|
The build-path test only checked that a push arrived, so an insert that skips store failed as a bare timeout. It now reports the bypass by name when the witness reached the cache without a push, and pins that a build publishes once. TestCacheAddAloneDoesNotPublish states the trap directly: the promoted LRU Add caches without publishing.
AskAlexSharov
left a comment
There was a problem hiding this comment.
Approving. The design is sound and the tests pin the parts that matter. A few things to fix or confirm; none of them block.
1. witness_feed_subscribers can stick at a wrong value
subscribe/unsubscribe mutate subs under f.mu, release the lock, then call subCount(), which takes the lock again. Two concurrent unsubscribes can publish the gauge in the reverse order of their mutations, so the gauge stays at 1 after the last subscriber left, until the next sub/unsub repairs it.
Set the gauge in the same critical section:
func (f *witnessFeed) subscribe() chan witnessPush {
ch := make(chan witnessPush, witnessFeedBuffer)
f.mu.Lock()
f.subs[ch] = struct{}{}
witnessFeedSubscribersGauge.SetInt(len(f.subs))
f.mu.Unlock()
return ch
}Same for unsubscribe. subCount() then stays test-only.
2. witness_feed_drop_total counts overflow events, not dropped witnesses
PrioritizedSend discards up to cap(ch)/2 queued pushes per overflow — 2 with witnessFeedBuffer = 4 — but publish increments the counter once. An operator who reads witness_feed_drop_total to answer "how many witnesses did this subscriber lose?" gets an undercount of up to 2x. Either rename it to witness_feed_overflow_total, or inline the drain and count the real discards.
3. The push stream is not contiguous, and nothing on the wire says so
shouldBuild builds only for a single-header advance that is not already cached. A catch-up burst (multi-header batch), a stale-pin skip, or any build failure produces no push for those heights. That is the intended contract, but a client learns about it only by watching blockNumber for holes.
The line added to configuring-erigon.mdx reads "pushes each witness over WebSocket as the cache builds it", which sounds contiguous. Worth one clause there and on the ExecutionWitnesses docstring: pushes are best-effort, gaps are normal during catch-up, and the client re-requests the missing heights with debug_executionWitness.
4. ExecutionWitnesses is missing from the PrivateDebugAPI interface
daemon.go:158 registers Service: PrivateDebugAPI(debugImpl). It works today because registerName reflects the dynamic type (*DebugAPIImpl), so a method outside the interface is still found. But the interface no longer describes the debug surface, and TestWitnessSubscriptionWireDispatch registers the concrete api, so it does not exercise the production registration shape. Add the method to the interface, and register PrivateDebugAPI(api) in that test.
5. No cap on subscribers
The debug namespace is Public: true. Every subscription adds a channel that publish walks while holding f.mu, on the builder's critical path. Per-subscriber work is bounded — PrioritizedSend always frees a slot before its blocking send, given witnessFeedBuffer >= 2 — so this is not a stall. But there is no limit. With the double gate (--witness.cache.blocks plus an exposed WS debug namespace) I think it is acceptable; just confirm it is a conscious choice.
Minor
subscribeRPC returns nil, err on the subscribe-error path, while the two guards above it return &rpc.Subscription{}, err. Pre-existing, but the function is already being touched.
Verified while reading
storeis the single insert on both build paths.TestCacheAddAloneDoesNotPublishandTestBuildPathsPublishcatch a regression back to a bareAdd— good tests, they name the bypass instead of only asserting the happy path.- The
encaliasing between the cache entry andwitnessPush.jsonis safe:MarshalFastJSONreturns a freshjson.Marshalbuffer with no pooling, and nothing mutates it after the store. Extra retention is bounded by the buffered pushes, which point at the same bytes the LRU holds. const _ = uint(witnessFeedBuffer - 2)is a good compile-time guard for thecap/2assumption insidePrioritizedSend.- Moving the
filters == nilguard into the callers also fixed a stale comment:subscribe()has always run synchronously, not inside the goroutine. go test ./rpc/jsonrpc/ -run 'TestWitnessFeed|TestWitnessSubscription|TestWitnessNotification|TestWitnessCacheStorePublishes|TestCacheAddAloneDoesNotPublish|TestWitnessCacheWiringSharedFeed'is green locally.
Set witness_feed_subscribers inside the feed lock: publishing it after the unlock let two concurrent unsubscribes report in the reverse order of their mutations and leave the gauge stale. Rename witness_feed_drop_total to witness_feed_overflow_total — one overflow discards up to cap/2 queued pushes, so it never counted witnesses. Document that the push stream has gaps: catch-up bursts, skipped or failed builds and slow subscribers all leave holes, and the client re-requests them. Add ExecutionWitnesses to PrivateDebugAPI so the interface describes the debug surface, register through it in the wire test, and return an empty subscription on subscribeRPC's subscribe-error path like its sibling guards.
|
Thanks. All five fixed in 2ff1c0d, plus the minor. 1. Gauge race — real, fixed as suggested. Both 2. Drop counter — renamed to 3. Gaps — you are right that the docs line implied contiguity. Both the mdx and the 4. Interface — added, and the wire test registers 5. Subscriber cap — conscious, no cap. It needs Minor —
|
Move store next to Add so the wrapper and the raw insert read together and main's accessor group stays contiguous. Drop the rationale repeated on store — the type doc already carries it — and the duplicate re-request note on ExecutionWitnesses.
zilkworm pulls witnesses; PR 1 (erigontech/z6m#114) gave it the WS transport and tip watermark, but each block still costs a
debug_executionWitnessround trip. This adds the node-side push: every witness the eager cache builder completes is pushed to subscribers.Changes
debug_subscribe("executionWitnesses", {encoding?}): fresh-only, stateless; per-subscriber cap-4 channel with drop-oldest fan-out (common.PrioritizedSend)store, so every built witness is also published, reusing the cache's pre-marshaled JSON with no per-subscriber marshal{blockNumber, blockHash, witness}; reorgs re-push the same height with the new hash via the existing rebuildencodingparam reserved (jsononly;rlplater); nil cache (standalone rpcdaemon / flag off) → explicit errorsubscribeRPCgeneralized: the filters-nil guard moved into its callers so any namespace can reuse the shared pumpwitness_feed_drop_totalandwitness_feed_subscribers; no new flags — embedded-only, gated by--witness.cache.blocks