Skip to content

Commit 0d32aba

Browse files
committed
refactoring the solution for storing created_time of subscriptions/channels
1 parent 3d0d38a commit 0d32aba

File tree

4 files changed

+77
-53
lines changed

4 files changed

+77
-53
lines changed

README.md

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,11 @@ 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-
### Limitations
134+
### Recommendations
135135
136-
The `GraphQL::AnyCable::Cleaner` uses [IDLETIME](https://redis.io/commands/object-idletime/) for detecting how long the object was inactive.
137-
It is a good way to detect inactive subscriptions, but `IDLETIME` is updated every time, not only when new subscriptions are, but when we read it
138-
It means that `broadcasting` also updates the `IDLETIME`, which does not give us the ability to detect useless subscriptions for cleaning
139-
It will be resolved in the next versions
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
140139
141140
## Configuration
142141
@@ -173,21 +172,6 @@ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several pos
173172
174173
And any other way provided by [anyway_config]. Check its documentation!
175174
176-
## Emergency Actions
177-
178-
In situations, when you don't set `subscription_expiration_seconds`, have a lot of inactive subscriptions and `GraphQL::AnyCable::Cleaner` doesn`t help in that, you can do the
179-
next actions for clearing subscriptions
180-
181-
1. Set `config.subscription_expiration_seconds`. After that, the new subscriptions will have `TTL`
182-
2. Through the `redis_prefix` (look at the `Configuration` block) change the redis prefixes, which uses for storing keys
183-
3. Run the script, using the old `redis_prefix` (the default value is `graphql`)
184-
```ruby
185-
redis = GraphQL::AnyCable.redis
186-
redis.scan_each("graphql-subscription:*") do |key|
187-
redis.del(key) if redis.ttl(key) < 0 # Remove it, because it is an old record
188-
end
189-
```
190-
191175
## Data model
192176
193177
As in AnyCable there is no place to store subscription data in-memory, it should be persisted somewhere to be retrieved on `GraphQLSchema.subscriptions.trigger` and sent to subscribed clients. `graphql-anycable` uses the same Redis database as AnyCable itself.

lib/graphql/anycable/cleaner.rb

Lines changed: 38 additions & 17 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,26 +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-
next unless object_created_time_expired?(key)
30+
store_name = redis_key(adapter::SUBSCRIPTIONS_STORAGE_TIME)
3331

34-
redis.multi do |pipeline|
35-
pipeline.del(key)
36-
pipeline.hdel(redis_key(adapter::CREATED_AT_KEY), key)
37-
end
38-
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))
3943
end
4044

4145
def clean_fingerprint_subscriptions
@@ -77,14 +81,31 @@ def redis_key(prefix)
7781
"#{config.redis_prefix}-#{prefix}"
7882
end
7983

80-
def object_created_time_expired?(key)
81-
last_created_time = redis.hget(redis_key(adapter::CREATED_AT_KEY), key)
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])
8292

83-
return false unless last_created_time
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
84101

85-
expire_date = Time.parse(last_created_time) + config.subscription_expiration_seconds
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)
86106

87-
Time.now >= expire_date
107+
redis.zrem(storage_name, key)
108+
end
88109
end
89110
end
90111
end

lib/graphql/subscriptions/anycable_subscriptions.rb

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ class AnyCableSubscriptions < GraphQL::Subscriptions
5959
FINGERPRINTS_PREFIX = "fingerprints:" # ZSET: To get fingerprints by topic
6060
SUBSCRIPTIONS_PREFIX = "subscriptions:" # SET: To get subscriptions by fingerprint
6161
CHANNEL_PREFIX = "channel:" # SET: Auxiliary structure for whole channel's subscriptions cleanup
62-
CREATED_AT_KEY = "objects:list-created-times" # HASH: Stores name and created_time of object
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)
@@ -145,19 +146,28 @@ def write_subscription(query, events)
145146

146147
redis.multi do |pipeline|
147148
full_subscription_id = "#{redis_key(SUBSCRIPTION_PREFIX)}#{subscription_id}"
149+
full_channel_id = "#{redis_key(CHANNEL_PREFIX)}#{channel_uniq_id}"
148150

149-
pipeline.sadd(redis_key(CHANNEL_PREFIX) + channel_uniq_id, [subscription_id])
151+
pipeline.sadd(full_channel_id, [subscription_id])
150152
pipeline.mapped_hmset(full_subscription_id, data)
151153

152-
pipeline.hset(redis_key(CREATED_AT_KEY), full_subscription_id, Time.now.to_s)
153-
154154
events.each do |event|
155155
pipeline.zincrby(redis_key(FINGERPRINTS_PREFIX) + event.topic, 1, event.fingerprint)
156156
pipeline.sadd(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint, [subscription_id])
157157
end
158-
next unless config.subscription_expiration_seconds
159-
pipeline.expire(redis_key(CHANNEL_PREFIX) + channel_uniq_id, config.subscription_expiration_seconds)
160-
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)
161171
end
162172
end
163173

@@ -189,7 +199,7 @@ def delete_subscription(subscription_id)
189199
full_subscription_id = "#{redis_key(SUBSCRIPTION_PREFIX)}#{subscription_id}"
190200

191201
pipeline.del(full_subscription_id)
192-
pipeline.hdel(redis_key(CREATED_AT_KEY), full_subscription_id)
202+
pipeline.zrem(redis_key(SUBSCRIPTIONS_STORAGE_TIME), full_subscription_id)
193203
end
194204
# Clean up fingerprints that doesn't have any subscriptions left
195205
redis.pipelined do |pipeline|
@@ -207,10 +217,14 @@ def delete_channel_subscriptions(channel_or_id)
207217
# Missing in case disconnect happens before #execute
208218
return unless channel_id
209219

210-
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|
211223
delete_subscription(subscription_id)
212224
end
213-
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)
214228
end
215229

216230
private

spec/graphql/anycable_spec.rb

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +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
141-
expect(redis.hexists("graphql-objects:list-created-times", "graphql-subscription:some-truly-random-number")).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
142146
subject
143-
expect(redis.exists?("graphql-channel:some-truly-random-number")).to be false
147+
expect(redis.exists?(channel)).to be false
144148
expect(redis.exists?("graphql-fingerprints::productUpdated:")).to be false
145-
expect(redis.exists?("graphql-subscription:some-truly-random-number")).to be false
146-
expect(redis.hexists("graphql-objects:list-created-times", "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
147152
end
148153
end
149154

0 commit comments

Comments
 (0)