Skip to content

Commit 38b0aaa

Browse files
committed
Optimization GraphQL::AnyCable::Cleaner. Use a separate key for storing created_time of subscriptions and channels
1 parent f16a4bb commit 38b0aaa

File tree

4 files changed

+95
-28
lines changed

4 files changed

+95
-28
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ To avoid filling Redis storage with stale subscription data:
131131
132132
Heroku users should set up `use_redis_object_on_cleanup` setting to `false` due to [limitations in Heroku Redis](https://devcenter.heroku.com/articles/heroku-redis#connection-permissions).
133133
134+
### Recommendations
135+
136+
You should run `GraphQL::AnyCable::Cleaner` or `rake graphql:anycable:clean` periodically because it helps to avoid swelling of RAM consumption,
137+
but before using `GraphQL::AnyCable::Cleaner` or `rake graphql:anycable:clean`, you should configure `subscription_expiration_seconds`
138+
and `use_redis_object_on_cleanup` settings
139+
134140
## Configuration
135141
136142
GraphQL-AnyCable uses [anyway_config] to configure itself. There are several possibilities to configure this gem:

lib/graphql/anycable/cleaner.rb

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ module AnyCable
55
module Cleaner
66
extend self
77

8+
MAX_RECORDS_AT_ONCE = 1_000
9+
810
def clean
911
clean_channels
1012
clean_subscriptions
@@ -16,24 +18,28 @@ def clean_channels
1618
return unless config.subscription_expiration_seconds
1719
return unless config.use_redis_object_on_cleanup
1820

19-
redis.scan_each(match: "#{redis_key(adapter::CHANNEL_PREFIX)}*") do |key|
20-
idle = redis.object("IDLETIME", key)
21-
next if idle&.<= config.subscription_expiration_seconds
21+
store_name = redis_key(adapter::CHANNELS_STORAGE_TIME)
2222

23-
redis.del(key)
24-
end
23+
remove_old_objects(store_name)
2524
end
2625

2726
def clean_subscriptions
2827
return unless config.subscription_expiration_seconds
2928
return unless config.use_redis_object_on_cleanup
3029

31-
redis.scan_each(match: "#{redis_key(adapter::SUBSCRIPTION_PREFIX)}*") do |key|
32-
idle = redis.object("IDLETIME", key)
33-
next if idle&.<= config.subscription_expiration_seconds
30+
store_name = redis_key(adapter::SUBSCRIPTIONS_STORAGE_TIME)
3431

35-
redis.del(key)
36-
end
32+
remove_old_objects(store_name)
33+
end
34+
35+
# For cases, when we need to clear only `subscription time storage`
36+
def clean_subscription_time_storage
37+
clean_created_time_storage(redis_key(adapter::SUBSCRIPTIONS_STORAGE_TIME))
38+
end
39+
40+
# For cases, when we need to clear only `channel time storage`
41+
def clean_channel_time_storage
42+
clean_created_time_storage(redis_key(adapter::CHANNELS_STORAGE_TIME))
3743
end
3844

3945
def clean_fingerprint_subscriptions
@@ -74,6 +80,33 @@ def config
7480
def redis_key(prefix)
7581
"#{config.redis_prefix}-#{prefix}"
7682
end
83+
84+
def remove_old_objects(store_name)
85+
# Determine the time point before which the keys should be deleted
86+
time_point = (Time.now - config.subscription_expiration_seconds).to_i
87+
88+
# iterating per 1000 records
89+
loop do
90+
# fetches keys, which need to be deleted
91+
keys = redis.zrangebyscore(store_name, "-inf", time_point, limit: [0, MAX_RECORDS_AT_ONCE])
92+
93+
break if keys.empty?
94+
95+
redis.multi do |pipeline|
96+
pipeline.del(*keys)
97+
pipeline.zrem(store_name, keys)
98+
end
99+
end
100+
end
101+
102+
# For cases, when the key was dropped, but it remains in the `subscription/channel time storage`
103+
def clean_created_time_storage(storage_name)
104+
redis.zscan_each(storage_name, count: MAX_RECORDS_AT_ONCE) do |key|
105+
next if redis.exists?(key)
106+
107+
redis.zrem(storage_name, key)
108+
end
109+
end
77110
end
78111
end
79112
end

lib/graphql/subscriptions/anycable_subscriptions.rb

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
require "anycable"
44
require "graphql/subscriptions"
55
require "graphql/anycable/errors"
6-
76
# rubocop: disable Metrics/AbcSize, Metrics/LineLength, Metrics/MethodLength
87

98
# A subscriptions implementation that sends data as AnyCable broadcastings.
@@ -56,10 +55,12 @@ class AnyCableSubscriptions < GraphQL::Subscriptions
5655

5756
def_delegators :"GraphQL::AnyCable", :redis, :config
5857

59-
SUBSCRIPTION_PREFIX = "subscription:" # HASH: Stores subscription data: query, context, …
60-
FINGERPRINTS_PREFIX = "fingerprints:" # ZSET: To get fingerprints by topic
61-
SUBSCRIPTIONS_PREFIX = "subscriptions:" # SET: To get subscriptions by fingerprint
62-
CHANNEL_PREFIX = "channel:" # SET: Auxiliary structure for whole channel's subscriptions cleanup
58+
SUBSCRIPTION_PREFIX = "subscription:" # HASH: Stores subscription data: query, context, …
59+
FINGERPRINTS_PREFIX = "fingerprints:" # ZSET: To get fingerprints by topic
60+
SUBSCRIPTIONS_PREFIX = "subscriptions:" # SET: To get subscriptions by fingerprint
61+
CHANNEL_PREFIX = "channel:" # SET: Auxiliary structure for whole channel's subscriptions cleanup
62+
SUBSCRIPTIONS_STORAGE_TIME = "subscription-storage-time" # ZSET: Stores name and created_time of subscriptions
63+
CHANNELS_STORAGE_TIME = "channel-storage-time" # ZSET: Stores name and created_time of channels
6364

6465
# @param serializer [<#dump(obj), #load(string)] Used for serializing messages before handing them to `.broadcast(msg)`
6566
def initialize(serializer: Serialize, **rest)
@@ -131,7 +132,6 @@ def write_subscription(query, events)
131132
# Store subscription_id in the channel state to cleanup on disconnect
132133
write_subscription_id(channel, channel_uniq_id)
133134

134-
135135
events.each do |event|
136136
channel.stream_from(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint)
137137
end
@@ -145,15 +145,29 @@ def write_subscription(query, events)
145145
}
146146

147147
redis.multi do |pipeline|
148-
pipeline.sadd(redis_key(CHANNEL_PREFIX) + channel_uniq_id, [subscription_id])
149-
pipeline.mapped_hmset(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, data)
148+
full_subscription_id = "#{redis_key(SUBSCRIPTION_PREFIX)}#{subscription_id}"
149+
full_channel_id = "#{redis_key(CHANNEL_PREFIX)}#{channel_uniq_id}"
150+
151+
pipeline.sadd(full_channel_id, [subscription_id])
152+
pipeline.mapped_hmset(full_subscription_id, data)
153+
150154
events.each do |event|
151155
pipeline.zincrby(redis_key(FINGERPRINTS_PREFIX) + event.topic, 1, event.fingerprint)
152156
pipeline.sadd(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint, [subscription_id])
153157
end
154-
next unless config.subscription_expiration_seconds
155-
pipeline.expire(redis_key(CHANNEL_PREFIX) + channel_uniq_id, config.subscription_expiration_seconds)
156-
pipeline.expire(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, config.subscription_expiration_seconds)
158+
159+
# add the records to the storages if subscription_expiration_seconds is nil
160+
unless config.subscription_expiration_seconds
161+
current_timestamp = Time.now.to_i
162+
163+
pipeline.zadd(redis_key(SUBSCRIPTIONS_STORAGE_TIME), current_timestamp, full_subscription_id)
164+
pipeline.zadd(redis_key(CHANNELS_STORAGE_TIME), current_timestamp, full_channel_id)
165+
166+
next
167+
end
168+
169+
pipeline.expire(full_channel_id, config.subscription_expiration_seconds)
170+
pipeline.expire(full_subscription_id, config.subscription_expiration_seconds)
157171
end
158172
end
159173

@@ -182,7 +196,10 @@ def delete_subscription(subscription_id)
182196
fingerprint_subscriptions[redis_key(FINGERPRINTS_PREFIX) + topic] = score
183197
end
184198
# Delete subscription itself
185-
pipeline.del(redis_key(SUBSCRIPTION_PREFIX) + subscription_id)
199+
full_subscription_id = "#{redis_key(SUBSCRIPTION_PREFIX)}#{subscription_id}"
200+
201+
pipeline.del(full_subscription_id)
202+
pipeline.zrem(redis_key(SUBSCRIPTIONS_STORAGE_TIME), full_subscription_id)
186203
end
187204
# Clean up fingerprints that doesn't have any subscriptions left
188205
redis.pipelined do |pipeline|
@@ -200,10 +217,14 @@ def delete_channel_subscriptions(channel_or_id)
200217
# Missing in case disconnect happens before #execute
201218
return unless channel_id
202219

203-
redis.smembers(redis_key(CHANNEL_PREFIX) + channel_id).each do |subscription_id|
220+
full_channel_id = "#{redis_key(CHANNEL_PREFIX)}#{channel_id}"
221+
222+
redis.smembers(full_channel_id).each do |subscription_id|
204223
delete_subscription(subscription_id)
205224
end
206-
redis.del(redis_key(CHANNEL_PREFIX) + channel_id)
225+
226+
redis.del(full_channel_id)
227+
redis.zrem(redis_key(CHANNELS_STORAGE_TIME), full_channel_id)
207228
end
208229

209230
private

spec/graphql/anycable_spec.rb

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,20 @@
135135
end
136136

137137
it "removes subscription from redis" do
138-
expect(redis.exists?("graphql-subscription:some-truly-random-number")).to be true
139-
expect(redis.exists?("graphql-channel:some-truly-random-number")).to be true
138+
subscription = "graphql-subscription:#{subscription_id}"
139+
channel = "graphql-channel:#{subscription_id}"
140+
141+
expect(redis.exists?(subscription)).to be true
142+
expect(redis.exists?(channel)).to be true
140143
expect(redis.exists?("graphql-fingerprints::productUpdated:")).to be true
144+
expect(redis.zrange("graphql-subscription-storage-time", 0, -1).member?(subscription)).to be true
145+
expect(redis.zrange("graphql-channel-storage-time", 0, -1).member?(channel)).to be true
141146
subject
142-
expect(redis.exists?("graphql-channel:some-truly-random-number")).to be false
147+
expect(redis.exists?(channel)).to be false
143148
expect(redis.exists?("graphql-fingerprints::productUpdated:")).to be false
144-
expect(redis.exists?("graphql-subscription:some-truly-random-number")).to be false
149+
expect(redis.exists?(subscription)).to be false
150+
expect(redis.zrange("graphql-subscription-storage-time", 0, -1).member?(subscription)).to be false
151+
expect(redis.zrange("graphql-channel-storage-time", 0, -1).member?(channel)).to be false
145152
end
146153
end
147154

0 commit comments

Comments
 (0)