graphql-anycable 0.5.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c831aba1f67077cccb4ef5f1b7858715cf056e554551dab59acdf265a8c671e
4
- data.tar.gz: 5049b208e8a02a2e110e698f5336a1314577d2e1f8a37742ce07807daac6e41a
3
+ metadata.gz: 6e8f296cdfe805ab7eb4ea8247b3880b785ba1314c3c2ef25d3ac316765714b5
4
+ data.tar.gz: d010e06e309c4724a111ebb5cff503678bf53b0ba8039f438ecd86892073ef7d
5
5
  SHA512:
6
- metadata.gz: d9451177ced38d8f963fe8b7f4c5b8ae1933a1d9730403202ee0864f23b3649752e538f8e480aedcae042aac2958b60145dc1305b79e67a3e480a96279df2bb1
7
- data.tar.gz: 1367ac8df59b0470725197da7be2701175cb8571146f81eaa896dd00b3052eee1f91e2abdabdf67bb5a18754385e11169d942864a65e1fb59cdb7f8afdd5938e
6
+ metadata.gz: c84765a3b9461f431da8e3ad4a31fdc50e28836d9b331e04af7374676002bb9b02c78d6e18512b1e776a8d13fa431232c5c019b059497ccdee6953adecbde53c
7
+ data.tar.gz: 3a741fbaaf27eea28bd0a1885cee1e7ac1b24e8bb8a270537751465fa78e1f73f1ef1a3ca1538670e7d90503292dc5e05d8511a8f81861144ee999c6a9f30e25
@@ -1,4 +1,4 @@
1
- name: Build and release gem to RubyGems
1
+ name: Build and release gem
2
2
 
3
3
  on:
4
4
  push:
@@ -21,16 +21,26 @@ jobs:
21
21
  git fetch --tags --force # Really fetch annotated tag. See https://github.com/actions/checkout/issues/290#issuecomment-680260080
22
22
  echo ::set-output name=version::${GITHUB_REF#refs/tags/v}
23
23
  echo ::set-output name=subject::$(git for-each-ref $GITHUB_REF --format='%(contents:subject)')
24
- # Multiline body for release. See https://github.community/t/set-output-truncates-multiline-strings/16852/5
25
24
  BODY="$(git for-each-ref $GITHUB_REF --format='%(contents:body)')"
25
+ # Extract changelog entries between this and previous version headers
26
+ escaped_version=$(echo ${GITHUB_REF#refs/tags/v} | sed -e 's/[]\/$*.^[]/\\&/g')
27
+ changelog=$(awk "BEGIN{inrelease=0} /## ${escaped_version}/{inrelease=1;next} /## [0-9]+\.[0-9]+\.[0-9]+/{inrelease=0;exit} {if (inrelease) print}" CHANGELOG.md)
28
+ # Multiline body for release. See https://github.community/t/set-output-truncates-multiline-strings/16852/5
29
+ BODY="${BODY}"$'\n'"${changelog}"
26
30
  BODY="${BODY//'%'/'%25'}"
27
31
  BODY="${BODY//$'\n'/'%0A'}"
28
32
  BODY="${BODY//$'\r'/'%0D'}"
29
33
  echo "::set-output name=body::$BODY"
34
+ # Add pre-release option if tag name has any suffix after vMAJOR.MINOR.PATCH
35
+ if [[ ${GITHUB_REF#refs/tags/} =~ ^v[0-9]+\.[0-9]+\.[0-9]+.+ ]]; then
36
+ echo ::set-output name=prerelease::true
37
+ fi
30
38
  - name: Build gem
31
39
  run: gem build
40
+ - name: Calculate checksums
41
+ run: sha256sum graphql-anycable-${{ steps.tag.outputs.version }}.gem > SHA256SUM
32
42
  - name: Check version
33
- run: ls graphql-anycable-${{ steps.tag.outputs.version }}.gem
43
+ run: ls -l graphql-anycable-${{ steps.tag.outputs.version }}.gem
34
44
  - name: Create Release
35
45
  id: create_release
36
46
  uses: actions/create-release@v1
@@ -41,9 +51,8 @@ jobs:
41
51
  release_name: ${{ steps.tag.outputs.subject }}
42
52
  body: ${{ steps.tag.outputs.body }}
43
53
  draft: false
44
- prerelease: false
45
- - name: Upload Release Asset
46
- id: upload-release-asset
54
+ prerelease: ${{ steps.tag.outputs.prerelease }}
55
+ - name: Upload built gem as release asset
47
56
  uses: actions/upload-release-asset@v1
48
57
  env:
49
58
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -52,13 +61,22 @@ jobs:
52
61
  asset_path: graphql-anycable-${{ steps.tag.outputs.version }}.gem
53
62
  asset_name: graphql-anycable-${{ steps.tag.outputs.version }}.gem
54
63
  asset_content_type: application/x-tar
55
- - name: Install publish prerequisites
64
+ - name: Upload checksums as release asset
65
+ uses: actions/upload-release-asset@v1
66
+ env:
67
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
68
+ with:
69
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
70
+ asset_path: SHA256SUM
71
+ asset_name: SHA256SUM
72
+ asset_content_type: text/plain
73
+ - name: Publish to GitHub packages
74
+ env:
75
+ GEM_HOST_API_KEY: Bearer ${{ secrets.GITHUB_TOKEN }}
56
76
  run: |
57
- sudo apt-get update
58
- sudo apt-get install oathtool
77
+ gem push graphql-anycable-${{ steps.tag.outputs.version }}.gem --host https://rubygems.pkg.github.com/${{ github.repository_owner }}
59
78
  - name: Publish to RubyGems
60
79
  env:
61
80
  GEM_HOST_API_KEY: "${{ secrets.RUBYGEMS_API_KEY }}"
62
- RUBYGEMS_OTP_KEY: "${{ secrets.RUBYGEMS_OTP_KEY }}"
63
81
  run: |
64
- gem push graphql-anycable-${{ steps.tag.outputs.version }}.gem --otp=$(oathtool --totp --base32 $RUBYGEMS_OTP_KEY)
82
+ gem push graphql-anycable-${{ steps.tag.outputs.version }}.gem
@@ -1,4 +1,4 @@
1
- name: Run tests
1
+ name: Tests
2
2
 
3
3
  on:
4
4
  pull_request:
@@ -10,36 +10,43 @@ on:
10
10
 
11
11
  jobs:
12
12
  test:
13
- name: "Run tests"
14
- if: "! contains(toJSON(github.event.commits.latest.message), '[ci skip]')"
13
+ name: "GraphQL-Ruby ${{ matrix.graphql }} (interpreter: ${{ matrix.interpreter }}) with AnyCable ${{ matrix.anycable }} on Ruby ${{ matrix.ruby }}"
15
14
  runs-on: ubuntu-latest
16
15
  strategy:
17
16
  fail-fast: false
18
17
  matrix:
19
18
  include:
19
+ - ruby: "3.0"
20
+ graphql: '~> 1.12.0'
21
+ anycable: '~> 1.0.0'
22
+ interpreter: yes
23
+ - ruby: "3.0"
24
+ graphql: '~> 1.12.0'
25
+ anycable: '~> 1.0.0'
26
+ interpreter: no
20
27
  - ruby: 2.7
21
- graphql: '~> 1.11.0'
28
+ graphql: '~> 1.12.0'
22
29
  anycable: '~> 1.0.0'
23
30
  interpreter: yes
24
31
  - ruby: 2.7
25
- graphql: '~> 1.11.0'
32
+ graphql: '~> 1.12.0'
26
33
  anycable: '~> 1.0.0'
27
34
  interpreter: no
28
35
  - ruby: 2.6
29
- graphql: '~> 1.10.0'
36
+ graphql: '~> 1.11.0'
30
37
  anycable: '~> 1.0.0'
31
38
  interpreter: yes
32
39
  - ruby: 2.6
33
- graphql: '~> 1.10.0'
40
+ graphql: '~> 1.11.0'
34
41
  anycable: '~> 1.0.0'
35
42
  interpreter: no
36
43
  - ruby: 2.5
37
- graphql: '~> 1.9.0'
38
- anycable: '~> 0.6.0'
44
+ graphql: '~> 1.11.0'
45
+ anycable: '~> 1.0.0'
39
46
  interpreter: yes
40
47
  - ruby: 2.5
41
- graphql: '~> 1.9.0'
42
- anycable: '~> 0.6.0'
48
+ graphql: '~> 1.11.0'
49
+ anycable: '~> 1.0.0'
43
50
  interpreter: no
44
51
  container:
45
52
  image: ruby:${{ matrix.ruby }}
data/CHANGELOG.md CHANGED
@@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 1.0.0 - 2021-04-01
11
+
12
+ ### Added
13
+
14
+ - Support for [Subscriptions Broadcast](https://graphql-ruby.org/subscriptions/broadcast.html) feature in GraphQL-Ruby 1.11+. [@Envek] ([#15](https://github.com/anycable/graphql-anycable/pull/15))
15
+
16
+ ### Changed
17
+
18
+ - Subscription data storage format changed to support broadcasting feature (see [#15](https://github.com/anycable/graphql-anycable/pull/15))
19
+
20
+ ### Removed
21
+
22
+ - Drop support for GraphQL-Ruby before 1.11
23
+
24
+ - Drop support for AnyCable before 1.0
25
+
26
+ - Drop `:action_cable_stream` option from context: it is not used in reality.
27
+
28
+ See [rmosolgo/graphql-ruby#3076](https://github.com/rmosolgo/graphql-ruby/pull/3076) for details
29
+
30
+ ### Upgrading notes
31
+
32
+ 1. Change method of plugging in of this gem from `use GraphQL::Subscriptions::AnyCableSubscriptions` to `use GraphQL::AnyCable`:
33
+
34
+ ```ruby
35
+ use GraphQL::AnyCable
36
+ ```
37
+
38
+ If you need broadcasting, add `broadcast: true` option and ensure that [Interpreter mode](https://graphql-ruby.org/queries/interpreter.html) is enabled.
39
+
40
+ ```ruby
41
+ use GraphQL::Execution::Interpreter
42
+ use GraphQL::Analysis::AST
43
+ use GraphQL::AnyCable, broadcast: true, default_broadcastable: true
44
+ ```
45
+
46
+ 2. Enable `handle_legacy_subscriptions` setting for seamless upgrade from previous versions:
47
+
48
+ ```sh
49
+ GRAPHQL_ANYCABLE_HANDLE_LEGACY_SUBSCRIPTIONS=true
50
+ ```
51
+
52
+ Disable or remove this setting when you sure that all clients has re-subscribed (e.g. after `subscription_expiration_seconds` has passed after upgrade) as it imposes small performance penalty.
53
+
54
+ ## 0.5.0 - 2020-08-26
55
+
56
+ ### Changed
57
+
58
+ - Allow to plug in this gem by calling `use GraphQL::AnyCable` instead of `use GraphQL::Subscriptions::AnyCableSubscriptions`. [@Envek]
59
+ - Rename `GraphQL::Anycable` constant to `GraphQL::AnyCable` for consistency with AnyCable itself. [@Envek]
60
+
61
+ ## 0.4.2 - 2020-08-25
62
+
63
+ Technical release to test publishing via GitHub Actions.
64
+
8
65
  ## 0.4.1 - 2020-08-21
9
66
 
10
67
  ### Fixed
data/Gemfile CHANGED
@@ -7,7 +7,7 @@ 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.11")
10
+ gem "graphql", ENV.fetch("GRAPHQL_RUBY_VERSION", "~> 1.12")
11
11
  gem "anycable", ENV.fetch("ANYCABLE_VERSION", "~> 1.0")
12
12
 
13
13
  group :development, :test do
data/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  A (mostly) drop-in replacement for default ActionCable subscriptions adapter shipped with [graphql gem] but works with [AnyCable]!
4
4
 
5
5
  [![Gem Version](https://badge.fury.io/rb/graphql-anycable.svg)](https://badge.fury.io/rb/graphql-anycable)
6
+ [![Tests](https://github.com/anycable/graphql-anycable/actions/workflows/test.yml/badge.svg)](https://github.com/anycable/graphql-anycable/actions/workflows/test.yml)
6
7
 
7
8
  <a href="https://evilmartians.com/?utm_source=graphql-anycable&utm_campaign=project_page">
8
9
  <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54">
@@ -33,7 +34,7 @@ AnyCable must be configured with redis broadcast adapter (this is default).
33
34
  Add this line to your application's Gemfile:
34
35
 
35
36
  ```ruby
36
- gem 'graphql-anycable'
37
+ gem 'graphql-anycable', '~> 1.0'
37
38
  ```
38
39
 
39
40
  And then execute:
@@ -50,7 +51,7 @@ Or install it yourself as:
50
51
 
51
52
  ```ruby
52
53
  class MySchema < GraphQL::Schema
53
- use GraphQL::AnyCable
54
+ use GraphQL::AnyCable, broadcast: true
54
55
 
55
56
  subscription SubscriptionType
56
57
  end
@@ -99,6 +100,28 @@ Or install it yourself as:
99
100
  MySchema.subscriptions.trigger(:product_updated, {}, Product.first!, scope: account.id)
100
101
  ```
101
102
 
103
+ ## Broadcasting
104
+
105
+ By default, graphql-anycable evaluates queries and transmits results for every subscription client individually. Of course, it is a waste of resources if you have hundreds or thousands clients subscribed to the same data (and has huge negative impact on performance).
106
+
107
+ Thankfully, GraphQL-Ruby has added [Subscriptions Broadcast](https://graphql-ruby.org/subscriptions/broadcast.html) feature that allows to group exact same subscriptions, execute them and transmit results only once.
108
+
109
+ To enable this feature, turn on [Interpreter](https://graphql-ruby.org/queries/interpreter.html) and pass `broadcast` option set to `true` to graphql-anycable.
110
+
111
+ By default all fields are marked as _not safe for broadcasting_. If a subscription has at least one non-broadcastable field in its query, GraphQL-Ruby will execute every subscription for every client independently. If you sure that all your fields are safe to be broadcasted, you can pass `default_broadcastable` option set to `true` (but be aware that it can have security impllications!)
112
+
113
+ ```ruby
114
+ class MySchema < GraphQL::Schema
115
+ use GraphQL::Execution::Interpreter # Required for graphql-ruby before 1.12.4
116
+ use GraphQL::Analysis::AST
117
+ use GraphQL::AnyCable, broadcast: true, default_broadcastable: true
118
+
119
+ subscription SubscriptionType
120
+ end
121
+ ```
122
+
123
+ See GraphQL-Ruby [broadcasting docs](https://graphql-ruby.org/subscriptions/broadcast.html) for more details.
124
+
102
125
  ## Operations
103
126
 
104
127
  To avoid filling Redis storage with stale subscription data:
@@ -118,6 +141,7 @@ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several pos
118
141
  ```.env
119
142
  GRAPHQL_ANYCABLE_SUBSCRIPTION_EXPIRATION_SECONDS=604800
120
143
  GRAPHQL_ANYCABLE_USE_REDIS_OBJECT_ON_CLEANUP=true
144
+ GRAPHQL_ANYCABLE_HANDLE_LEGACY_SUBSCRIPTIONS=false
121
145
  ```
122
146
 
123
147
  2. YAML configuration files:
@@ -127,6 +151,7 @@ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several pos
127
151
  production:
128
152
  subscription_expiration_seconds: 300 # 5 minutes
129
153
  use_redis_object_on_cleanup: false # For restricted redis installations
154
+ handle_legacy_subscriptions: false # For seamless upgrade from pre-1.0 versions
130
155
  ```
131
156
 
132
157
  3. Configuration from your application code:
@@ -143,39 +168,46 @@ And any other way provided by [anyway_config]. Check its documentation!
143
168
 
144
169
  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.
145
170
 
146
- 1. Event subscriptions: `graphql-event:#{event.topic}` set containing identifiers for all subscriptions for given operation with certain context and arguments (serialized in _topic_). Used to find all subscriptions on `GraphQLSchema.subscriptions.trigger`.
171
+ 1. Grouped event subscriptions: `graphql-fingerprints:#{event.topic}` sorted set. Used to find all subscriptions on `GraphQLSchema.subscriptions.trigger`.
147
172
 
148
173
  ```
149
- SMEMBERS graphql-event:1:myStats:
174
+ ZREVRANGE graphql-fingerprints:1:myStats: 0 -1
175
+ => 1:myStats:/MyStats/fBDZmJU1UGTorQWvOyUeaHVwUxJ3T9SEqnetj6SKGXc=/0/RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o=
176
+ ```
177
+
178
+ 2. Event subscriptions: `graphql-subscriptions:#{event.fingerptint}` set containing identifiers for all subscriptions for given operation with certain context and arguments (serialized in _topic_). Fingerprints are already scoped by topic.
179
+
180
+ ```
181
+ SMEMBERS graphql-subscriptions:1:myStats:/MyStats/fBDZmJU1UGTorQWvOyUeaHVwUxJ3T9SEqnetj6SKGXc=/0/RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o=
150
182
  => 52ee8d65-275e-4d22-94af-313129116388
151
183
  ```
152
184
 
153
- 2. Subscription data: `graphql-subscription:#{subscription_id}` hash contains everything required to evaluate subscription on trigger and create data for client.
185
+ > For backward compatibility with pre-1.0 versions of this gem older `graphql-event:#{event.topic}` set containing subscription identifiers is also supported.
186
+ >
187
+ > ```
188
+ > SMEMBERS graphql-event:1:myStats:
189
+ > => 52ee8d65-275e-4d22-94af-313129116388
190
+ > ```
191
+
192
+ 3. Subscription data: `graphql-subscription:#{subscription_id}` hash contains everything required to evaluate subscription on trigger and create data for client.
154
193
 
155
194
  ```
156
195
  HGETALL graphql-subscription:52ee8d65-275e-4d22-94af-313129116388
157
196
  => {
158
- context: '{"user_id":1,"user":{"__gid__":"Z2lkOi8vZWJheS1tYWcyL1VzZXIvMQ"},"subscription_id":"52ee8d65-275e-4d22-94af-313129116388\","action_cable_stream":"graphql-subscription:52ee8d65-275e-4d22-94af-313129116388",}',
197
+ context: '{"user_id":1,"user":{"__gid__":"Z2lkOi8vZWJheS1tYWcyL1VzZXIvMQ"}}',
159
198
  variables: '{}',
160
199
  operation_name: 'MyStats'
161
200
  query_string: 'subscription MyStats { myStatsUpdated { completed total processed __typename } }',
162
201
  }
163
202
  ```
164
203
 
165
- 3. Channel subscriptions: `graphql-channel:#{channel_id}` set containing identifiers for subscriptions created in ActionCable channel to delete them on client disconnect.
204
+ 4. Channel subscriptions: `graphql-channel:#{channel_id}` set containing identifiers for subscriptions created in ActionCable channel to delete them on client disconnect.
166
205
 
167
206
  ```
168
207
  SMEMBERS graphql-channel:17420c6ed9e
169
208
  => 52ee8d65-275e-4d22-94af-313129116388
170
209
  ```
171
210
 
172
- 4. Subscription events: `graphql-subscription-events:#{subscription_id}` set containing event topics to delete subscription identifier from event subscriptions set on unsubscribe (or client disconnect).
173
-
174
- ```
175
- SMEMBERS graphql-subscription-events:52ee8d65-275e-4d22-94af-313129116388
176
- => 1:myStats:
177
- ```
178
-
179
211
  ## Development
180
212
 
181
213
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -26,9 +26,9 @@ 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", ">= 0.6.0", "< 2"
29
+ spec.add_dependency "anycable", "~> 1.0"
30
30
  spec.add_dependency "anyway_config", ">= 1.3", "< 3"
31
- spec.add_dependency "graphql", "~> 1.8"
31
+ spec.add_dependency "graphql", "~> 1.11"
32
32
  spec.add_dependency "redis", ">= 4.2.0"
33
33
 
34
34
  spec.add_development_dependency "bundler", "~> 2.0"
@@ -10,8 +10,8 @@ require_relative "graphql/subscriptions/anycable_subscriptions"
10
10
 
11
11
  module GraphQL
12
12
  module AnyCable
13
- def self.use(schema)
14
- schema.use GraphQL::Subscriptions::AnyCableSubscriptions
13
+ def self.use(schema, **options)
14
+ schema.use GraphQL::Subscriptions::AnyCableSubscriptions, **options
15
15
  end
16
16
 
17
17
  module_function
@@ -36,6 +36,3 @@ module GraphQL
36
36
  end
37
37
  end
38
38
  end
39
-
40
- # For backward compatibility
41
- GraphQL::Anycable = GraphQL::AnyCable
@@ -9,6 +9,8 @@ module GraphQL
9
9
  clean_channels
10
10
  clean_subscriptions
11
11
  clean_events
12
+ clean_fingerprint_subscriptions
13
+ clean_topic_fingerprints
12
14
  end
13
15
 
14
16
  def clean_channels
@@ -36,6 +38,8 @@ module GraphQL
36
38
  end
37
39
 
38
40
  def clean_events
41
+ return unless config.handle_legacy_subscriptions
42
+
39
43
  redis.scan_each(match: "#{adapter::SUBSCRIPTION_EVENTS_PREFIX}*") do |key|
40
44
  subscription_id = key.sub(/\A#{adapter::SUBSCRIPTION_EVENTS_PREFIX}/, "")
41
45
  next if redis.exists?(adapter::SUBSCRIPTION_PREFIX + subscription_id)
@@ -48,6 +52,27 @@ module GraphQL
48
52
  end
49
53
  end
50
54
 
55
+ def clean_fingerprint_subscriptions
56
+ redis.scan_each(match: "#{adapter::SUBSCRIPTIONS_PREFIX}*") do |key|
57
+ redis.smembers(key).each do |subscription_id|
58
+ next if redis.exists?(adapter::SUBSCRIPTION_PREFIX + subscription_id)
59
+
60
+ redis.srem(key, subscription_id)
61
+ end
62
+ end
63
+ end
64
+
65
+ def clean_topic_fingerprints
66
+ redis.scan_each(match: "#{adapter::FINGERPRINTS_PREFIX}*") do |key|
67
+ redis.zremrangebyscore(key, '-inf', '0')
68
+ redis.zrange(key, 0, -1).each do |fingerprint|
69
+ next if redis.exists?(adapter::SUBSCRIPTIONS_PREFIX + fingerprint)
70
+
71
+ redis.zrem(key, fingerprint)
72
+ end
73
+ end
74
+ end
75
+
51
76
  private
52
77
 
53
78
  def adapter
@@ -10,6 +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
13
14
  end
14
15
  end
15
16
  end
@@ -5,10 +5,7 @@ require "graphql-anycable"
5
5
  namespace :graphql do
6
6
  namespace :anycable do
7
7
  desc "Clean up stale graphql channels, subscriptions, and events from redis"
8
- task clean: %i[clean:channels clean:subscriptions clean:events]
9
-
10
- # Old name that was used earlier
11
- task clean_expired_subscriptions: :clean
8
+ task clean: %i[clean:channels clean:subscriptions clean:events clean:fingerprint_subscriptions clean:topic_fingerprints]
12
9
 
13
10
  namespace :clean do
14
11
  # Clean up old channels
@@ -21,10 +18,20 @@ namespace :graphql do
21
18
  GraphQL::AnyCable::Cleaner.clean_subscriptions
22
19
  end
23
20
 
24
- # Clean up subscription_ids from events for expired subscriptions
21
+ # Clean up legacy subscription_ids from events for expired subscriptions
25
22
  task :events do
26
23
  GraphQL::AnyCable::Cleaner.clean_events
27
24
  end
25
+
26
+ # Clean up subscription_ids from event fingerprints for expired subscriptions
27
+ task :fingerprint_subscriptions do
28
+ GraphQL::AnyCable::Cleaner.clean_fingerprint_subscriptions
29
+ end
30
+
31
+ # Clean up fingerprints from event topics. for expired subscriptions
32
+ task :topic_fingerprints do
33
+ GraphQL::AnyCable::Cleaner.clean_topic_fingerprints
34
+ end
28
35
  end
29
36
  end
30
37
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module AnyCable
5
- VERSION = "0.5.0"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end
@@ -56,10 +56,13 @@ module GraphQL
56
56
 
57
57
  def_delegators :"GraphQL::AnyCable", :redis, :config
58
58
 
59
- SUBSCRIPTION_PREFIX = "graphql-subscription:"
60
- SUBSCRIPTION_EVENTS_PREFIX = "graphql-subscription-events:"
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:
61
64
  EVENT_PREFIX = "graphql-event:"
62
- CHANNEL_PREFIX = "graphql-channel:"
65
+ SUBSCRIPTION_EVENTS_PREFIX = "graphql-subscription-events:"
63
66
 
64
67
  # @param serializer [<#dump(obj), #load(string)] Used for serializing messages before handing them to `.broadcast(msg)`
65
68
  def initialize(serializer: Serialize, **rest)
@@ -70,40 +73,91 @@ module GraphQL
70
73
  # An event was triggered.
71
74
  # Re-evaluate all subscribed queries and push the data over ActionCable.
72
75
  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)
79
+ return if fingerprints.empty?
80
+
81
+ fingerprint_subscription_ids = Hash[fingerprints.zip(
82
+ redis.pipelined do
83
+ fingerprints.map do |fingerprint|
84
+ redis.smembers(SUBSCRIPTIONS_PREFIX + fingerprint)
85
+ end
86
+ end
87
+ )]
88
+
89
+ fingerprint_subscription_ids.each do |fingerprint, subscription_ids|
90
+ execute_grouped(fingerprint, subscription_ids, event, object)
91
+ end
92
+
93
+ # Call to +trigger+ returns this. Convenient for playing in console
94
+ Hash[fingerprint_subscription_ids.map { |k,v| [k, v.size] }]
95
+ end
96
+
97
+ # The fingerprint has told us that this response should be shared by all subscribers,
98
+ # so just run it once, then deliver the result to every subscriber
99
+ def execute_grouped(fingerprint, subscription_ids, event, object)
100
+ return if subscription_ids.empty?
101
+
102
+ subscription_id = subscription_ids.find { |sid| redis.exists?(SUBSCRIPTION_PREFIX + sid) }
103
+ return unless subscription_id # All subscriptions has expired but haven't cleaned up yet
104
+
105
+ result = execute_update(subscription_id, event, object)
106
+ return unless result
107
+
108
+ # 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)
73
114
  redis.smembers(EVENT_PREFIX + event.topic).each do |subscription_id|
74
115
  next unless redis.exists?(SUBSCRIPTION_PREFIX + subscription_id)
75
- execute(subscription_id, event, object)
116
+ result = execute_update(subscription_id, event, object)
117
+ next unless result
118
+
119
+ deliver(SUBSCRIPTION_PREFIX + subscription_id, result)
76
120
  end
77
121
  end
78
122
 
123
+ # Disable this method as there is no fingerprint (it can be retrieved from subscription though)
124
+ def execute(subscription_id, event, object)
125
+ raise NotImplementedError, "Use execute_all method instead of execute to get actual event fingerprint"
126
+ end
127
+
79
128
  # This subscription was re-evaluated.
80
129
  # Send it to the specific stream where this client was waiting.
81
- def deliver(subscription_id, result)
82
- payload = {result: result.to_h, more: true}
83
- anycable.broadcast(SUBSCRIPTION_PREFIX + subscription_id, payload.to_json)
130
+ # @param strean_key [String]
131
+ # @param result [#to_h] result to send to clients
132
+ def deliver(stream_key, result)
133
+ payload = { result: result.to_h, more: true }.to_json
134
+ anycable.broadcast(stream_key, payload)
84
135
  end
85
136
 
86
137
  # Save query to "storage" (in redis)
87
138
  def write_subscription(query, events)
88
139
  context = query.context.to_h
89
- subscription_id = context[:subscription_id] ||= build_id
140
+ subscription_id = context.delete(:subscription_id) || build_id
90
141
  channel = context.delete(:channel)
91
- stream = context[:action_cable_stream] ||= SUBSCRIPTION_PREFIX + subscription_id
92
- channel.stream_from(stream)
142
+
143
+ events.each do |event|
144
+ channel.stream_from(SUBSCRIPTIONS_PREFIX + event.fingerprint)
145
+ end
93
146
 
94
147
  data = {
95
148
  query_string: query.query_string,
96
149
  variables: query.provided_variables.to_json,
97
150
  context: @serializer.dump(context.to_h),
98
- operation_name: query.operation_name
151
+ operation_name: query.operation_name,
152
+ events: events.map { |e| [e.topic, e.fingerprint] }.to_h.to_json,
99
153
  }
100
154
 
101
155
  redis.multi do
102
156
  redis.sadd(CHANNEL_PREFIX + channel.params["channelId"], subscription_id)
103
157
  redis.mapped_hmset(SUBSCRIPTION_PREFIX + subscription_id, data)
104
- redis.sadd(SUBSCRIPTION_EVENTS_PREFIX + subscription_id, events.map(&:topic))
105
158
  events.each do |event|
106
- redis.sadd(EVENT_PREFIX + event.topic, subscription_id)
159
+ redis.zincrby(FINGERPRINTS_PREFIX + event.topic, 1, event.fingerprint)
160
+ redis.sadd(SUBSCRIPTIONS_PREFIX + event.fingerprint, subscription_id)
107
161
  end
108
162
  next unless config.subscription_expiration_seconds
109
163
  redis.expire(CHANNEL_PREFIX + channel.params["channelId"], config.subscription_expiration_seconds)
@@ -117,24 +171,49 @@ module GraphQL
117
171
  "#{SUBSCRIPTION_PREFIX}#{subscription_id}",
118
172
  :query_string, :variables, :context, :operation_name
119
173
  ).tap do |subscription|
174
+ return if subscription.values.all?(&:nil?) # Redis returns hash with all nils for missing key
175
+
120
176
  subscription[:context] = @serializer.load(subscription[:context])
121
177
  subscription[:variables] = JSON.parse(subscription[:variables])
122
178
  subscription[:operation_name] = nil if subscription[:operation_name].strip == ""
123
179
  end
124
180
  end
125
181
 
126
- # The channel was closed, forget about it.
127
182
  def delete_subscription(subscription_id)
128
- # Remove subscription ids from all events
183
+ events = redis.hget(SUBSCRIPTION_PREFIX + subscription_id, :events)
184
+ events = events ? JSON.parse(events) : {}
185
+ fingerprint_subscriptions = {}
186
+ redis.pipelined do
187
+ events.each do |topic, fingerprint|
188
+ redis.srem(SUBSCRIPTIONS_PREFIX + fingerprint, subscription_id)
189
+ score = redis.zincrby(FINGERPRINTS_PREFIX + topic, -1, fingerprint)
190
+ fingerprint_subscriptions[FINGERPRINTS_PREFIX + topic] = score
191
+ end
192
+ # Delete subscription itself
193
+ redis.del(SUBSCRIPTION_PREFIX + subscription_id)
194
+ end
195
+ # Clean up fingerprints that doesn't have any subscriptions left
196
+ redis.pipelined do
197
+ fingerprint_subscriptions.each do |key, score|
198
+ redis.zremrangebyscore(key, '-inf', '0') if score.value.zero?
199
+ end
200
+ end
201
+ delete_legacy_subscription(subscription_id)
202
+ end
203
+
204
+ def delete_legacy_subscription(subscription_id)
205
+ return unless config.handle_legacy_subscriptions
206
+
129
207
  events = redis.smembers(SUBSCRIPTION_EVENTS_PREFIX + subscription_id)
130
- events.each do |event_topic|
131
- redis.srem(EVENT_PREFIX + event_topic, subscription_id)
208
+ redis.pipelined do
209
+ events.each do |event_topic|
210
+ redis.srem(EVENT_PREFIX + event_topic, subscription_id)
211
+ end
212
+ redis.del(SUBSCRIPTION_EVENTS_PREFIX + subscription_id)
132
213
  end
133
- # Delete subscription itself
134
- redis.del(SUBSCRIPTION_EVENTS_PREFIX + subscription_id)
135
- redis.del(SUBSCRIPTION_PREFIX + subscription_id)
136
214
  end
137
215
 
216
+ # The channel was closed, forget about it and its subscriptions
138
217
  def delete_channel_subscriptions(channel_id)
139
218
  redis.smembers(CHANNEL_PREFIX + channel_id).each do |subscription_id|
140
219
  delete_subscription(subscription_id)
metadata CHANGED
@@ -1,35 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-anycable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 1.0.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: 2020-08-25 00:00:00.000000000 Z
11
+ date: 2021-04-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: anycable
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: 0.6.0
20
- - - "<"
17
+ - - "~>"
21
18
  - !ruby/object:Gem::Version
22
- version: '2'
19
+ version: '1.0'
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
26
23
  requirements:
27
- - - ">="
28
- - !ruby/object:Gem::Version
29
- version: 0.6.0
30
- - - "<"
24
+ - - "~>"
31
25
  - !ruby/object:Gem::Version
32
- version: '2'
26
+ version: '1.0'
33
27
  - !ruby/object:Gem::Dependency
34
28
  name: anyway_config
35
29
  requirement: !ruby/object:Gem::Requirement
@@ -56,14 +50,14 @@ dependencies:
56
50
  requirements:
57
51
  - - "~>"
58
52
  - !ruby/object:Gem::Version
59
- version: '1.8'
53
+ version: '1.11'
60
54
  type: :runtime
61
55
  prerelease: false
62
56
  version_requirements: !ruby/object:Gem::Requirement
63
57
  requirements:
64
58
  - - "~>"
65
59
  - !ruby/object:Gem::Version
66
- version: '1.8'
60
+ version: '1.11'
67
61
  - !ruby/object:Gem::Dependency
68
62
  name: redis
69
63
  requirement: !ruby/object:Gem::Requirement
@@ -184,7 +178,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
184
178
  - !ruby/object:Gem::Version
185
179
  version: '0'
186
180
  requirements: []
187
- rubygems_version: 3.1.2
181
+ rubygems_version: 3.1.4
188
182
  signing_key:
189
183
  specification_version: 4
190
184
  summary: A drop-in replacement for GraphQL ActionCable subscriptions for AnyCable.