graphql-anycable 1.1.7 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +26 -36
- data/CHANGELOG.md +13 -3
- data/Gemfile +3 -3
- data/README.md +140 -12
- data/lib/graphql/anycable/cleaner.rb +10 -22
- data/lib/graphql/anycable/config.rb +1 -2
- data/lib/graphql/anycable/stats.rb +95 -0
- data/lib/graphql/anycable/tasks/clean_expired_subscriptions.rake +0 -5
- data/lib/graphql/anycable/version.rb +1 -1
- data/lib/graphql/subscriptions/anycable_subscriptions.rb +32 -59
- data/lib/graphql-anycable.rb +5 -6
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bafa1a80487eeb78627ed0752751eb488dd33ca9709d38b205d61d310f97d7ae
|
|
4
|
+
data.tar.gz: 821cba31fd376a471c9db5757a2da3e51d936f618f372a624fab1515dea5d4e6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fdad5b3cfcc372ec326ebfa9641ecdc81d5f586aca7fc54edb5bc1985d4e0e8fabd3ba93eb25cf8e9f55556a7d5410a2971596ab52936706199cdb6c6f3faa10
|
|
7
|
+
data.tar.gz: f71265d7dc1f0afdb4162e2d6a00d87c92bd53aa484429a5dff138e7f53db754b75a55137d999314f6c600eee3d88d661b6ab91a87b1bdd9f52a0317a7d9e43a
|
data/.github/workflows/test.yml
CHANGED
|
@@ -10,35 +10,33 @@ on:
|
|
|
10
10
|
|
|
11
11
|
jobs:
|
|
12
12
|
test:
|
|
13
|
-
name: "GraphQL-Ruby ${{ matrix.graphql }}
|
|
13
|
+
name: "GraphQL-Ruby ${{ matrix.graphql }} AnyCable ${{ matrix.anycable }} on Ruby ${{ matrix.ruby }} Redis ${{ matrix.redis_version }}"
|
|
14
|
+
# Skip running tests for local pull requests (use push event instead), run only for foreign ones
|
|
15
|
+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.owner.login != github.event.pull_request.base.repo.owner.login
|
|
14
16
|
runs-on: ubuntu-latest
|
|
15
17
|
strategy:
|
|
16
18
|
fail-fast: false
|
|
17
19
|
matrix:
|
|
18
20
|
include:
|
|
21
|
+
- ruby: "3.3"
|
|
22
|
+
graphql: '~> 2.3'
|
|
23
|
+
anycable: '~> 1.5'
|
|
24
|
+
redis_version: latest
|
|
25
|
+
- ruby: "3.2"
|
|
26
|
+
graphql: '~> 2.2.0'
|
|
27
|
+
anycable: '~> 1.4.0'
|
|
28
|
+
redis_version: '7.2'
|
|
19
29
|
- ruby: "3.1"
|
|
20
30
|
graphql: '~> 2.0.0'
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
graphql: '~> 1.12.0'
|
|
31
|
-
client_id: 'true'
|
|
32
|
-
anycable_rails: '~> 1.1.0'
|
|
33
|
-
redis_version: 4.0.14
|
|
34
|
-
container:
|
|
35
|
-
image: ruby:${{ matrix.ruby }}
|
|
36
|
-
env:
|
|
37
|
-
CI: true
|
|
38
|
-
GRAPHQL_RUBY_VERSION: ${{ matrix.graphql }}
|
|
39
|
-
ANYCABLE_RAILS_VERSION: ${{ matrix.anycable_rails }}
|
|
40
|
-
GRAPHQL_ANYCABLE_USE_CLIENT_PROVIDED_UNIQ_ID: ${{ matrix.client_id }}
|
|
41
|
-
REDIS_URL: redis://redis:6379
|
|
31
|
+
anycable: '~> 1.3.0'
|
|
32
|
+
redis_version: '6.2'
|
|
33
|
+
env:
|
|
34
|
+
CI: true
|
|
35
|
+
GRAPHQL_RUBY_VERSION: ${{ matrix.graphql }}
|
|
36
|
+
ANYCABLE_VERSION: ${{ matrix.anycable }}
|
|
37
|
+
ANYCABLE_RAILS_VERSION: ${{ matrix.anycable }}
|
|
38
|
+
GRAPHQL_ANYCABLE_USE_CLIENT_PROVIDED_UNIQ_ID: ${{ matrix.client_id }}
|
|
39
|
+
REDIS_URL: redis://localhost:6379
|
|
42
40
|
services:
|
|
43
41
|
redis:
|
|
44
42
|
image: redis:${{ matrix.redis_version }}
|
|
@@ -47,21 +45,13 @@ jobs:
|
|
|
47
45
|
--health-interval 10s
|
|
48
46
|
--health-timeout 5s
|
|
49
47
|
--health-retries 5
|
|
48
|
+
ports:
|
|
49
|
+
- 6379:6379
|
|
50
50
|
steps:
|
|
51
|
-
- uses: actions/checkout@
|
|
52
|
-
- uses:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
- uses: ruby/setup-ruby@v1
|
|
53
53
|
with:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
restore-keys: |
|
|
57
|
-
bundle-${{ matrix.ruby }}-${{ matrix.graphql }}-${{ matrix.anycable }}-
|
|
58
|
-
bundle-${{ matrix.ruby }}-
|
|
59
|
-
- name: Upgrade Bundler to 2.0 (for older Rubies)
|
|
60
|
-
run: gem install bundler -v '~> 2.0'
|
|
61
|
-
- name: Bundle install
|
|
62
|
-
run: |
|
|
63
|
-
bundle config path vendor/bundle
|
|
64
|
-
bundle install
|
|
65
|
-
bundle update
|
|
54
|
+
ruby-version: ${{ matrix.ruby }}
|
|
55
|
+
bundler-cache: true
|
|
66
56
|
- name: Run RSpec
|
|
67
57
|
run: bundle exec rspec
|
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
-
## 1.
|
|
10
|
+
## 1.2.0 - 2024-05-07
|
|
11
11
|
|
|
12
12
|
### Changed
|
|
13
13
|
|
|
@@ -17,6 +17,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
|
|
17
17
|
|
|
18
18
|
See https://github.com/anycable/graphql-anycable/issues/43 for details.
|
|
19
19
|
|
|
20
|
+
### Removed
|
|
21
|
+
|
|
22
|
+
- Handling of client-provided channel identifiers. **BREAKING CHANGE**
|
|
23
|
+
|
|
24
|
+
Please make sure that you have changed your channel `disconnected` method to pass channel instance to GraphQL-AnyCable's `delete_channel_subscriptions` method.
|
|
25
|
+
See [release notes for version 1.1.0](https://github.com/anycable/graphql-anycable/releases/tag/v1.1.0) for details.
|
|
26
|
+
|
|
27
|
+
- Handling of pre-1.0 subscriptions data.
|
|
28
|
+
|
|
29
|
+
If you're still using version 0.5 or below, please upgrade to 1.0 or 1.1 first with `handle_legacy_subscriptions` setting enabled.
|
|
30
|
+
See [release notes for version 1.0.0](https://github.com/anycable/graphql-anycable/releases/tag/v1.0.0) for details.
|
|
31
|
+
|
|
20
32
|
## 1.1.6 - 2023-08-03
|
|
21
33
|
|
|
22
34
|
### Fixed
|
|
@@ -185,8 +197,6 @@ Technical release to test publishing via GitHub Actions.
|
|
|
185
197
|
|
|
186
198
|
Initial version: store subscriptions on redis, re-execute queries in sync. [@Envek]
|
|
187
199
|
|
|
188
|
-
[@ilyasgaraev]: https://github.com/ilyasgaraev "Ilyas Garaev"
|
|
189
|
-
[@smasry]: https://github.com/smasry "Samer Masry"
|
|
190
200
|
[@gsamokovarov]: https://github.com/gsamokovarov "Genadi Samokovarov"
|
|
191
201
|
[@bibendi]: https://github.com/bibendi "Misha Merkushin"
|
|
192
202
|
[@FX-HAO]: https://github.com/FX-HAO "Fuxin Hao"
|
data/Gemfile
CHANGED
|
@@ -7,9 +7,9 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
|
|
|
7
7
|
# Specify your gem's dependencies in graphql-anycable.gemspec
|
|
8
8
|
gemspec
|
|
9
9
|
|
|
10
|
-
gem "graphql", ENV.fetch("GRAPHQL_RUBY_VERSION", "~>
|
|
11
|
-
gem "anycable", ENV.fetch("ANYCABLE_VERSION", "~> 1.
|
|
12
|
-
gem "anycable-rails", ENV.fetch("ANYCABLE_RAILS_VERSION", "~> 1.
|
|
10
|
+
gem "graphql", ENV.fetch("GRAPHQL_RUBY_VERSION", "~> 2.3")
|
|
11
|
+
gem "anycable", ENV.fetch("ANYCABLE_VERSION", "~> 1.5")
|
|
12
|
+
gem "anycable-rails", ENV.fetch("ANYCABLE_RAILS_VERSION", "~> 1.5")
|
|
13
13
|
|
|
14
14
|
group :development, :test do
|
|
15
15
|
gem "pry"
|
data/README.md
CHANGED
|
@@ -140,8 +140,7 @@ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several pos
|
|
|
140
140
|
```.env
|
|
141
141
|
GRAPHQL_ANYCABLE_SUBSCRIPTION_EXPIRATION_SECONDS=604800
|
|
142
142
|
GRAPHQL_ANYCABLE_USE_REDIS_OBJECT_ON_CLEANUP=true
|
|
143
|
-
|
|
144
|
-
GRAPHQL_ANYCABLE_USE_CLIENT_PROVIDED_UNIQ_ID=false
|
|
143
|
+
GRAPHQL_ANYCABLE_REDIS_PREFIX=graphql
|
|
145
144
|
```
|
|
146
145
|
|
|
147
146
|
2. YAML configuration files (note that this is `config/graphql_anycable.yml`, *not* `config/anycable.yml`):
|
|
@@ -151,8 +150,7 @@ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several pos
|
|
|
151
150
|
production:
|
|
152
151
|
subscription_expiration_seconds: 300 # 5 minutes
|
|
153
152
|
use_redis_object_on_cleanup: false # For restricted redis installations
|
|
154
|
-
|
|
155
|
-
use_client_provided_uniq_id: false # To avoid problems with non-uniqueness of Apollo channel identifiers
|
|
153
|
+
redis_prefix: graphql # You can configure redis_prefix for anycable-graphql subscription prefixes. Default value "graphql"
|
|
156
154
|
```
|
|
157
155
|
|
|
158
156
|
3. Configuration from your application code:
|
|
@@ -160,11 +158,48 @@ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several pos
|
|
|
160
158
|
```ruby
|
|
161
159
|
GraphQL::AnyCable.configure do |config|
|
|
162
160
|
config.subscription_expiration_seconds = 3600 # 1 hour
|
|
161
|
+
config.redis_prefix = "graphql" # on our side, we add `-` ourselves after the redis_prefix
|
|
163
162
|
end
|
|
164
163
|
```
|
|
165
164
|
|
|
166
165
|
And any other way provided by [anyway_config]. Check its documentation!
|
|
167
166
|
|
|
167
|
+
## Emergency actions
|
|
168
|
+
In situations when you don't set `subscription_expiration_seconds`, have a lot of inactive subscriptions, and `GraphQL::AnyCable::Cleaner` does`t help in that,
|
|
169
|
+
you can do the following actions for clearing subscriptions
|
|
170
|
+
|
|
171
|
+
1. Set `config.subscription_expiration_seconds`. After that, the new subscriptions will have `TTL`
|
|
172
|
+
2. Run the script
|
|
173
|
+
```ruby
|
|
174
|
+
redis = GraphQL::AnyCable.redis
|
|
175
|
+
config = GraphQL::AnyCable.config
|
|
176
|
+
|
|
177
|
+
# do it for subscriptions
|
|
178
|
+
redis.scan_each("graphql-subscription:*") do |key|
|
|
179
|
+
redis.expire(key, config.subscription_expiration_seconds) if redis.ttl(key) < 0
|
|
180
|
+
# or you can just remove it immediately
|
|
181
|
+
# redis.del(key) if redis.ttl(key) < 0
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# do it for channels
|
|
185
|
+
redis.scan_each("graphql-channel:*") do |key|
|
|
186
|
+
redis.expire(key, config.subscription_expiration_seconds) if redis.ttl(key) < 0
|
|
187
|
+
# or you can just remove it immediately
|
|
188
|
+
# redis.del(key) if redis.ttl(key) < 0
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Or you can change the `redis_prefix` in the `configuration` and then remove all records with the old_prefix
|
|
193
|
+
For instance:
|
|
194
|
+
|
|
195
|
+
1. Change the `redis_prefix`. The default `redis_prefix` is `graphql`
|
|
196
|
+
2. Run the ruby script, which remove all records with `old prefix`
|
|
197
|
+
```ruby
|
|
198
|
+
redis.scan_each("graphql-*") do |key|
|
|
199
|
+
redis.del(key)
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
168
203
|
## Data model
|
|
169
204
|
|
|
170
205
|
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.
|
|
@@ -183,13 +218,6 @@ As in AnyCable there is no place to store subscription data in-memory, it should
|
|
|
183
218
|
=> 52ee8d65-275e-4d22-94af-313129116388
|
|
184
219
|
```
|
|
185
220
|
|
|
186
|
-
> For backward compatibility with pre-1.0 versions of this gem older `graphql-event:#{event.topic}` set containing subscription identifiers is also supported.
|
|
187
|
-
>
|
|
188
|
-
> ```
|
|
189
|
-
> SMEMBERS graphql-event:1:myStats:
|
|
190
|
-
> => 52ee8d65-275e-4d22-94af-313129116388
|
|
191
|
-
> ```
|
|
192
|
-
|
|
193
221
|
3. Subscription data: `graphql-subscription:#{subscription_id}` hash contains everything required to evaluate subscription on trigger and create data for client.
|
|
194
222
|
|
|
195
223
|
```
|
|
@@ -202,13 +230,112 @@ As in AnyCable there is no place to store subscription data in-memory, it should
|
|
|
202
230
|
}
|
|
203
231
|
```
|
|
204
232
|
|
|
205
|
-
4. Channel subscriptions: `graphql-channel:#{
|
|
233
|
+
4. Channel subscriptions: `graphql-channel:#{subscription_id}` set containing identifiers for subscriptions created in ActionCable channel to delete them on client disconnect.
|
|
206
234
|
|
|
207
235
|
```
|
|
208
236
|
SMEMBERS graphql-channel:17420c6ed9e
|
|
209
237
|
=> 52ee8d65-275e-4d22-94af-313129116388
|
|
210
238
|
```
|
|
211
239
|
|
|
240
|
+
## Stats
|
|
241
|
+
|
|
242
|
+
You can grab Redis subscription statistics by calling
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
GraphQL::AnyCable.stats
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
It will return a total of the amount of the key with the following prefixes
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
graphql-subscription
|
|
252
|
+
graphql-fingerprints
|
|
253
|
+
graphql-subscriptions
|
|
254
|
+
graphql-channel
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The response will look like this
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"total": {
|
|
262
|
+
"subscription":22646,
|
|
263
|
+
"fingerprints":3200,
|
|
264
|
+
"subscriptions":20101,
|
|
265
|
+
"channel": 4900
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
You can also grab the number of subscribers grouped by subscriptions
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
GraphQL::AnyCable.stats(include_subscriptions: true)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
It will return the response that contains `subscriptions`
|
|
277
|
+
|
|
278
|
+
```json
|
|
279
|
+
{
|
|
280
|
+
"total": {
|
|
281
|
+
"subscription":22646,
|
|
282
|
+
"fingerprints":3200,
|
|
283
|
+
"subscriptions":20101,
|
|
284
|
+
"channel": 4900
|
|
285
|
+
},
|
|
286
|
+
"subscriptions": {
|
|
287
|
+
"productCreated": 11323,
|
|
288
|
+
"productUpdated": 11323
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Also, you can set another `scan_count`, if needed.
|
|
294
|
+
The default value is 1_000
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
GraphQL::AnyCable.stats(scan_count: 100)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
We can set statistics data to [Yabeda][] for tracking amount of subscriptions
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
# config/initializers/metrics.rb
|
|
304
|
+
Yabeda.configure do
|
|
305
|
+
group :graphql_anycable_statistics do
|
|
306
|
+
gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
# in your app
|
|
313
|
+
statistics = GraphQL::AnyCable.stats[:total]
|
|
314
|
+
|
|
315
|
+
statistics.each do |key , value|
|
|
316
|
+
Yabeda.graphql_anycable_statistics.subscriptions_count.set({name: key}, value)
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Or you can use `collect`
|
|
321
|
+
```ruby
|
|
322
|
+
# config/initializers/metrics.rb
|
|
323
|
+
Yabeda.configure do
|
|
324
|
+
group :graphql_anycable_statistics do
|
|
325
|
+
gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
collect do
|
|
329
|
+
statistics = GraphQL::AnyCable.stats[:total]
|
|
330
|
+
|
|
331
|
+
statistics.each do |redis_prefix, value|
|
|
332
|
+
graphql_anycable_statistics.subscriptions_count.set({name: redis_prefix}, value)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
|
|
212
339
|
## Testing applications which use `graphql-anycable`
|
|
213
340
|
|
|
214
341
|
You can pass custom redis-server URL to AnyCable using ENV variable.
|
|
@@ -268,3 +395,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
|
268
395
|
[AnyCable]: https://github.com/anycable/anycable "Polyglot replacement for Ruby WebSocket servers with Action Cable protocol"
|
|
269
396
|
[LiteCable]: https://github.com/palkan/litecable "Lightweight Action Cable implementation (Rails-free)"
|
|
270
397
|
[anyway_config]: https://github.com/palkan/anyway_config "Ruby libraries and applications configuration on steroids!"
|
|
398
|
+
[Yabeda]: https://github.com/yabeda-rb/yabeda "Extendable solution for easy setup of monitoring in your Ruby apps"
|
|
@@ -8,7 +8,6 @@ module GraphQL
|
|
|
8
8
|
def clean
|
|
9
9
|
clean_channels
|
|
10
10
|
clean_subscriptions
|
|
11
|
-
clean_events
|
|
12
11
|
clean_fingerprint_subscriptions
|
|
13
12
|
clean_topic_fingerprints
|
|
14
13
|
end
|
|
@@ -17,7 +16,7 @@ module GraphQL
|
|
|
17
16
|
return unless config.subscription_expiration_seconds
|
|
18
17
|
return unless config.use_redis_object_on_cleanup
|
|
19
18
|
|
|
20
|
-
redis.scan_each(match: "#{adapter::CHANNEL_PREFIX}*") do |key|
|
|
19
|
+
redis.scan_each(match: "#{redis_key(adapter::CHANNEL_PREFIX)}*") do |key|
|
|
21
20
|
idle = redis.object("IDLETIME", key)
|
|
22
21
|
next if idle&.<= config.subscription_expiration_seconds
|
|
23
22
|
|
|
@@ -29,7 +28,7 @@ module GraphQL
|
|
|
29
28
|
return unless config.subscription_expiration_seconds
|
|
30
29
|
return unless config.use_redis_object_on_cleanup
|
|
31
30
|
|
|
32
|
-
redis.scan_each(match: "#{adapter::SUBSCRIPTION_PREFIX}*") do |key|
|
|
31
|
+
redis.scan_each(match: "#{redis_key(adapter::SUBSCRIPTION_PREFIX)}*") do |key|
|
|
33
32
|
idle = redis.object("IDLETIME", key)
|
|
34
33
|
next if idle&.<= config.subscription_expiration_seconds
|
|
35
34
|
|
|
@@ -37,25 +36,10 @@ module GraphQL
|
|
|
37
36
|
end
|
|
38
37
|
end
|
|
39
38
|
|
|
40
|
-
def clean_events
|
|
41
|
-
return unless config.handle_legacy_subscriptions
|
|
42
|
-
|
|
43
|
-
redis.scan_each(match: "#{adapter::SUBSCRIPTION_EVENTS_PREFIX}*") do |key|
|
|
44
|
-
subscription_id = key.sub(/\A#{adapter::SUBSCRIPTION_EVENTS_PREFIX}/, "")
|
|
45
|
-
next if redis.exists?(adapter::SUBSCRIPTION_PREFIX + subscription_id)
|
|
46
|
-
|
|
47
|
-
redis.smembers(key).each do |event_topic|
|
|
48
|
-
redis.srem(adapter::EVENT_PREFIX + event_topic, subscription_id)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
redis.del(key)
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
39
|
def clean_fingerprint_subscriptions
|
|
56
|
-
redis.scan_each(match: "#{adapter::SUBSCRIPTIONS_PREFIX}*") do |key|
|
|
40
|
+
redis.scan_each(match: "#{redis_key(adapter::SUBSCRIPTIONS_PREFIX)}*") do |key|
|
|
57
41
|
redis.smembers(key).each do |subscription_id|
|
|
58
|
-
next if redis.exists?(adapter::SUBSCRIPTION_PREFIX + subscription_id)
|
|
42
|
+
next if redis.exists?(redis_key(adapter::SUBSCRIPTION_PREFIX) + subscription_id)
|
|
59
43
|
|
|
60
44
|
redis.srem(key, subscription_id)
|
|
61
45
|
end
|
|
@@ -63,10 +47,10 @@ module GraphQL
|
|
|
63
47
|
end
|
|
64
48
|
|
|
65
49
|
def clean_topic_fingerprints
|
|
66
|
-
redis.scan_each(match: "#{adapter::FINGERPRINTS_PREFIX}*") do |key|
|
|
50
|
+
redis.scan_each(match: "#{redis_key(adapter::FINGERPRINTS_PREFIX)}*") do |key|
|
|
67
51
|
redis.zremrangebyscore(key, '-inf', '0')
|
|
68
52
|
redis.zrange(key, 0, -1).each do |fingerprint|
|
|
69
|
-
next if redis.exists?(adapter::SUBSCRIPTIONS_PREFIX + fingerprint)
|
|
53
|
+
next if redis.exists?(redis_key(adapter::SUBSCRIPTIONS_PREFIX) + fingerprint)
|
|
70
54
|
|
|
71
55
|
redis.zrem(key, fingerprint)
|
|
72
56
|
end
|
|
@@ -86,6 +70,10 @@ module GraphQL
|
|
|
86
70
|
def config
|
|
87
71
|
GraphQL::AnyCable.config
|
|
88
72
|
end
|
|
73
|
+
|
|
74
|
+
def redis_key(prefix)
|
|
75
|
+
"#{config.redis_prefix}-#{prefix}"
|
|
76
|
+
end
|
|
89
77
|
end
|
|
90
78
|
end
|
|
91
79
|
end
|
|
@@ -10,8 +10,7 @@ module GraphQL
|
|
|
10
10
|
|
|
11
11
|
attr_config subscription_expiration_seconds: nil
|
|
12
12
|
attr_config use_redis_object_on_cleanup: true
|
|
13
|
-
attr_config
|
|
14
|
-
attr_config use_client_provided_uniq_id: true
|
|
13
|
+
attr_config redis_prefix: "graphql" # Here, we set clear redis_prefix without any hyphen. The hyphen is added at the end of this value on our side.
|
|
15
14
|
end
|
|
16
15
|
end
|
|
17
16
|
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module AnyCable
|
|
5
|
+
# Calculates amount of Graphql Redis keys
|
|
6
|
+
# (graphql-subscription, graphql-fingerprints, graphql-subscriptions, graphql-channel)
|
|
7
|
+
# Also, calculate the number of subscribers grouped by subscriptions
|
|
8
|
+
class Stats
|
|
9
|
+
SCAN_COUNT_RECORDS_AMOUNT = 1_000
|
|
10
|
+
|
|
11
|
+
attr_reader :scan_count, :include_subscriptions
|
|
12
|
+
|
|
13
|
+
def initialize(scan_count: SCAN_COUNT_RECORDS_AMOUNT, include_subscriptions: false)
|
|
14
|
+
@scan_count = scan_count
|
|
15
|
+
@include_subscriptions = include_subscriptions
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def collect
|
|
19
|
+
total_subscriptions_result = {total: {}}
|
|
20
|
+
|
|
21
|
+
list_prefixes_keys.each do |name, prefix|
|
|
22
|
+
total_subscriptions_result[:total][name] = count_by_scan(match: "#{prefix}*")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if include_subscriptions
|
|
26
|
+
total_subscriptions_result[:subscriptions] = group_subscription_stats
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
total_subscriptions_result
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Counting all keys, that match the pattern with iterating by count
|
|
35
|
+
def count_by_scan(match:)
|
|
36
|
+
sb_amount = 0
|
|
37
|
+
cursor = '0'
|
|
38
|
+
|
|
39
|
+
loop do
|
|
40
|
+
cursor, result = redis.scan(cursor, match: match, count: scan_count)
|
|
41
|
+
sb_amount += result.count
|
|
42
|
+
|
|
43
|
+
break if cursor == '0'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sb_amount
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Calculate subscribes, grouped by subscriptions
|
|
50
|
+
def group_subscription_stats
|
|
51
|
+
subscription_groups = {}
|
|
52
|
+
|
|
53
|
+
redis.scan_each(match: "#{list_prefixes_keys[:fingerprints]}*", count: scan_count) do |fingerprint_key|
|
|
54
|
+
subscription_name = fingerprint_key.gsub(/#{list_prefixes_keys[:fingerprints]}|:/, "")
|
|
55
|
+
subscription_groups[subscription_name] = 0
|
|
56
|
+
|
|
57
|
+
redis.zscan_each(fingerprint_key) do |data|
|
|
58
|
+
redis.sscan_each("#{list_prefixes_keys[:subscriptions]}#{data[0]}") do |subscription_key|
|
|
59
|
+
next unless redis.exists?("#{list_prefixes_keys[:subscription]}#{subscription_key}")
|
|
60
|
+
|
|
61
|
+
subscription_groups[subscription_name] += 1
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
subscription_groups
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def list_prefixes_keys
|
|
70
|
+
{
|
|
71
|
+
subscription: redis_key(adapter::SUBSCRIPTION_PREFIX),
|
|
72
|
+
fingerprints: redis_key(adapter::FINGERPRINTS_PREFIX),
|
|
73
|
+
subscriptions: redis_key(adapter::SUBSCRIPTIONS_PREFIX),
|
|
74
|
+
channel: redis_key(adapter::CHANNEL_PREFIX)
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def adapter
|
|
79
|
+
GraphQL::Subscriptions::AnyCableSubscriptions
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def redis
|
|
83
|
+
GraphQL::AnyCable.redis
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def config
|
|
87
|
+
GraphQL::AnyCable.config
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def redis_key(prefix)
|
|
91
|
+
"#{config.redis_prefix}-#{prefix}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -18,11 +18,6 @@ namespace :graphql do
|
|
|
18
18
|
GraphQL::AnyCable::Cleaner.clean_subscriptions
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
# Clean up legacy subscription_ids from events for expired subscriptions
|
|
22
|
-
task :events do
|
|
23
|
-
GraphQL::AnyCable::Cleaner.clean_events
|
|
24
|
-
end
|
|
25
|
-
|
|
26
21
|
# Clean up subscription_ids from event fingerprints for expired subscriptions
|
|
27
22
|
task :fingerprint_subscriptions do
|
|
28
23
|
GraphQL::AnyCable::Cleaner.clean_fingerprint_subscriptions
|
|
@@ -56,13 +56,10 @@ module GraphQL
|
|
|
56
56
|
|
|
57
57
|
def_delegators :"GraphQL::AnyCable", :redis, :config
|
|
58
58
|
|
|
59
|
-
SUBSCRIPTION_PREFIX = "
|
|
60
|
-
FINGERPRINTS_PREFIX = "
|
|
61
|
-
SUBSCRIPTIONS_PREFIX = "
|
|
62
|
-
CHANNEL_PREFIX = "
|
|
63
|
-
# For backward compatibility:
|
|
64
|
-
EVENT_PREFIX = "graphql-event:"
|
|
65
|
-
SUBSCRIPTION_EVENTS_PREFIX = "graphql-subscription-events:"
|
|
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
|
|
66
63
|
|
|
67
64
|
# @param serializer [<#dump(obj), #load(string)] Used for serializing messages before handing them to `.broadcast(msg)`
|
|
68
65
|
def initialize(serializer: Serialize, **rest)
|
|
@@ -73,15 +70,13 @@ module GraphQL
|
|
|
73
70
|
# An event was triggered.
|
|
74
71
|
# Re-evaluate all subscribed queries and push the data over ActionCable.
|
|
75
72
|
def execute_all(event, object)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
fingerprints = redis.zrange(FINGERPRINTS_PREFIX + event.topic, 0, -1)
|
|
73
|
+
fingerprints = redis.zrange(redis_key(FINGERPRINTS_PREFIX) + event.topic, 0, -1)
|
|
79
74
|
return if fingerprints.empty?
|
|
80
75
|
|
|
81
76
|
fingerprint_subscription_ids = Hash[fingerprints.zip(
|
|
82
77
|
redis.pipelined do |pipeline|
|
|
83
78
|
fingerprints.map do |fingerprint|
|
|
84
|
-
pipeline.smembers(SUBSCRIPTIONS_PREFIX + fingerprint)
|
|
79
|
+
pipeline.smembers(redis_key(SUBSCRIPTIONS_PREFIX) + fingerprint)
|
|
85
80
|
end
|
|
86
81
|
end
|
|
87
82
|
)]
|
|
@@ -99,25 +94,14 @@ module GraphQL
|
|
|
99
94
|
def execute_grouped(fingerprint, subscription_ids, event, object)
|
|
100
95
|
return if subscription_ids.empty?
|
|
101
96
|
|
|
102
|
-
subscription_id = subscription_ids.find { |sid| redis.exists?(SUBSCRIPTION_PREFIX + sid) }
|
|
97
|
+
subscription_id = subscription_ids.find { |sid| redis.exists?(redis_key(SUBSCRIPTION_PREFIX) + sid) }
|
|
103
98
|
return unless subscription_id # All subscriptions has expired but haven't cleaned up yet
|
|
104
99
|
|
|
105
100
|
result = execute_update(subscription_id, event, object)
|
|
106
101
|
return unless result
|
|
107
102
|
|
|
108
103
|
# Having calculated the result _once_, send the same payload to all subscribers
|
|
109
|
-
deliver(SUBSCRIPTIONS_PREFIX + fingerprint, result)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# For migration from pre-1.0 graphql-anycable gem
|
|
113
|
-
def execute_legacy(event, object)
|
|
114
|
-
redis.smembers(EVENT_PREFIX + event.topic).each do |subscription_id|
|
|
115
|
-
next unless redis.exists?(SUBSCRIPTION_PREFIX + subscription_id)
|
|
116
|
-
result = execute_update(subscription_id, event, object)
|
|
117
|
-
next unless result
|
|
118
|
-
|
|
119
|
-
deliver(SUBSCRIPTION_PREFIX + subscription_id, result)
|
|
120
|
-
end
|
|
104
|
+
deliver(redis_key(SUBSCRIPTIONS_PREFIX) + fingerprint, result)
|
|
121
105
|
end
|
|
122
106
|
|
|
123
107
|
# Disable this method as there is no fingerprint (it can be retrieved from subscription though)
|
|
@@ -142,14 +126,11 @@ module GraphQL
|
|
|
142
126
|
|
|
143
127
|
raise GraphQL::AnyCable::ChannelConfigurationError unless channel
|
|
144
128
|
|
|
145
|
-
channel_uniq_id = config.use_client_provided_uniq_id? ? channel.params["channelId"] : subscription_id
|
|
146
|
-
|
|
147
129
|
# Store subscription_id in the channel state to cleanup on disconnect
|
|
148
|
-
write_subscription_id(channel,
|
|
149
|
-
|
|
130
|
+
write_subscription_id(channel, subscription_id)
|
|
150
131
|
|
|
151
132
|
events.each do |event|
|
|
152
|
-
channel.stream_from(SUBSCRIPTIONS_PREFIX + event.fingerprint)
|
|
133
|
+
channel.stream_from(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint)
|
|
153
134
|
end
|
|
154
135
|
|
|
155
136
|
data = {
|
|
@@ -161,22 +142,22 @@ module GraphQL
|
|
|
161
142
|
}
|
|
162
143
|
|
|
163
144
|
redis.multi do |pipeline|
|
|
164
|
-
pipeline.sadd(CHANNEL_PREFIX +
|
|
165
|
-
pipeline.mapped_hmset(SUBSCRIPTION_PREFIX + subscription_id, data)
|
|
145
|
+
pipeline.sadd(redis_key(CHANNEL_PREFIX) + subscription_id, [subscription_id])
|
|
146
|
+
pipeline.mapped_hmset(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, data)
|
|
166
147
|
events.each do |event|
|
|
167
|
-
pipeline.zincrby(FINGERPRINTS_PREFIX + event.topic, 1, event.fingerprint)
|
|
168
|
-
pipeline.sadd(SUBSCRIPTIONS_PREFIX + event.fingerprint, [subscription_id])
|
|
148
|
+
pipeline.zincrby(redis_key(FINGERPRINTS_PREFIX) + event.topic, 1, event.fingerprint)
|
|
149
|
+
pipeline.sadd(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint, [subscription_id])
|
|
169
150
|
end
|
|
170
151
|
next unless config.subscription_expiration_seconds
|
|
171
|
-
pipeline.expire(CHANNEL_PREFIX +
|
|
172
|
-
pipeline.expire(SUBSCRIPTION_PREFIX + subscription_id, config.subscription_expiration_seconds)
|
|
152
|
+
pipeline.expire(redis_key(CHANNEL_PREFIX) + subscription_id, config.subscription_expiration_seconds)
|
|
153
|
+
pipeline.expire(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, config.subscription_expiration_seconds)
|
|
173
154
|
end
|
|
174
155
|
end
|
|
175
156
|
|
|
176
157
|
# Return the query from "storage" (in redis)
|
|
177
158
|
def read_subscription(subscription_id)
|
|
178
159
|
redis.mapped_hmget(
|
|
179
|
-
"#{SUBSCRIPTION_PREFIX}#{subscription_id}",
|
|
160
|
+
"#{redis_key(SUBSCRIPTION_PREFIX)}#{subscription_id}",
|
|
180
161
|
:query_string, :variables, :context, :operation_name
|
|
181
162
|
).tap do |subscription|
|
|
182
163
|
return if subscription.values.all?(&:nil?) # Redis returns hash with all nils for missing key
|
|
@@ -188,17 +169,17 @@ module GraphQL
|
|
|
188
169
|
end
|
|
189
170
|
|
|
190
171
|
def delete_subscription(subscription_id)
|
|
191
|
-
events = redis.hget(SUBSCRIPTION_PREFIX + subscription_id, :events)
|
|
172
|
+
events = redis.hget(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, :events)
|
|
192
173
|
events = events ? JSON.parse(events) : {}
|
|
193
174
|
fingerprint_subscriptions = {}
|
|
194
175
|
redis.pipelined do |pipeline|
|
|
195
176
|
events.each do |topic, fingerprint|
|
|
196
|
-
pipeline.srem(SUBSCRIPTIONS_PREFIX + fingerprint, subscription_id)
|
|
197
|
-
score = pipeline.zincrby(FINGERPRINTS_PREFIX + topic, -1, fingerprint)
|
|
198
|
-
fingerprint_subscriptions[FINGERPRINTS_PREFIX + topic] = score
|
|
177
|
+
pipeline.srem(redis_key(SUBSCRIPTIONS_PREFIX) + fingerprint, subscription_id)
|
|
178
|
+
score = pipeline.zincrby(redis_key(FINGERPRINTS_PREFIX) + topic, -1, fingerprint)
|
|
179
|
+
fingerprint_subscriptions[redis_key(FINGERPRINTS_PREFIX) + topic] = score
|
|
199
180
|
end
|
|
200
181
|
# Delete subscription itself
|
|
201
|
-
pipeline.del(SUBSCRIPTION_PREFIX + subscription_id)
|
|
182
|
+
pipeline.del(redis_key(SUBSCRIPTION_PREFIX) + subscription_id)
|
|
202
183
|
end
|
|
203
184
|
# Clean up fingerprints that doesn't have any subscriptions left
|
|
204
185
|
redis.pipelined do |pipeline|
|
|
@@ -206,33 +187,21 @@ module GraphQL
|
|
|
206
187
|
pipeline.zremrangebyscore(key, '-inf', '0') if score.value.zero?
|
|
207
188
|
end
|
|
208
189
|
end
|
|
209
|
-
delete_legacy_subscription(subscription_id)
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def delete_legacy_subscription(subscription_id)
|
|
213
|
-
return unless config.handle_legacy_subscriptions
|
|
214
|
-
|
|
215
|
-
events = redis.smembers(SUBSCRIPTION_EVENTS_PREFIX + subscription_id)
|
|
216
|
-
redis.pipelined do
|
|
217
|
-
events.each do |event_topic|
|
|
218
|
-
redis.srem(EVENT_PREFIX + event_topic, subscription_id)
|
|
219
|
-
end
|
|
220
|
-
redis.del(SUBSCRIPTION_EVENTS_PREFIX + subscription_id)
|
|
221
|
-
end
|
|
222
190
|
end
|
|
223
191
|
|
|
224
192
|
# The channel was closed, forget about it and its subscriptions
|
|
225
|
-
def delete_channel_subscriptions(
|
|
226
|
-
#
|
|
227
|
-
|
|
193
|
+
def delete_channel_subscriptions(channel)
|
|
194
|
+
raise(ArgumentError, "Please pass channel instance to #{__method__} in your #unsubscribed method") if channel.is_a?(String)
|
|
195
|
+
|
|
196
|
+
channel_id = read_subscription_id(channel)
|
|
228
197
|
|
|
229
198
|
# Missing in case disconnect happens before #execute
|
|
230
199
|
return unless channel_id
|
|
231
200
|
|
|
232
|
-
redis.smembers(CHANNEL_PREFIX + channel_id).each do |subscription_id|
|
|
201
|
+
redis.smembers(redis_key(CHANNEL_PREFIX) + channel_id).each do |subscription_id|
|
|
233
202
|
delete_subscription(subscription_id)
|
|
234
203
|
end
|
|
235
|
-
redis.del(CHANNEL_PREFIX + channel_id)
|
|
204
|
+
redis.del(redis_key(CHANNEL_PREFIX) + channel_id)
|
|
236
205
|
end
|
|
237
206
|
|
|
238
207
|
private
|
|
@@ -268,6 +237,10 @@ module GraphQL
|
|
|
268
237
|
channel.connection.socket.istate
|
|
269
238
|
end
|
|
270
239
|
end
|
|
240
|
+
|
|
241
|
+
def redis_key(prefix)
|
|
242
|
+
"#{config.redis_prefix}-#{prefix}"
|
|
243
|
+
end
|
|
271
244
|
end
|
|
272
245
|
end
|
|
273
246
|
end
|
data/lib/graphql-anycable.rb
CHANGED
|
@@ -6,20 +6,19 @@ require_relative "graphql/anycable/version"
|
|
|
6
6
|
require_relative "graphql/anycable/cleaner"
|
|
7
7
|
require_relative "graphql/anycable/config"
|
|
8
8
|
require_relative "graphql/anycable/railtie" if defined?(Rails)
|
|
9
|
+
require_relative "graphql/anycable/stats"
|
|
9
10
|
require_relative "graphql/subscriptions/anycable_subscriptions"
|
|
10
11
|
|
|
11
12
|
module GraphQL
|
|
12
13
|
module AnyCable
|
|
13
14
|
def self.use(schema, **options)
|
|
14
|
-
if config.use_client_provided_uniq_id?
|
|
15
|
-
warn "[Deprecated] Using client provided channel uniq IDs could lead to unexpected behaviour, " \
|
|
16
|
-
"please, set GraphQL::AnyCable.config.use_client_provided_uniq_id = false or GRAPHQL_ANYCABLE_USE_CLIENT_PROVIDED_UNIQ_ID=false, " \
|
|
17
|
-
"and update the `#unsubscribed` callback code according to the latest docs."
|
|
18
|
-
end
|
|
19
|
-
|
|
20
15
|
schema.use GraphQL::Subscriptions::AnyCableSubscriptions, **options
|
|
21
16
|
end
|
|
22
17
|
|
|
18
|
+
def self.stats(**options)
|
|
19
|
+
Stats.new(**options).collect
|
|
20
|
+
end
|
|
21
|
+
|
|
23
22
|
module_function
|
|
24
23
|
|
|
25
24
|
def redis
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: graphql-anycable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrey Novikov
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2024-05-
|
|
11
|
+
date: 2024-05-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: anycable-core
|
|
@@ -191,6 +191,7 @@ files:
|
|
|
191
191
|
- lib/graphql/anycable/config.rb
|
|
192
192
|
- lib/graphql/anycable/errors.rb
|
|
193
193
|
- lib/graphql/anycable/railtie.rb
|
|
194
|
+
- lib/graphql/anycable/stats.rb
|
|
194
195
|
- lib/graphql/anycable/tasks/clean_expired_subscriptions.rake
|
|
195
196
|
- lib/graphql/anycable/version.rb
|
|
196
197
|
- lib/graphql/subscriptions/anycable_subscriptions.rb
|