Skip to content

Commit aab684c

Browse files
committed
add spec for Cleaner. Add ability to set "subscription_expiration_seconds" as argument
1 parent 1a1981e commit aab684c

File tree

6 files changed

+218
-19
lines changed

6 files changed

+218
-19
lines changed

Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ group :development, :test do
1818
gem "rubocop"
1919
gem "rubocop-rspec"
2020
end
21+
22+
group :test do
23+
gem "timecop"
24+
end

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,20 @@ 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+
You can also call specific commands `clean_channels` or `clean_subscriptions` with passed `subscription_expiration_seconds` as an argument.
135+
For instance
136+
137+
```ruby
138+
GraphQL::AnyCable::Cleaner.clean_channels(100)
139+
# or
140+
GraphQL::AnyCable::Cleaner.clean_subscriptions(100)
141+
```
142+
143+
It will be helpful in cases when you have another value, `subscription_expiration_seconds` (or you don't have one) in `configuration`,
144+
but it needs to remove `subscriptions` and `channels` earlier without changing `subscription_expiration_seconds` in `configuration`
145+
146+
You can't put a zero value for `clean_channels` or `clean_subscriptions` methods
147+
134148
### Recommendations
135149
136150
You should run `GraphQL::AnyCable::Cleaner` or `rake graphql:anycable:clean` periodically because it helps to avoid swelling of RAM consumption,

lib/graphql/anycable/cleaner.rb

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,26 @@ def clean
1414
clean_topic_fingerprints
1515
end
1616

17-
def clean_channels
18-
return unless config.subscription_expiration_seconds
17+
def clean_channels(expiration_seconds = nil)
18+
expiration_seconds ||= config.subscription_expiration_seconds
19+
20+
return if expiration_seconds.nil? || expiration_seconds.to_i.zero?
1921
return unless config.use_redis_object_on_cleanup
2022

2123
store_name = redis_key(adapter::CHANNELS_STORAGE_TIME)
2224

23-
remove_old_objects(store_name)
25+
remove_old_objects(store_name, expiration_seconds.to_i)
2426
end
2527

26-
def clean_subscriptions
27-
return unless config.subscription_expiration_seconds
28+
def clean_subscriptions(expiration_seconds = nil)
29+
expiration_seconds ||= config.subscription_expiration_seconds
30+
31+
return if expiration_seconds.nil? || expiration_seconds.to_i.zero?
2832
return unless config.use_redis_object_on_cleanup
2933

3034
store_name = redis_key(adapter::SUBSCRIPTIONS_STORAGE_TIME)
3135

32-
remove_old_objects(store_name)
36+
remove_old_objects(store_name, expiration_seconds.to_i)
3337
end
3438

3539
# For cases, when we need to clear only `subscription time storage`
@@ -81,9 +85,9 @@ def redis_key(prefix)
8185
"#{config.redis_prefix}-#{prefix}"
8286
end
8387

84-
def remove_old_objects(store_name)
88+
def remove_old_objects(store_name, expiration_seconds)
8589
# Determine the time point before which the keys should be deleted
86-
time_point = (Time.now - config.subscription_expiration_seconds).to_i
90+
time_point = (Time.now - expiration_seconds).to_i
8791

8892
# iterating per 1000 records
8993
loop do

lib/graphql/anycable/tasks/clean_expired_subscriptions.rake

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ namespace :graphql do
99

1010
namespace :clean do
1111
# Clean up old channels
12-
task :channels do
13-
GraphQL::AnyCable::Cleaner.clean_channels
12+
task :channels, [:expire_seconds] do |_, args|
13+
GraphQL::AnyCable::Cleaner.clean_channels(args[:expire_seconds]&.to_i)
1414
end
1515

1616
# Clean up old subscriptions (they should have expired by themselves)
17-
task :subscriptions do
18-
GraphQL::AnyCable::Cleaner.clean_subscriptions
17+
task :subscriptions, [:expire_seconds] do |_, args|
18+
GraphQL::AnyCable::Cleaner.clean_subscriptions(args[:expire_seconds]&.to_i)
1919
end
2020

2121
# Clean up subscription_ids from event fingerprints for expired subscriptions

lib/graphql/subscriptions/anycable_subscriptions.rb

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -156,15 +156,12 @@ def write_subscription(query, events)
156156
pipeline.sadd(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint, [subscription_id])
157157
end
158158

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
159+
current_timestamp = Time.now.to_i
162160

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)
161+
pipeline.zadd(redis_key(SUBSCRIPTIONS_STORAGE_TIME), current_timestamp, full_subscription_id)
162+
pipeline.zadd(redis_key(CHANNELS_STORAGE_TIME), current_timestamp, full_channel_id)
165163

166-
next
167-
end
164+
next unless config.subscription_expiration_seconds
168165

169166
pipeline.expire(full_channel_id, config.subscription_expiration_seconds)
170167
pipeline.expire(full_subscription_id, config.subscription_expiration_seconds)

spec/graphql/cleaner_spec.rb

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# frozen_string_literal: true
2+
3+
require "timecop"
4+
5+
RSpec.describe GraphQL::AnyCable::Cleaner do
6+
let(:query) do
7+
<<~GRAPHQL
8+
subscription SomeSubscription { productUpdated { id } }
9+
GRAPHQL
10+
end
11+
12+
let(:channel) do
13+
socket = double("Socket", istate: AnyCable::Socket::State.new({}))
14+
connection = double("Connection", anycable_socket: socket)
15+
double("Channel", id: "legacy_id", params: { "channelId" => "legacy_id" }, stream_from: nil, connection: connection)
16+
end
17+
18+
let(:subscription_id) do
19+
"some-truly-random-number"
20+
end
21+
22+
let(:redis) { GraphQL::AnyCable.redis }
23+
24+
before do
25+
AnycableSchema.execute(
26+
query: query,
27+
context: { channel: channel, subscription_id: subscription_id },
28+
variables: {},
29+
operation_name: "SomeSubscription",
30+
)
31+
end
32+
33+
describe ".clean_subscriptions" do
34+
context "when expired_seconds passed via argument" do
35+
context "when subscriptions are expired" do
36+
let(:lifetime_in_seconds) { 10 }
37+
38+
it "cleans subscriptions" do
39+
expect(redis.keys("graphql-subscription:*").count).to be > 0
40+
41+
Timecop.freeze(Time.now + 10) do
42+
described_class.clean_subscriptions(lifetime_in_seconds)
43+
end
44+
45+
expect(redis.keys("graphql-subscription:*").count).to be 0
46+
end
47+
end
48+
49+
context "when subscriptions are not expired" do
50+
let(:lifetime_in_seconds) { 100 }
51+
52+
it "not cleans subscriptions" do
53+
described_class.clean_subscriptions(lifetime_in_seconds)
54+
55+
expect(redis.keys("graphql-subscription:*").count).to be > 0
56+
end
57+
end
58+
end
59+
60+
context "when expired_seconds passed via config" do
61+
context "when subscriptions are expired" do
62+
around do |ex|
63+
old_value = GraphQL::AnyCable.config.subscription_expiration_seconds
64+
GraphQL::AnyCable.config.subscription_expiration_seconds = 10
65+
66+
ex.run
67+
68+
GraphQL::AnyCable.config.subscription_expiration_seconds = old_value
69+
end
70+
71+
it "cleans subscriptions" do
72+
expect(redis.keys("graphql-subscription:*").count).to be > 0
73+
74+
Timecop.freeze(Time.now + 10) do
75+
described_class.clean_subscriptions
76+
end
77+
78+
expect(redis.keys("graphql-subscription:*").count).to be 0
79+
end
80+
end
81+
82+
context "when config.subscription_expiration_seconds is nil" do
83+
it "remains subscriptions" do
84+
Timecop.freeze(Time.now + 10) do
85+
described_class.clean_subscriptions
86+
end
87+
88+
expect(redis.keys("graphql-subscription:*").count).to be > 0
89+
end
90+
end
91+
end
92+
93+
context "when an expiration_seconds is not positive integer" do
94+
it "does not clean subscriptions" do
95+
expect(described_class).to_not receive(:remove_old_objects)
96+
97+
described_class.clean_subscriptions("")
98+
99+
expect(redis.keys("graphql-subscription:*").count).to be > 0
100+
end
101+
end
102+
end
103+
104+
describe ".clean_channels" do
105+
context "when expired_seconds passed via argument" do
106+
context "when channels are expired" do
107+
let(:lifetime_in_seconds) { 10 }
108+
109+
it "cleans subscriptions" do
110+
expect(redis.keys("graphql-channel:*").count).to be > 0
111+
112+
Timecop.freeze(Time.now + 10) do
113+
described_class.clean_channels(lifetime_in_seconds)
114+
end
115+
116+
expect(redis.keys("graphql-channel:*").count).to be 0
117+
end
118+
end
119+
120+
context "when channels are not expired" do
121+
let(:lifetime_in_seconds) { 100 }
122+
123+
it "does not clean channels" do
124+
described_class.clean_channels(lifetime_in_seconds)
125+
126+
expect(redis.keys("graphql-channel:*").count).to be > 0
127+
end
128+
end
129+
end
130+
131+
context "when an expiration_seconds is not positive integer" do
132+
it "does not clean channels" do
133+
expect(described_class).to_not receive(:remove_old_objects)
134+
135+
described_class.clean_channels("")
136+
137+
expect(redis.keys("graphql-channel:*").count).to be > 0
138+
end
139+
end
140+
end
141+
142+
describe ".clean_fingerprint_subscriptions" do
143+
context "when subscription is blank" do
144+
subject do
145+
AnycableSchema.subscriptions.delete_subscription(subscription_id)
146+
147+
described_class.clean_fingerprint_subscriptions
148+
end
149+
150+
it "cleans graphql-subscriptions" do
151+
subscriptions_key = redis.keys("graphql-subscriptions:*")[0]
152+
153+
expect(redis.smembers(subscriptions_key).empty?).to be false
154+
155+
subject
156+
157+
expect(redis.smembers(subscriptions_key).empty?).to be true
158+
end
159+
end
160+
end
161+
162+
describe ".clean_topic_fingerprints" do
163+
subject do
164+
# Emulate situation, when subscriptions in fingerprints are orphan
165+
redis.scan_each(match: "graphql-subscriptions:*").each do |k|
166+
redis.del(k)
167+
end
168+
169+
described_class.clean_topic_fingerprints
170+
end
171+
172+
it "cleans fingerprints" do
173+
expect(redis.zrange("graphql-fingerprints::productUpdated:", 0, -1).empty?).to be false
174+
175+
subject
176+
177+
expect(redis.zrange("graphql-fingerprints::productUpdated:", 0, -1).empty?).to be true
178+
end
179+
end
180+
end

0 commit comments

Comments
 (0)