graphql-anycable 1.1.6 → 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/{build-release.yml → release.yml} +8 -4
- data/.github/workflows/test.yml +26 -36
- data/CHANGELOG.md +22 -2
- data/Gemfile +3 -3
- data/README.md +140 -12
- data/graphql-anycable.gemspec +1 -1
- 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 +8 -7
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
|
@@ -8,13 +8,17 @@ on:
|
|
8
8
|
jobs:
|
9
9
|
release:
|
10
10
|
runs-on: ubuntu-latest
|
11
|
+
permissions:
|
12
|
+
contents: write
|
13
|
+
id-token: write
|
14
|
+
packages: write
|
11
15
|
steps:
|
12
|
-
- uses: actions/checkout@
|
16
|
+
- uses: actions/checkout@v4
|
13
17
|
with:
|
14
18
|
fetch-depth: 0 # Fetch current tag as annotated. See https://github.com/actions/checkout/issues/290
|
15
19
|
- uses: ruby/setup-ruby@v1
|
16
20
|
with:
|
17
|
-
ruby-version:
|
21
|
+
ruby-version: "3.3"
|
18
22
|
- name: "Extract data from tag: version, message, body"
|
19
23
|
id: tag
|
20
24
|
run: |
|
@@ -75,8 +79,8 @@ jobs:
|
|
75
79
|
GEM_HOST_API_KEY: Bearer ${{ secrets.GITHUB_TOKEN }}
|
76
80
|
run: |
|
77
81
|
gem push graphql-anycable-${{ steps.tag.outputs.version }}.gem --host https://rubygems.pkg.github.com/${{ github.repository_owner }}
|
82
|
+
- name: Configure RubyGems Credentials
|
83
|
+
uses: rubygems/configure-rubygems-credentials@main
|
78
84
|
- name: Publish to RubyGems
|
79
|
-
env:
|
80
|
-
GEM_HOST_API_KEY: "${{ secrets.RUBYGEMS_API_KEY }}"
|
81
85
|
run: |
|
82
86
|
gem push graphql-anycable-${{ steps.tag.outputs.version }}.gem
|
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,6 +7,28 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
|
7
7
|
|
8
8
|
## Unreleased
|
9
9
|
|
10
|
+
## 1.2.0 - 2024-05-07
|
11
|
+
|
12
|
+
### Changed
|
13
|
+
|
14
|
+
- Depend on `anycable-core` gem instead of `anycable`.
|
15
|
+
|
16
|
+
This allows to avoid installing `grpc` gem when using alternate AnyCable broadcasting adapters (like HTTP).
|
17
|
+
|
18
|
+
See https://github.com/anycable/graphql-anycable/issues/43 for details.
|
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
|
+
|
10
32
|
## 1.1.6 - 2023-08-03
|
11
33
|
|
12
34
|
### Fixed
|
@@ -175,8 +197,6 @@ Technical release to test publishing via GitHub Actions.
|
|
175
197
|
|
176
198
|
Initial version: store subscriptions on redis, re-execute queries in sync. [@Envek]
|
177
199
|
|
178
|
-
[@ilyasgaraev]: https://github.com/ilyasgaraev "Ilyas Garaev"
|
179
|
-
[@smasry]: https://github.com/smasry "Samer Masry"
|
180
200
|
[@gsamokovarov]: https://github.com/gsamokovarov "Genadi Samokovarov"
|
181
201
|
[@bibendi]: https://github.com/bibendi "Misha Merkushin"
|
182
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"
|
data/graphql-anycable.gemspec
CHANGED
@@ -26,7 +26,7 @@ Gem::Specification.new do |spec|
|
|
26
26
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
27
27
|
spec.require_paths = ["lib"]
|
28
28
|
|
29
|
-
spec.add_dependency "anycable",
|
29
|
+
spec.add_dependency "anycable-core", "~> 1.1"
|
30
30
|
spec.add_dependency "anyway_config", ">= 1.3", "< 3"
|
31
31
|
spec.add_dependency "graphql", ">= 1.11", "< 3"
|
32
32
|
spec.add_dependency "redis", ">= 4.2.0"
|
@@ -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,29 +1,29 @@
|
|
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:
|
11
|
+
date: 2024-05-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name: anycable
|
14
|
+
name: anycable-core
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '1.
|
19
|
+
version: '1.1'
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '1.
|
26
|
+
version: '1.1'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: anyway_config
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -171,7 +171,7 @@ extra_rdoc_files: []
|
|
171
171
|
files:
|
172
172
|
- ".github/ISSUE_TEMPLATE/bug_report.md"
|
173
173
|
- ".github/ISSUE_TEMPLATE/feature_request.md"
|
174
|
-
- ".github/workflows/
|
174
|
+
- ".github/workflows/release.yml"
|
175
175
|
- ".github/workflows/test.yml"
|
176
176
|
- ".gitignore"
|
177
177
|
- ".rspec"
|
@@ -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
|
@@ -213,7 +214,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
213
214
|
- !ruby/object:Gem::Version
|
214
215
|
version: '0'
|
215
216
|
requirements: []
|
216
|
-
rubygems_version: 3.
|
217
|
+
rubygems_version: 3.5.9
|
217
218
|
signing_key:
|
218
219
|
specification_version: 4
|
219
220
|
summary: A drop-in replacement for GraphQL ActionCable subscriptions for AnyCable.
|