Skip to content

Expose the SubscribeRequest sink for runtime filter updates - #302

Open
senzenn wants to merge 9 commits into
mainfrom
feat/grpc-runtime-filter-updates
Open

Expose the SubscribeRequest sink for runtime filter updates#302
senzenn wants to merge 9 commits into
mainfrom
feat/grpc-runtime-filter-updates

Conversation

@senzenn

@senzenn senzenn commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #286.

subscribe_with_request returns a sink alongside the update stream. The gRPC source bound it to _sub_tx and threw it away, so a subscription's filter set was fixed for the life of the connection. Changing filters meant dropping the connection and reconnecting, which loses messages in the window.

This keeps the sink. Runtime::filter_updates() hands the sending half to the caller, and the source applies each set it receives to the live subscription.

Approach

Two new SourceTrait methods with defaults, rather than changing connect's signature. There are 17 implementations, 11 of them test mocks, and defaults leave all of them alone.

The channel carries Filters, not SubscribeRequest. Converting to a wire request leaves commitment and from_slot unset and the source fills them from config afterwards, so a raw request would skip that and let the subscription drift from the registered parsers.

from_slot only goes on the initial subscribe. Servers read it as a replay request, so repeating it on an update replays the gap or ends the stream.

Fumarole discards its sink the same way at yellowstone-fumarole-source/src/lib.rs:137. Separate PR, now that the seam exists.

Testing

cargo test --workspace, clippy with -Dwarnings and cargo fmt --check all pass, and each commit builds on its own. New tests cover the request builder and a filter set reaching a source through the channel.

Notes for reviewers

  • A set the server refuses ends the run. Under run() or run_async() that is fatal and exits the process.
  • Keys are parser IDs. The wire subscription is derived from the registered pipelines (self.pipelines.filters() in runtime/src/lib.rs), so a key matching no pipeline still changes what the server sends while the runtime discards everything it produces.
  • Every set replaces the whole subscription rather than adding to it, since that is what the servers do with a mid-stream request. A caller resends the complete set each time, not a delta.
  • Delivery is best effort otherwise: a set rejected while the source is between connections gets held and retried on a timer, with nothing reported back to the sender.

The subscribe request was built inline in connect. Pull it into
build_subscribe_request so there is one place that turns a Filters into a
wire request, which is what a second caller will need.

from_slot stays out of the builder. It is a one-time start position rather
than a steady-state setting, which is why the client library overwrites the
field with the live checkpoint on reconnect instead of reusing the configured
value, so the initial subscribe sets it at the call site.
Sources hold their subscription for the lifetime of the connection, so a
caller whose filter set changes has to drop the connection and reconnect to
pick it up, losing messages in the window.

Add the seam for changing it in place. SourceTrait gains two defaulted
methods: supports_filter_updates, and connect_with_filter_updates which
ignores the receiver and defers to connect. Defaults rather than a signature
change on connect keeps all 17 existing implementations compiling untouched,
11 of which are test mocks.

The builder creates the channel and Runtime hands out the sending half via
filter_updates, gated on the source advertising support so a caller wiring
updates to a source that ignores them finds out at the call site. The runtime
drops its own copy of the sender before running, otherwise a source waiting on
updates would wait forever on a channel nobody could send to.

The payload is Filters rather than SubscribeRequest. The conversion to a wire
request leaves commitment and from_slot unset and the gRPC source fills them
from config afterwards, so a raw request would bypass that and let the
subscription drift from the registered parsers.
subscribe_with_request returns a sink alongside the stream, and the source
bound it to _sub_tx and dropped it. Keep it and drive it from the update
channel, so the read loop becomes a select over the stream and the receiver.
Both trait methods funnel into one private run, so there is a single
connection path rather than two copies of it.

A rejected send is held and retried rather than logged and dropped.
Auto-reconnect is on by default and the client swallows recoverable stream
errors to reconnect behind our back, so a send failing mid-reconnect never
surfaces on the stream. Worse, the sink records a request into its reconnect
state only after a successful send, so the reconnect would come back with the
previous filters while the caller's send had already returned Ok. The stream
producing again is the signal that the reconnect landed and the sink has a
live sender, so that is when a held set goes out.

Also spell out in build_subscribe_request why from_slot must not ride along on
an update: geyser reads it as a replay request and either replays every slot
since or ends the stream when it has no replay buffer, and richat rejects the
request outright when the set contains blocks.

The update branch retires itself once the sending half is gone, since a closed
receiver is ready on every poll and would otherwise spin the loop.
A mock source that records the filter sets it receives, covering the four
cases the plumbing can get wrong: a set sent through the handle reaches the
source, an unsupporting source hands out no sender, the sender is handed out
at most once, and a runtime whose handle was never taken still runs to
completion.

The last one is a regression guard. try_run_async consumes the runtime but the
unclaimed sender used to stay alive inside it for the whole scope, so a source
awaiting recv waited on a channel that could never produce. It hangs without
the explicit drop.

Tests share one static for the recorded IDs and libtest runs them on separate
threads, so each records under its own parser ID and asserts on that alone
rather than clearing and comparing the whole vector.
Document how to take the handle before the runtime is consumed, that each set
replaces the whole subscription rather than adding to it, and that keys are
parser IDs so an unregistered one changes what the server sends and then gets
discarded at trace level.

Say plainly that the feature depends on the provider honouring mid-stream
requests. Both yellowstone-grpc-geyser and richat apply them in their source,
but a deployment can sit behind infrastructure that does not forward them.
A server that refuses a filter set answers on the stream, not the sink, and
the codes it uses are outside the client's recoverable set, so the stream
ends and the runtime exits. That was already true before this branch for the
initial subscribe, but sending sets mid-stream makes it reachable long after
startup, where the cause is far less obvious.

Nothing can un-send the request, so record how many updates went out on the
connection and report it alongside the status. An operator seeing a run stop
on InvalidArgument can then tell a rejected update apart from an unrelated
server error. Document the same on the accessor and in the README.
The server applies a new set promptly, but a consumer sees it only once
whatever it has already queued drains, so the wait is a property of how far
behind the pipeline is rather than of the update itself. Updates matching the
new set arrive stale by that margin and settle to real time once the backlog
clears.

Say that plainly on the accessor and in the README, because a caller who reads
the delay the other way will size the wrong problem.
…raffic

Review caught that a set held after a sink rejection was only retried inside
the arm handling a successful stream item, which assumes the connection keeps
producing. It need not. The filter being replaced may match nothing, and the
client swallows its own keepalive messages rather than yielding them, so there
are live connections where the retry site is never reached and the set is
stranded for the rest of the connection.

Retry on a five second tick instead, gated on something actually being held so
the branch is disabled in the normal case. Log when a connection ends with a
set still unsent, since the earlier warning promised a retry that then never
happened.

Also correct three things review found stale or wrong. The channel-depth doc
said sets are never coalesced, which stopped being true when a newer set
started superseding a held one. The accessor doc said a refused set ends the
run, understating it: run and run_async treat that error as fatal and exit the
process. And the delivery test built its payload from Prefilter::default(),
which converts to an entirely empty request, so it would have passed on a
payload that says nothing on the wire.
Adding the retry timer left the same send-and-count block at three sites and
made the copy in the stream arm redundant, since the timer already covers
every case. Drop that one so the per-update path carries no filter work at
all, and let the send helper own the success count so the two remaining sites
are a single line each.

Reset the timer when a set is newly held. An interval's first tick is
immediate, so without it a rejected set retried at once, inside the same
reconnect window that had just rejected it.
@senzenn
senzenn marked this pull request as draft September 1, 2026 12:09
@senzenn
senzenn requested a review from Nagaprasadvr September 1, 2026 12:09
@senzenn
senzenn marked this pull request as ready for review September 2, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose the SubscribeRequest Sink when connecting to yellowstone grpc source

1 participant