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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c3d8264a1ad4045379531d78a2b2d80507c1b37c2e98ca78370182258c20a79
4
- data.tar.gz: 237d5b34c060675f21b97f0a08a12c70bb94795f08796dfdedc2e01971471ceb
3
+ metadata.gz: bafa1a80487eeb78627ed0752751eb488dd33ca9709d38b205d61d310f97d7ae
4
+ data.tar.gz: 821cba31fd376a471c9db5757a2da3e51d936f618f372a624fab1515dea5d4e6
5
5
  SHA512:
6
- metadata.gz: 4352ab99658356eb39f60048dff9a9a8c7b4968d4b5f50122f6e56534ebbec3f5d1fbd2b320f73b83e5001e0eb1d73904690a03831a8d7a54ef5b38231365fe1
7
- data.tar.gz: b0fa928b0d187607d25bb6fedf4511bff6288fa6f82a88182bb892ad936922fe569da681ffff227ba88443b43f625e8d8ed007f300a891f39392a747948dd600
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@v2
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: 2.7
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
@@ -10,35 +10,33 @@ on:
10
10
 
11
11
  jobs:
12
12
  test:
13
- name: "GraphQL-Ruby ${{ matrix.graphql }} on Ruby ${{ matrix.ruby }} (use_client_id: ${{ matrix.client_id }}) Redis v${{ matrix.redis_version }}"
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
- client_id: 'false'
22
- anycable_rails: '~> 1.3'
23
- redis_version: latest
24
- - ruby: "3.0"
25
- graphql: '~> 1.13.0'
26
- client_id: 'false'
27
- anycable_rails: '~> 1.2.0'
28
- redis_version: 5.0.4
29
- - ruby: 2.7
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@v3
52
- - uses: actions/cache@v3
51
+ - uses: actions/checkout@v4
52
+ - uses: ruby/setup-ruby@v1
53
53
  with:
54
- path: vendor/bundle
55
- key: bundle-${{ matrix.ruby }}-${{ matrix.graphql }}-${{ matrix.anycable }}-${{ hashFiles('**/*.gemspec') }}-${{ hashFiles('**/Gemfile') }}
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", "~> 1.12")
11
- gem "anycable", ENV.fetch("ANYCABLE_VERSION", "~> 1.0")
12
- gem "anycable-rails", ENV.fetch("ANYCABLE_RAILS_VERSION", "~> 1.3")
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
- GRAPHQL_ANYCABLE_HANDLE_LEGACY_SUBSCRIPTIONS=false
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
- handle_legacy_subscriptions: false # For seamless upgrade from pre-1.0 versions
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:#{channel_id}` set containing identifiers for subscriptions created in ActionCable channel to delete them on client disconnect.
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"
@@ -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", "~> 1.0"
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 handle_legacy_subscriptions: false
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module AnyCable
5
- VERSION = "1.1.6"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  end
@@ -56,13 +56,10 @@ module GraphQL
56
56
 
57
57
  def_delegators :"GraphQL::AnyCable", :redis, :config
58
58
 
59
- SUBSCRIPTION_PREFIX = "graphql-subscription:" # HASH: Stores subscription data: query, context, …
60
- FINGERPRINTS_PREFIX = "graphql-fingerprints:" # ZSET: To get fingerprints by topic
61
- SUBSCRIPTIONS_PREFIX = "graphql-subscriptions:" # SET: To get subscriptions by fingerprint
62
- CHANNEL_PREFIX = "graphql-channel:" # SET: Auxiliary structure for whole channel's subscriptions cleanup
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
- execute_legacy(event, object) if config.handle_legacy_subscriptions
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, channel_uniq_id)
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 + channel_uniq_id, [subscription_id])
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 + channel_uniq_id, config.subscription_expiration_seconds)
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(channel_or_id)
226
- # For backward compatibility
227
- channel_id = channel_or_id.is_a?(String) ? channel_or_id : read_subscription_id(channel_or_id)
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
@@ -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.1.6
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: 2023-08-03 00:00:00.000000000 Z
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.0'
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.0'
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/build-release.yml"
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.1.6
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.