graphql-anycable 0.5.0 → 1.1.1

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: 6c831aba1f67077cccb4ef5f1b7858715cf056e554551dab59acdf265a8c671e
4
- data.tar.gz: 5049b208e8a02a2e110e698f5336a1314577d2e1f8a37742ce07807daac6e41a
3
+ metadata.gz: a0437da8b1214b8c5aa81733cd8b0b4449a527f9d7f487e3d0e97034ffefea2e
4
+ data.tar.gz: ef6d5a734dfc73824e2a37e7b3d3bcd47eb3af597b99a9d91a7aa41c7fc85797
5
5
  SHA512:
6
- metadata.gz: d9451177ced38d8f963fe8b7f4c5b8ae1933a1d9730403202ee0864f23b3649752e538f8e480aedcae042aac2958b60145dc1305b79e67a3e480a96279df2bb1
7
- data.tar.gz: 1367ac8df59b0470725197da7be2701175cb8571146f81eaa896dd00b3052eee1f91e2abdabdf67bb5a18754385e11169d942864a65e1fb59cdb7f8afdd5938e
6
+ metadata.gz: eae7c1026142f0d4360d556af37eeae4937e9cde79bf0e05a8aed6a3dac9e40a6445f7970b11bc9c8a25def13ee42d1c0507452d6c168805452dc9a7d8fb1c30
7
+ data.tar.gz: f2985e19bad56f101099ca1da605f0eb62b606034d595530c86bbd672b146b65ef7f54212813fdf110dba961dfaa14e400bab390505828b4a8dc7fa289039155
@@ -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,26 @@ 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 }}, use_client_id: ${{ matrix.client_id }}) on Ruby ${{ matrix.ruby }}"
15
14
  runs-on: ubuntu-latest
16
15
  strategy:
17
16
  fail-fast: false
18
17
  matrix:
19
18
  include:
20
- - ruby: 2.7
21
- graphql: '~> 1.11.0'
22
- anycable: '~> 1.0.0'
19
+ - ruby: "3.0"
20
+ graphql: '~> 1.12.0'
21
+ anycable: '~> 1.1.0'
22
+ client_id: 'false'
23
23
  interpreter: yes
24
24
  - ruby: 2.7
25
- graphql: '~> 1.11.0'
26
- anycable: '~> 1.0.0'
27
- interpreter: no
28
- - ruby: 2.6
29
- graphql: '~> 1.10.0'
30
- anycable: '~> 1.0.0'
25
+ graphql: '~> 1.12.0'
26
+ anycable: '~> 1.1.0'
27
+ client_id: 'false'
31
28
  interpreter: yes
32
29
  - ruby: 2.6
33
- graphql: '~> 1.10.0'
30
+ graphql: '~> 1.11.0'
34
31
  anycable: '~> 1.0.0'
35
- interpreter: no
36
- - ruby: 2.5
37
- graphql: '~> 1.9.0'
38
- anycable: '~> 0.6.0'
39
- interpreter: yes
40
- - ruby: 2.5
41
- graphql: '~> 1.9.0'
42
- anycable: '~> 0.6.0'
32
+ client_id: 'true'
43
33
  interpreter: no
44
34
  container:
45
35
  image: ruby:${{ matrix.ruby }}
@@ -47,6 +37,7 @@ jobs:
47
37
  CI: true
48
38
  GRAPHQL_RUBY_VERSION: ${{ matrix.graphql }}
49
39
  ANYCABLE_VERSION: ${{ matrix.anycable }}
40
+ GRAPHQL_ANYCABLE_USE_CLIENT_PROVIDED_UNIQ_ID: ${{ matrix.client_id }}
50
41
  steps:
51
42
  - uses: actions/checkout@v2
52
43
  - uses: actions/cache@v2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,98 @@ 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.1.1 - 2021-12-06
11
+
12
+ ### Fixed
13
+
14
+ - Handling of buggy istate values on unsubscribe (when `use_client_provided_uniq_id: false`). [@palkan] [#20](https://github.com/anycable/graphql-anycable/pull/20)
15
+ - A bug when `#unsubscribe` happens before `#execute`. [@palkan] [#20](https://github.com/anycable/graphql-anycable/pull/20)
16
+
17
+ ## 1.1.0 - 2021-11-17
18
+
19
+ ### Added
20
+
21
+ - Support for generating unique channel IDs server-side and storing them in the channel states.
22
+
23
+ Currently, we rely on `params["channelId"]` to track subscriptions. This value is random when using `graphql-ruby` JS client, but is not guaranteed to be random in general.
24
+
25
+ Now you can opt-in to use server-side IDs by specifying `use_client_provided_uniq_id: false` in YAML config or thru the `GRAPHQL_ANYCABLE_USE_CLIENT_PROVIDED_UNIQ_ID=false` env var.
26
+
27
+ NOTE: Relying on client-side IDs is deprecated and will be removed in the future versions.
28
+
29
+ You must also update your cleanup code in the `Channel#unsubscribed`:
30
+
31
+ ```diff
32
+ - channel_id = params.fetch("channelId")
33
+ - MySchema.subscriptions.delete_channel_subscriptions(channel_id)
34
+ + MySchema.subscriptions.delete_channel_subscriptions(self)
35
+ ```
36
+
37
+ ## 1.0.1 - 2021-04-16
38
+
39
+ ### Added
40
+
41
+ - Guard check for presence of ActionCable channel instance in the GraphQL execution context.
42
+
43
+ This allows to detect wrong configuration (user forgot to pass channel into context) or wrong usage (subscription query was sent via HTTP request, not via WebSocket channel) of the library and provides clear error message to gem users.
44
+
45
+ ## 1.0.0 - 2021-04-01
46
+
47
+ ### Added
48
+
49
+ - 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))
50
+
51
+ ### Changed
52
+
53
+ - Subscription data storage format changed to support broadcasting feature (see [#15](https://github.com/anycable/graphql-anycable/pull/15))
54
+
55
+ ### Removed
56
+
57
+ - Drop support for GraphQL-Ruby before 1.11
58
+
59
+ - Drop support for AnyCable before 1.0
60
+
61
+ - Drop `:action_cable_stream` option from context: it is not used in reality.
62
+
63
+ See [rmosolgo/graphql-ruby#3076](https://github.com/rmosolgo/graphql-ruby/pull/3076) for details
64
+
65
+ ### Upgrading notes
66
+
67
+ 1. Change method of plugging in of this gem from `use GraphQL::Subscriptions::AnyCableSubscriptions` to `use GraphQL::AnyCable`:
68
+
69
+ ```ruby
70
+ use GraphQL::AnyCable
71
+ ```
72
+
73
+ If you need broadcasting, add `broadcast: true` option and ensure that [Interpreter mode](https://graphql-ruby.org/queries/interpreter.html) is enabled.
74
+
75
+ ```ruby
76
+ use GraphQL::Execution::Interpreter
77
+ use GraphQL::Analysis::AST
78
+ use GraphQL::AnyCable, broadcast: true, default_broadcastable: true
79
+ ```
80
+
81
+ 2. Enable `handle_legacy_subscriptions` setting for seamless upgrade from previous versions:
82
+
83
+ ```sh
84
+ GRAPHQL_ANYCABLE_HANDLE_LEGACY_SUBSCRIPTIONS=true
85
+ ```
86
+
87
+ 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.
88
+
89
+ ## 0.5.0 - 2020-08-26
90
+
91
+ ### Changed
92
+
93
+ - Allow to plug in this gem by calling `use GraphQL::AnyCable` instead of `use GraphQL::Subscriptions::AnyCableSubscriptions`. [@Envek]
94
+ - Rename `GraphQL::Anycable` constant to `GraphQL::AnyCable` for consistency with AnyCable itself. [@Envek]
95
+
96
+ ## 0.4.2 - 2020-08-25
97
+
98
+ Technical release to test publishing via GitHub Actions.
99
+
8
100
  ## 0.4.1 - 2020-08-21
9
101
 
10
102
  ### Fixed
@@ -57,3 +149,4 @@ Initial version: store subscriptions on redis, re-execute queries in sync. [@Env
57
149
  [@bibendi]: https://github.com/bibendi "Misha Merkushin"
58
150
  [@FX-HAO]: https://github.com/FX-HAO "Fuxin Hao"
59
151
  [@Envek]: https://github.com/Envek "Andrey Novikov"
152
+ [@palkan]: https://github.com/palkan "Vladimir Dementyev"
data/Gemfile CHANGED
@@ -7,8 +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.11")
10
+ gem "graphql", ENV.fetch("GRAPHQL_RUBY_VERSION", "~> 1.12")
11
11
  gem "anycable", ENV.fetch("ANYCABLE_VERSION", "~> 1.0")
12
+ gem "anycable-rails", ENV.fetch("ANYCABLE_VERSION", "~> 1.0"), require: false
12
13
 
13
14
  group :development, :test do
14
15
  gem "pry"
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
@@ -76,8 +77,7 @@ Or install it yourself as:
76
77
  end
77
78
 
78
79
  def unsubscribed
79
- channel_id = params.fetch("channelId")
80
- MySchema.subscriptions.delete_channel_subscriptions(channel_id)
80
+ MySchema.subscriptions.delete_channel_subscriptions(self)
81
81
  end
82
82
 
83
83
  private
@@ -99,6 +99,28 @@ Or install it yourself as:
99
99
  MySchema.subscriptions.trigger(:product_updated, {}, Product.first!, scope: account.id)
100
100
  ```
101
101
 
102
+ ## Broadcasting
103
+
104
+ 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).
105
+
106
+ 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.
107
+
108
+ To enable this feature, turn on [Interpreter](https://graphql-ruby.org/queries/interpreter.html) and pass `broadcast` option set to `true` to graphql-anycable.
109
+
110
+ 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!)
111
+
112
+ ```ruby
113
+ class MySchema < GraphQL::Schema
114
+ use GraphQL::Execution::Interpreter # Required for graphql-ruby before 1.12.4
115
+ use GraphQL::Analysis::AST
116
+ use GraphQL::AnyCable, broadcast: true, default_broadcastable: true
117
+
118
+ subscription SubscriptionType
119
+ end
120
+ ```
121
+
122
+ See GraphQL-Ruby [broadcasting docs](https://graphql-ruby.org/subscriptions/broadcast.html) for more details.
123
+
102
124
  ## Operations
103
125
 
104
126
  To avoid filling Redis storage with stale subscription data:
@@ -118,6 +140,8 @@ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several pos
118
140
  ```.env
119
141
  GRAPHQL_ANYCABLE_SUBSCRIPTION_EXPIRATION_SECONDS=604800
120
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
121
145
  ```
122
146
 
123
147
  2. YAML configuration files:
@@ -127,6 +151,8 @@ 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
155
+ use_client_provided_uniq_id: false # To avoid problems with non-uniqueness of Apollo channel identifiers
130
156
  ```
131
157
 
132
158
  3. Configuration from your application code:
@@ -143,39 +169,46 @@ And any other way provided by [anyway_config]. Check its documentation!
143
169
 
144
170
  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
171
 
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`.
172
+ 1. Grouped event subscriptions: `graphql-fingerprints:#{event.topic}` sorted set. Used to find all subscriptions on `GraphQLSchema.subscriptions.trigger`.
147
173
 
148
174
  ```
149
- SMEMBERS graphql-event:1:myStats:
175
+ ZREVRANGE graphql-fingerprints:1:myStats: 0 -1
176
+ => 1:myStats:/MyStats/fBDZmJU1UGTorQWvOyUeaHVwUxJ3T9SEqnetj6SKGXc=/0/RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o=
177
+ ```
178
+
179
+ 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.
180
+
181
+ ```
182
+ SMEMBERS graphql-subscriptions:1:myStats:/MyStats/fBDZmJU1UGTorQWvOyUeaHVwUxJ3T9SEqnetj6SKGXc=/0/RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o=
150
183
  => 52ee8d65-275e-4d22-94af-313129116388
151
184
  ```
152
185
 
153
- 2. Subscription data: `graphql-subscription:#{subscription_id}` hash contains everything required to evaluate subscription on trigger and create data for client.
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
+ 3. Subscription data: `graphql-subscription:#{subscription_id}` hash contains everything required to evaluate subscription on trigger and create data for client.
154
194
 
155
195
  ```
156
196
  HGETALL graphql-subscription:52ee8d65-275e-4d22-94af-313129116388
157
197
  => {
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",}',
198
+ context: '{"user_id":1,"user":{"__gid__":"Z2lkOi8vZWJheS1tYWcyL1VzZXIvMQ"}}',
159
199
  variables: '{}',
160
200
  operation_name: 'MyStats'
161
201
  query_string: 'subscription MyStats { myStatsUpdated { completed total processed __typename } }',
162
202
  }
163
203
  ```
164
204
 
165
- 3. Channel subscriptions: `graphql-channel:#{channel_id}` set containing identifiers for subscriptions created in ActionCable channel to delete them on client disconnect.
205
+ 4. Channel subscriptions: `graphql-channel:#{channel_id}` set containing identifiers for subscriptions created in ActionCable channel to delete them on client disconnect.
166
206
 
167
207
  ```
168
208
  SMEMBERS graphql-channel:17420c6ed9e
169
209
  => 52ee8d65-275e-4d22-94af-313129116388
170
210
  ```
171
211
 
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
212
  ## Development
180
213
 
181
214
  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,13 +26,16 @@ 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
+ spec.add_development_dependency "anycable-rails"
34
35
  spec.add_development_dependency "bundler", "~> 2.0"
35
36
  spec.add_development_dependency "fakeredis"
37
+ spec.add_development_dependency "rack"
38
+ spec.add_development_dependency "railties"
36
39
  spec.add_development_dependency "rake", ">= 12.3.3"
37
40
  spec.add_development_dependency "rspec", "~> 3.0"
38
41
  end
@@ -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,16 @@ 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
15
+
16
+ on_load do
17
+ next unless use_client_provided_uniq_id?
18
+
19
+ warn "[Deprecated] Using client provided channel uniq IDs could lead to unexpected behaviour, " \
20
+ " please, set GraphQL::AnyCable.config.use_client_provided_uniq_id = false or GRAPHQL_ANYCABLE_USE_CLIENT_PROVIDED_UNIQ_ID=false, " \
21
+ " and update the `#unsubscribed` callback code according to the latest docs."
22
+ end
13
23
  end
14
24
  end
15
25
  end
@@ -0,0 +1,20 @@
1
+ module GraphQL
2
+ module AnyCable
3
+ # This error is thrown when ActionCable channel wasn't provided to subscription implementation.
4
+ # Typical cases:
5
+ # 1. application developer forgot to pass ActionCable channel into context
6
+ # 2. subscription query was sent via usual HTTP request, not websockets as intended
7
+ class ChannelConfigurationError < ::RuntimeError
8
+ def initialize(msg = nil)
9
+ super(msg || <<~DEFAULT_MESSAGE)
10
+ ActionCable channel wasn't provided in the context for GraphQL query execution!
11
+
12
+ This can occur in the following cases:
13
+ 1. ActionCable channel instance wasn't passed into GraphQL execution context in the channel's execute method.
14
+ See https://github.com/anycable/graphql-anycable#usage
15
+ 2. Subscription query was sent via usual HTTP request, not via WebSocket as intended
16
+ DEFAULT_MESSAGE
17
+ end
18
+ end
19
+ end
20
+ 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.1.1"
6
6
  end
7
7
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "anycable"
4
4
  require "graphql/subscriptions"
5
+ require "graphql/anycable/errors"
5
6
 
6
7
  # rubocop: disable Metrics/AbcSize, Metrics/LineLength, Metrics/MethodLength
7
8
 
@@ -44,8 +45,7 @@ require "graphql/subscriptions"
44
45
  # end
45
46
  #
46
47
  # def unsubscribed
47
- # channel_id = params.fetch("channelId")
48
- # MySchema.subscriptions.delete_channel_subscriptions(channel_id)
48
+ # MySchema.subscriptions.delete_channel_subscriptions(self)
49
49
  # end
50
50
  # end
51
51
  #
@@ -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,43 +73,102 @@ 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
+ raise GraphQL::AnyCable::ChannelConfigurationError unless channel
144
+
145
+ channel_uniq_id = config.use_client_provided_uniq_id? ? channel.params["channelId"] : subscription_id
146
+
147
+ # Store subscription_id in the channel state to cleanup on disconnect
148
+ write_subscription_id(channel, channel_uniq_id)
149
+
150
+
151
+ events.each do |event|
152
+ channel.stream_from(SUBSCRIPTIONS_PREFIX + event.fingerprint)
153
+ end
93
154
 
94
155
  data = {
95
156
  query_string: query.query_string,
96
157
  variables: query.provided_variables.to_json,
97
158
  context: @serializer.dump(context.to_h),
98
- operation_name: query.operation_name
159
+ operation_name: query.operation_name,
160
+ events: events.map { |e| [e.topic, e.fingerprint] }.to_h.to_json,
99
161
  }
100
162
 
101
163
  redis.multi do
102
- redis.sadd(CHANNEL_PREFIX + channel.params["channelId"], subscription_id)
164
+ redis.sadd(CHANNEL_PREFIX + channel_uniq_id, subscription_id)
103
165
  redis.mapped_hmset(SUBSCRIPTION_PREFIX + subscription_id, data)
104
- redis.sadd(SUBSCRIPTION_EVENTS_PREFIX + subscription_id, events.map(&:topic))
105
166
  events.each do |event|
106
- redis.sadd(EVENT_PREFIX + event.topic, subscription_id)
167
+ redis.zincrby(FINGERPRINTS_PREFIX + event.topic, 1, event.fingerprint)
168
+ redis.sadd(SUBSCRIPTIONS_PREFIX + event.fingerprint, subscription_id)
107
169
  end
108
170
  next unless config.subscription_expiration_seconds
109
- redis.expire(CHANNEL_PREFIX + channel.params["channelId"], config.subscription_expiration_seconds)
171
+ redis.expire(CHANNEL_PREFIX + channel_uniq_id, config.subscription_expiration_seconds)
110
172
  redis.expire(SUBSCRIPTION_PREFIX + subscription_id, config.subscription_expiration_seconds)
111
173
  end
112
174
  end
@@ -117,25 +179,56 @@ module GraphQL
117
179
  "#{SUBSCRIPTION_PREFIX}#{subscription_id}",
118
180
  :query_string, :variables, :context, :operation_name
119
181
  ).tap do |subscription|
182
+ return if subscription.values.all?(&:nil?) # Redis returns hash with all nils for missing key
183
+
120
184
  subscription[:context] = @serializer.load(subscription[:context])
121
185
  subscription[:variables] = JSON.parse(subscription[:variables])
122
186
  subscription[:operation_name] = nil if subscription[:operation_name].strip == ""
123
187
  end
124
188
  end
125
189
 
126
- # The channel was closed, forget about it.
127
190
  def delete_subscription(subscription_id)
128
- # Remove subscription ids from all events
191
+ events = redis.hget(SUBSCRIPTION_PREFIX + subscription_id, :events)
192
+ events = events ? JSON.parse(events) : {}
193
+ fingerprint_subscriptions = {}
194
+ redis.pipelined do
195
+ events.each do |topic, fingerprint|
196
+ redis.srem(SUBSCRIPTIONS_PREFIX + fingerprint, subscription_id)
197
+ score = redis.zincrby(FINGERPRINTS_PREFIX + topic, -1, fingerprint)
198
+ fingerprint_subscriptions[FINGERPRINTS_PREFIX + topic] = score
199
+ end
200
+ # Delete subscription itself
201
+ redis.del(SUBSCRIPTION_PREFIX + subscription_id)
202
+ end
203
+ # Clean up fingerprints that doesn't have any subscriptions left
204
+ redis.pipelined do
205
+ fingerprint_subscriptions.each do |key, score|
206
+ redis.zremrangebyscore(key, '-inf', '0') if score.value.zero?
207
+ end
208
+ 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
+
129
215
  events = redis.smembers(SUBSCRIPTION_EVENTS_PREFIX + subscription_id)
130
- events.each do |event_topic|
131
- redis.srem(EVENT_PREFIX + event_topic, 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)
132
221
  end
133
- # Delete subscription itself
134
- redis.del(SUBSCRIPTION_EVENTS_PREFIX + subscription_id)
135
- redis.del(SUBSCRIPTION_PREFIX + subscription_id)
136
222
  end
137
223
 
138
- def delete_channel_subscriptions(channel_id)
224
+ # 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)
228
+
229
+ # Missing in case disconnect happens before #execute
230
+ return unless channel_id
231
+
139
232
  redis.smembers(CHANNEL_PREFIX + channel_id).each do |subscription_id|
140
233
  delete_subscription(subscription_id)
141
234
  end
@@ -147,6 +240,34 @@ module GraphQL
147
240
  def anycable
148
241
  @anycable ||= ::AnyCable.broadcast_adapter
149
242
  end
243
+
244
+ def read_subscription_id(channel)
245
+ return channel.instance_variable_get(:@__sid__) if channel.instance_variable_defined?(:@__sid__)
246
+
247
+ istate = fetch_channel_istate(channel)
248
+
249
+ return unless istate
250
+
251
+ channel.instance_variable_set(:@__sid__, istate["sid"])
252
+ end
253
+
254
+ def write_subscription_id(channel, val)
255
+ channel.connection.socket.istate["sid"] = val
256
+ channel.instance_variable_set(:@__sid__, val)
257
+ end
258
+
259
+ def fetch_channel_istate(channel)
260
+ # For Rails integration
261
+ return channel.__istate__ if channel.respond_to?(:__istate__)
262
+
263
+ return unless channel.connection.socket.istate
264
+
265
+ if channel.connection.socket.istate[channel.identifier]
266
+ JSON.parse(channel.connection.socket.istate[channel.identifier])
267
+ else
268
+ channel.connection.socket.istate
269
+ end
270
+ end
150
271
  end
151
272
  end
152
273
  end
@@ -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
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.1.1
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-12-06 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
@@ -78,6 +72,20 @@ dependencies:
78
72
  - - ">="
79
73
  - !ruby/object:Gem::Version
80
74
  version: 4.2.0
75
+ - !ruby/object:Gem::Dependency
76
+ name: anycable-rails
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
81
89
  - !ruby/object:Gem::Dependency
82
90
  name: bundler
83
91
  requirement: !ruby/object:Gem::Requirement
@@ -106,6 +114,34 @@ dependencies:
106
114
  - - ">="
107
115
  - !ruby/object:Gem::Version
108
116
  version: '0'
117
+ - !ruby/object:Gem::Dependency
118
+ name: rack
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ - !ruby/object:Gem::Dependency
132
+ name: railties
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ type: :development
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
109
145
  - !ruby/object:Gem::Dependency
110
146
  name: rake
111
147
  requirement: !ruby/object:Gem::Requirement
@@ -161,6 +197,7 @@ files:
161
197
  - lib/graphql/anycable.rb
162
198
  - lib/graphql/anycable/cleaner.rb
163
199
  - lib/graphql/anycable/config.rb
200
+ - lib/graphql/anycable/errors.rb
164
201
  - lib/graphql/anycable/railtie.rb
165
202
  - lib/graphql/anycable/tasks/clean_expired_subscriptions.rake
166
203
  - lib/graphql/anycable/version.rb
@@ -184,7 +221,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
184
221
  - !ruby/object:Gem::Version
185
222
  version: '0'
186
223
  requirements: []
187
- rubygems_version: 3.1.2
224
+ rubygems_version: 3.1.6
188
225
  signing_key:
189
226
  specification_version: 4
190
227
  summary: A drop-in replacement for GraphQL ActionCable subscriptions for AnyCable.