Fix subscription event delivery when some subscriptions expire - #53
Conversation
|
GitHub workflow installs graphql 2.5 (here GraphQL-Ruby ~> 2.3), which brings stricter validation for subscriptions here and here According to the validations, subscription operations must have only one root field subscription SomeSubscription {
productCreated { id title }
productUpdated { id }
}I have updated the tests (stat_spec and anycable_spec) to follow this limitation |
|
Hi @prog-supdex — thanks for this, matches issue #52 for us too. We've patched read_subscription locally (return nil on missing query_string) to stop the bad broadcasts, but still exposed to the execute_grouped race since it only tries one subscription_id. Any status on getting this merged? Would like to drop our workaround once it lands. |
b21ada2 to
08e9fa7
Compare
|
Thanks for digging into this, @prog-supdex! I ran the suite on both Verdict: the core fix is correct and worth merging, but there is one behavioral regression I reproduced empirically that should be addressed first. What it gets right (verified)1. The race in 2. This also fixes the second half of #52 directly: previously 3. The For anyone using the Issues1. Blocking — the retry loop re-executes the query N times when subscribers unsubscribe
if subscriptions_context[:unsubscribed] && !subscriptions_context[:final_update]
delete_subscription(subscription_id)
result = nil
end
Measured with a 5-subscriber group whose
"Unsubscribe when the thing finishes" is a common idiom, and it pairs badly with 2. Same root cause —
|
`break if result || subscription_exists?` retried the group whenever the subscription was gone after #execute_update. graphql-ruby produces that exact state on a second path: #unsubscribe without a final update deletes the subscription and returns nil (`:unsubscribed && !:final_update` in graphql/subscriptions.rb). So a single subscriber unsubscribing on a trigger made the loop re-run the whole query for every remaining subscriber of the group -- measured 5 executions for a group of 5, against 1 before -- and then deliver nothing to anybody. The probe was racy in the other direction, too: a subscription could expire between #execute_update returning NO_UPDATE and the exists? round trip, turning a skip that GraphQL asked for into a broadcast to the whole group. Only #read_subscription can tell these apart, so let it say so directly by raising instead of returning nothing. That removes the second probe, and with it #subscription_exists?, as a read already tells us whether the subscription is there: a healthy group now costs 3 Redis round trips per fingerprint instead of 4. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015nM1t8qN6YdVeB35o99cnj
Pin down the case that regressed: a subscriber calling #unsubscribe from its update must not make the group re-run the query. It fails against the previous retry condition and passes now. Also cover #delete_subscription without an explicit connection, which is the point of dropping its `AnyCable.redis` default, and give the re-entrancy check an example of its own -- it was asserting from inside a stub in a hook, so a failure would have been reported against an unrelated expectation. Drop the stub on Event#fingerprint: a hand-built event cannot compute one (it has no query), but nothing on this path asks it to, as #execute_grouped takes the fingerprint as an argument. Read the real one from Redis instead. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015nM1t8qN6YdVeB35o99cnj
The `AnyCable.redis` default was a user-visible fix of its own, and `read_subscription` raising is a contract change for anyone calling it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015nM1t8qN6YdVeB35o99cnj
|
I pushed three commits addressing the review above. Summary of what changed and why: 1.
|
| graphql-ruby | callers of read_subscription |
|---|---|
| 1.11.0 … 2.5.4 | Subscriptions#execute_update only |
| 2.6.0 … 2.6.9 | execute_update + Dashboard::Subscriptions::SubscriptionsController#show |
execute_update in turn has exactly one caller in every version, Subscriptions#execute — which this adapter overrides to raise NotImplementedError. That is the proof that #execute_grouped is the only live path: anything else reaching execute_update would already be failing today.
The Dashboard call site is gated by before_action :check_installed, whose feature_installed? requires GraphQL::Pro::Subscriptions; AnyCableSubscriptions is not one, so the action never runs. That controller also needs broadcast_subscription_id?, still_subscribed?, read_subscriptions, topics and topic_last_triggered_at, none of which exist in the OSS gem at all.
As a runtime check rather than a reading of the source, forcing read_subscription to raise for every id and then exercising subscribe, trigger, delete_channel_subscriptions, delete_subscription, all five Cleaner entry points and Stats leaks nothing.
Worth knowing that graphql-ruby's own delete_subscription call for a missing subscription, which is now skipped, was a no-op anyway: it reads the events map out of the very hash that expired, so it only ever DELs an absent key. Redis state left behind is byte-identical, and the stale id waits for Cleaner exactly as before.
Verification
50 examples, 0 failures and RuboCop clean, against graphql-ruby 1.13, 2.0 and 2.3.
🤖 Investigated and written with Claude Code; the pushed commits are co-authored by Claude.
anycable#51 landed the same fix this branch had already grown past: it stopped #read_subscription handing graphql-ruby a hash of nils for a missing key, which is what produced "No query string was present" on the client. Resolved in favour of this branch, which subsumes it. Both make a missing subscription unmistakable to the caller; this one raises rather than returning nil, because returning nil is what conflates a vanished subscription with an update GraphQL skipped on purpose, and #execute_grouped has to tell those apart. It also keys off :query_string alone rather than all four values, so a half-written hash counts as missing too, and guards #strip with #to_s. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015nM1t8qN6YdVeB35o99cnj
Three lines of constructor existed to format one message for one raise site, and the class name already says what happened. `raise ..., subscription_id` reads as `SubscriptionExpiredError: sub-abc123`, which is the same information. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015nM1t8qN6YdVeB35o99cnj
|
Thanks, I went through the latest changes. The exception-based approach looks good to me and fixes both the unsubscribe regression and the I added two small regression specs: one for the subscription disappearing after I also updated the PR description. The full suite passes on graphql-ruby 1.13 and 2.5. |
|
Thank you very much for the contribution and for your patience! Released in 1.3.4, please enjoy! |
Fix #52
Why
A subscription can expire after
execute_groupedselects it but beforeread_subscriptionreads its data.When that happens, the event is not executed and other active subscriptions with the same fingerprint do not receive the update.
What we do
If the selected subscription is no longer stored in Redis, try the next subscription from the same group.
read_subscriptionnow raisesGraphQL::AnyCable::SubscriptionExpiredErrorwhen the Redis hash is missing, andexecute_groupedretries only in that case.If GraphQL returns
NO_UPDATEor unsubscribes, stop and do not run the same update again.Release the Redis connection before deserializing subscription data.
Use the configured Redis connector when deleting a subscription instead of the deprecated
GraphQL::AnyCable.redisaccessor.Added specs for the race condition,
NO_UPDATE,unsubscribe, missing subscriptions, Redis connection re-entrancy, and the case when all subscriptions have expired.