This is a graphql-pro bug (closed-source), but Pro has no public issue tracker, so I'm filing here to reach you — happy to move it to support@graphql.pro if you'd prefer. We're a Pro license holder.
Gem / location
graphql-pro 1.30.0
GraphQL::Pro::Subscriptions::RedisStorage, in the clean_inactive_subs Lua script used by each_fingerprint_and_subscription_ids.
Summary
When a subscription's query record is removed by Redis TTL (stale_ttl_s) rather than by an explicit delete_subscription, the cleanup that runs while iterating a topic's fingerprints fails to remove that subscription id from its fingerprintsubscriptions:<fingerprint> set — unless every member of that set is dead. Any fingerprint set that still has at least one live member accumulates dead ids indefinitely, so fingerprintsubscriptions:* grows without bound.
Root cause
The fingerprintsubscriptions:<fingerprint> set is populated with the bare subscription id (via sadd(fingerprint_subscriptions_key(f), [sub_id]) in the write path). But in clean_inactive_subs, the SREM against that set is issued with the prefixed query key (<prefix>query:<id>) — the same variable used for the preceding DEL — instead of the bare id. Since that value is never a member of the set, the SREM is a silent no-op and the dead id is left behind.
The leak is masked for fully-dead sets only because run_fingerprint_batch separately DELs the whole set when no active ids remain. Mixed (live + dead) sets are where it leaks.
Strong signal that this is the bug and not intended: the legacy path (filter_active_subscriptions) does it correctly — it SREMs the bare inactive_subscription_ids. Only the fingerprint path uses the prefixed key.
Suggested fix
In clean_inactive_subs, SREM the bare subscription id from fingerprintsubscriptions:<fingerprint> (the bare ids are already available 1:1 alongside the prefixed query keys), matching what the legacy filter_active_subscriptions path does.
Reproduction
- Configure
RedisStorage with a short stale_ttl_s.
- Create two subscriptions whose queries hash to the same fingerprint (same query + variables), so both ids land in one
fingerprintsubscriptions:<fp> set.
- Let one subscription's query record expire via TTL while the other stays live.
- Trigger the topic (runs
each_fingerprint_and_subscription_ids).
- Observe: the expired id is still a member of
fingerprintsubscriptions:<fp> (SMEMBERS lists it) and is never reaped.
Impact (our production)
Redis backing GraphQL subscriptions roughly doubled in memory over a few months. ~96% of keys were gql:sub:*, and a sampled ~99% of fingerprintsubscriptions members pointed at query records that had already expired. The affected pattern is recurring per-user fingerprints (e.g. mobile clients that reconnect with a fresh subscription id but an identical query+variables fingerprint): the set always keeps one live member, so the bug applies and dead ids pile up.
Thanks for all your work on graphql-ruby/pro!
This is a
graphql-probug (closed-source), but Pro has no public issue tracker, so I'm filing here to reach you — happy to move it tosupport@graphql.proif you'd prefer. We're a Pro license holder.Gem / location
graphql-pro1.30.0GraphQL::Pro::Subscriptions::RedisStorage, in theclean_inactive_subsLua script used byeach_fingerprint_and_subscription_ids.Summary
When a subscription's query record is removed by Redis TTL (
stale_ttl_s) rather than by an explicitdelete_subscription, the cleanup that runs while iterating a topic's fingerprints fails to remove that subscription id from itsfingerprintsubscriptions:<fingerprint>set — unless every member of that set is dead. Any fingerprint set that still has at least one live member accumulates dead ids indefinitely, sofingerprintsubscriptions:*grows without bound.Root cause
The
fingerprintsubscriptions:<fingerprint>set is populated with the bare subscription id (viasadd(fingerprint_subscriptions_key(f), [sub_id])in the write path). But inclean_inactive_subs, theSREMagainst that set is issued with the prefixed query key (<prefix>query:<id>) — the same variable used for the precedingDEL— instead of the bare id. Since that value is never a member of the set, theSREMis a silent no-op and the dead id is left behind.The leak is masked for fully-dead sets only because
run_fingerprint_batchseparately DELs the whole set when no active ids remain. Mixed (live + dead) sets are where it leaks.Strong signal that this is the bug and not intended: the legacy path (
filter_active_subscriptions) does it correctly — itSREMs the bareinactive_subscription_ids. Only the fingerprint path uses the prefixed key.Suggested fix
In
clean_inactive_subs,SREMthe bare subscription id fromfingerprintsubscriptions:<fingerprint>(the bare ids are already available 1:1 alongside the prefixed query keys), matching what the legacyfilter_active_subscriptionspath does.Reproduction
RedisStoragewith a shortstale_ttl_s.fingerprintsubscriptions:<fp>set.each_fingerprint_and_subscription_ids).fingerprintsubscriptions:<fp>(SMEMBERSlists it) and is never reaped.Impact (our production)
Redis backing GraphQL subscriptions roughly doubled in memory over a few months. ~96% of keys were
gql:sub:*, and a sampled ~99% offingerprintsubscriptionsmembers pointed at query records that had already expired. The affected pattern is recurring per-user fingerprints (e.g. mobile clients that reconnect with a fresh subscription id but an identical query+variables fingerprint): the set always keeps one live member, so the bug applies and dead ids pile up.Thanks for all your work on graphql-ruby/pro!