Skip to content

[rpc] Add Tracking to Subscriptions Made via HTTP for Unused Filter Eviction#18591

Closed
onelapahead wants to merge 1 commit into
erigontech:mainfrom
kaleido-io:evictable-rpc-filters
Closed

[rpc] Add Tracking to Subscriptions Made via HTTP for Unused Filter Eviction#18591
onelapahead wants to merge 1 commit into
erigontech:mainfrom
kaleido-io:evictable-rpc-filters

Conversation

@onelapahead

@onelapahead onelapahead commented Jan 8, 2026

Copy link
Copy Markdown
Contributor

This adds a missing feature from Geth: evicting stale filters via a background "timeout" loop. Similar to what was done in https://github.com/okx/xlayer-erigon/pull/777 but hopefully more efficient.

Meant to help avoid accumulating Go heap for unused filters which can further hurt performance over time.

Also adds metrics for keeping track of the number of filters we have so we can confirm they do not grow indefinitely.

@onelapahead

onelapahead commented Jan 11, 2026

Copy link
Copy Markdown
Contributor Author

We've been soaking this in our nodes with success on ensuring unused filters are evicted overtime. This PR is ready for review now.

@onelapahead onelapahead marked this pull request as ready for review January 11, 2026 14:18
@yperbasis yperbasis added the RPC label Jan 13, 2026

@canepat canepat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for this, it looks like a solid contribution.

LGTM, please rebase on main and avoid changing execution-spec-tests commit

@onelapahead onelapahead requested a review from lupin012 as a code owner March 8, 2026 22:38
@onelapahead

Copy link
Copy Markdown
Contributor Author

Thanks @canepat - should be ready for review now. We'll start soaking this on a cherry picked version of v3.3.8 including this change.

@yperbasis yperbasis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Bug: Log store leak on remote filter update failure

Severity: High

In unsubscribeLogsInternal, if the remote logs filter update fails, the function returns false without calling deleteLogStore(id). The filter has already been removed from logsFilters (via removeLogsFilter),
but its associated log store in ff.logsStores is never cleaned up — it will accumulate indefinitely.

The old code always reached deleteLogStore(id) regardless of the remote update outcome. Fix:

// In unsubscribeLogsInternal, before return false in the error path:
ff.deleteLogStore(id)
return false

Also, the return value change from true → false when the remote update fails is defensible (the operation partially failed), but callers should be aware that the local filter was removed even though false is
returned.


Race condition: TOCTOU in eviction loop

Severity: Medium

The eviction uses a two-phase approach:

  1. Phase 1: Range over the SyncMap, calling ShouldEvict() and collecting IDs
  2. Phase 2: Iterate collected IDs, call unsubscribe*Internal()

Between phases, a client can call TouchSubscription() (resetting lastAccess via atomic store), but phase 2 does not re-check ShouldEvict(). An actively-polled subscription could be evicted.

The SyncMap uses sync.RWMutex — Range holds RLock, but releases it before phase 2. The atomic lastAccess update in Touch is invisible to the already-evaluated ShouldEvict.

Fix: Re-check ShouldEvict inside the phase-2 loop before unsubscribing:

for _, id := range headsToEvict {
if sub, ok := ff.headsSubs.Get(id); ok {
if !sub.ShouldEvict(timeout) {
continue // Touched since we collected it
}
// ... proceed with eviction
}
}

This doesn't eliminate the race entirely (another TOCTOU window between the re-check and unsubscribe), but it shrinks the window from O(full Range iteration) to O(single unsubscribe call), which is practical
given that the timeout is typically minutes.


Race condition: Double-close on concurrent unsubscribe

Severity: Low (mitigated by existing guard)

The eviction loop and a client-initiated UnsubscribeHeads can race on the same ID:

  1. Eviction: Get(id) → gets sub reference
  2. Client: Get(id) → gets same sub reference
  3. Eviction: Delete(id) + sub.Close()
  4. Client: sub.Close() again

The chan_sub.Close() has a if s.closed { return } guard under a mutex, which prevents a panic from double-closing the channel. However, both goroutines call decrementMetrics, leading to a double-decrement of
the gauge — the active subscription count would go negative.

Fix: The UnsubscribeHeads public method should handle the case where unsubscribeHeadsInternal returns false (already evicted) by skipping the decrementMetrics call — which it already does. So this is safe as
written for metrics. The Close() double-call is protected by the mutex guard. Low severity, but worth a comment.


Metrics: subscriptions renamed to subscriptions_active

Severity: Low (operational)

The gauge metric name changes from subscriptions to subscriptions_active, and gains a protocol label dimension. This is a breaking change for any existing Prometheus dashboards or alerting rules referencing
subscriptions{filter=...}.

Investigation shows the old activeSubscriptionsGauge was defined but never actually used in the codebase — so this is effectively creating a new metric, not breaking an existing one. The rename is fine.

The getSubscriptionCounter and getReapedCounter functions use embedded labels in metric names (e.g., subscriptions_created_total{filter="logs",protocol="http"}). This pattern is correct — the Erigon metrics
library (diagnostics/metrics/parsing.go) explicitly parses embedded {label="value"} syntax from metric names, and this pattern is used widely (e.g., in execution/commitment/commitment.go).


Summary

  │                        Issue                         │ Severity │               Type                │                                                                                                           
  ├──────────────────────────────────────────────────────┼──────────┼───────────────────────────────────┤
  │ Log store leak on remote update failure              │ High     │ Bug                               │                                                                                                           
  ├──────────────────────────────────────────────────────┼──────────┼───────────────────────────────────┤
  │ TOCTOU: eviction after Touch                         │ Medium   │ Race condition                    │                                                                                                           
  ├──────────────────────────────────────────────────────┼──────────┼───────────────────────────────────┤
  │ Double-close / double-decrement potential            │ Low      │ Race condition (mostly mitigated) │                                                                                                           
  ├──────────────────────────────────────────────────────┼──────────┼───────────────────────────────────┤                                                                                                           
  │ SetProtocol not thread-safe                          │ Low      │ Data race                         │
  ├──────────────────────────────────────────────────────┼──────────┼───────────────────────────────────┤                                                                                                           
  │ Metric rename (subscriptions → subscriptions_active) │ Low      │ Operational / breaking dashboards │
  ├──────────────────────────────────────────────────────┼──────────┼───────────────────────────────────┤                                                                                                           
  │ Exported DecrementMetrics unused                     │ Nit      │ Dead code                         │
  ├──────────────────────────────────────────────────────┼──────────┼───────────────────────────────────┤                                                                                                           
  │ 0 * time.Minute                                      │ Nit      │ Style                             │
  ├──────────────────────────────────────────────────────┼──────────┼───────────────────────────────────┤                                                                                                           
  │ Verbose per-touch Trace logging                      │ Nit      │ Performance                       │
  └──────────────────────────────────────────────────────┴──────────┴───────────────────────────────────┘                                                                                                           

The log store leak is the only must-fix before merge. The TOCTOU race should ideally be addressed (re-check before evicting), but given the timeout is on the order of minutes, it's unlikely to cause real issues
in practice. The rest are minor.

@yperbasis yperbasis added this to the 3.5.0 milestone Mar 21, 2026
@yperbasis yperbasis modified the milestones: 3.5.0, 3.6.0 May 7, 2026
@onelapahead onelapahead force-pushed the evictable-rpc-filters branch 2 times, most recently from b122a88 to ebb112c Compare May 21, 2026 23:26
@onelapahead onelapahead requested a review from yperbasis May 21, 2026 23:26
…viction

Signed-off-by: hfuss <hayden.fuss@kaleido.io>

self-review fixes

Signed-off-by: hfuss <hayden.fuss@kaleido.io>
@onelapahead onelapahead force-pushed the evictable-rpc-filters branch from ebb112c to 6d67fb1 Compare May 22, 2026 00:00
@onelapahead

Copy link
Copy Markdown
Contributor Author

Sorry @yperbasis - been awhile on this but have rebased and addressed feedback 🙏🏻

@yperbasis yperbasis requested review from Copilot and removed request for Giulio2002 June 9, 2026 14:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds timeout-based tracking and eviction for RPC polling filters (to prevent stale filters from accumulating), along with new metrics and CLI configuration to control eviction behavior.

Changes:

  • Extend rpchelper.Sub with last-access/protocol tracking and implement it in chan_sub.
  • Add a background eviction loop in rpchelper.Filters driven by a configurable timeout, plus TouchSubscription/tracking helpers and subscription lifecycle metrics.
  • Wire protocol labeling and timeout tracking into JSON-RPC filter creation/polling paths, and expose a new CLI flag/config field for the timeout.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
rpc/rpchelper/subscription.go Adds protocol + last-access tracking methods to subscriptions to support eviction and metrics.
rpc/rpchelper/metrics.go Introduces protocol-labeled gauges and subscription lifecycle counters.
rpc/rpchelper/filters.go Implements the timeout eviction loop, touch/track hooks, unsubscribe internals, and metric updates.
rpc/rpchelper/config.go Adds RpcSubscriptionFiltersTimeout to configuration with default disabled (0).
rpc/jsonrpc/eth_filters.go Hooks filter creation/polling into tracking/touch logic and labels WS subscriptions.
node/cli/flags.go Adds rpc.subscription.filters.timeout flag for embedded node CLI.
node/cli/default_flags.go Registers the new timeout flag in default flag set.
cmd/rpcdaemon/cli/config.go Adds the timeout option to rpcdaemon’s Cobra flags/config.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread rpc/rpchelper/filters.go
Comment on lines +116 to +121
// Start the timeout loop for filter eviction if timeout is configured
if config.RpcSubscriptionFiltersTimeout > 0 {
logger.Info("[rpc] [filters] starting timeout loop for idle filter eviction",
"timeout", config.RpcSubscriptionFiltersTimeout,
"checkInterval", config.RpcSubscriptionFiltersTimeout/2)
go ff.timeoutLoop(ctx, config.RpcSubscriptionFiltersTimeout)
Comment thread rpc/rpchelper/filters.go
Comment on lines +336 to +338
// evictStaleFilters removes filters that haven't been accessed within the timeout duration.
// It iterates the existing subscription maps and checks each subscription's embedded tracking.
func (ff *Filters) evictStaleSubscriptions(timeout time.Duration) {
return "", rpc.ErrNotificationsUnsupported
}
txsCh, id := api.filters.SubscribePendingTxs(32)
api.filters.TrackSubscription(rpchelper.SubscriptionID(id), rpchelper.FilterTypePendingTxs, rpchelper.ProtocolHTTP)
return "", rpc.ErrNotificationsUnsupported
}
ch, id := api.filters.SubscribeNewHeads(32)
api.filters.TrackSubscription(rpchelper.SubscriptionID(id), rpchelper.FilterTypeHeads, rpchelper.ProtocolHTTP)
return "", rpc.ErrNotificationsUnsupported
}
logs, id := api.filters.SubscribeLogs(256, crit)
api.filters.TrackSubscription(rpchelper.SubscriptionID(id), rpchelper.FilterTypeLogs, rpchelper.ProtocolHTTP)
Comment thread rpc/rpchelper/filters.go
Comment on lines +116 to +122
// Start the timeout loop for filter eviction if timeout is configured
if config.RpcSubscriptionFiltersTimeout > 0 {
logger.Info("[rpc] [filters] starting timeout loop for idle filter eviction",
"timeout", config.RpcSubscriptionFiltersTimeout,
"checkInterval", config.RpcSubscriptionFiltersTimeout/2)
go ff.timeoutLoop(ctx, config.RpcSubscriptionFiltersTimeout)
} else {
@yperbasis

Copy link
Copy Markdown
Member

Thanks @onelapahead! Your branch is on an org-owned fork, which GitHub does not let base-repo maintainers push to, so we couldn't deliver the merge with latest main here. The work continues in #22261, which carries your commit with authorship preserved plus the conflict resolution against the refactored WebSocket subscription handlers. Closing in favor of that PR.

@yperbasis yperbasis closed this Jul 6, 2026
yperbasis added a commit that referenced this pull request Jul 6, 2026
… check

Replace ShouldEvict + re-check with CloseIfIdle, which decides staleness and
closes under the same lock Touch uses, so a subscription polled within the
timeout can never be evicted; this removes the TOCTOU race the old two-phase
eviction acknowledged as unavoidable. lastAccess and protocol move from
atomics to the existing chan_sub mutex.

Also compute the eviction check interval once in New so the startup log
matches the actual ticker (it previously logged timeout/2 while the loop
floored to 1s), fix the evictStaleSubscriptions doc-comment name, and add
eviction/touch coverage tests. Addresses Copilot review comments from #18591.
pull Bot pushed a commit to Dustin4444/erigon that referenced this pull request Jul 7, 2026
…rics (erigontech#22261)

Supersedes erigontech#18591 by @onelapahead (his branch lives on an org fork that
maintainers cannot push to; the original commit is carried here with
authorship preserved).

Polling filters (`eth_newFilter`, `eth_newBlockFilter`,
`eth_newPendingTransactionFilter`) previously lived until
`eth_uninstallFilter` or a restart, accumulating buffered heap for
clients that walked away. They are now evicted when not polled within
`--rpc.subscription.filters.timeout` (default **5 minutes**, matching
the stale-filter deadline in geth's `eth/filters` —
`FilterAPI.timeoutLoop` with `Config.Timeout`; 0 disables eviction). Any
poll (`eth_getFilterChanges`, `eth_getFilterLogs`) resets the filter's
deadline. WebSocket subscriptions are unaffected — they end with their
connection.

**Behavior change:** a client that polls slower than the timeout gets
`filter not found` and must recreate its filter — same as geth.

## Metrics

- `subscriptions_active{filter,protocol}` gauge — current live
subscriptions
- `subscriptions_created_total` / `subscriptions_unsubscribed_total`
counters (`active = created − unsubscribed` holds exactly)
- `subscriptions_reaped_total{filter}` — the timeout-evicted subset
- Counters are registered once as cached vecs
(`metrics.GetOrCreateCounterVec` is new in `diagnostics/metrics`,
mirroring `GaugeVec`)

## Implementation notes

- Pollable subscriptions live in a single id→type registry: `Subscribe*`
takes the protocol at creation (`http` = evictable polling filter, `ws`
= connection-scoped; internal subscriptions are neither evicted nor
counted), so a subscription cannot be created untracked. Polls resolve
the filter type and reset the deadline in one map lookup, and one
eviction loop iterates the registry for all filter types.
- Eviction decides staleness and closes the subscription under the same
lock the poll-side touch uses, so an actively-polled filter can never be
evicted; the metrics decrement is gated on the map delete, so eviction
racing a client unsubscribe counts once.
- Teardown is orphan-proof: a forwarding goroutine draining a closed
channel can no longer recreate a per-filter store entry after it was
deleted (the write is dropped under the store lock when the subscription
is gone) — for logs, heads, and pending txs alike.
- One remote logs-filter update per eviction cycle, however many logs
filters expire in it.
- `eth_getFilterChanges` now returns **all** buffered pending-tx batches
(previously only the first batch was reported while all were consumed —
silent data loss), and each poll costs a single subscription-map lookup.
- `eth_newFilter` and `eth_subscribe` (logs/receipts) now return an
error when the remote filter update fails, instead of handing out a dead
filter id / never-firing subscription (that silent failure predates this
PR).
- The dead, commented-out geth `PublicFilterAPI` port in
`rpc/filters/api.go` is removed; the criteria types and `FilterCriteria`
JSON decoding stay.

Covered by unit tests in `rpc/rpchelper/filters_eviction_test.go`
(eviction, touch-prevents-eviction, WS exemption, orphan guard, batched
remote update, and a concurrent touch/evict exercise run under `-race`)
plus an `eth_getFilterChanges` batching test in `rpc/jsonrpc`. ChangeLog
updated (breaking-change entry + new flag/metrics).

---------

Signed-off-by: hfuss <hayden.fuss@kaleido.io>
Co-authored-by: hfuss <hayden.fuss@kaleido.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants