graphql-anycable 0.4.2 → 1.1.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: 0f464f4d333c2d59173af97549967def49f8843ae53882c4ab455fa48e1b62ab
4
- data.tar.gz: f19ba6bfd926d4038ce6c11bc40c499f60f5d426daeec59f9b2f28304d08d0bb
3
+ metadata.gz: 335a3ba62f65f0d6dfdd5eab2e1361382d192b5fbc910c591ced6f78296fb4ff
4
+ data.tar.gz: 9f392057c605fc0b74d6ea2c940409540b87e25ddb0a6dee34e47ad3929f652a
5
5
  SHA512:
6
- metadata.gz: 7b35ce8815a7ffebed1162b35d21cf3addb5f6677df3dd00311827cdac7d371277703c271447631a143d9b6e3e4865ea17a141ce2664ea7948fe9c52ac6bc6f5
7
- data.tar.gz: 7696f55e6a71cc8e1c14d08473a86da9b950317d88224afb8beaa2f0484d96886bf1d0d5a51cc53d3026a8598f4f4f7ecc57db9106b4aec80bcd81a0670b07e1
6
+ metadata.gz: 55b8fbf37efbdeff43beb7266ed627491b1a592f9cfe7e803dfd420b49d988a8057cb4ad7df7dea916df4f4c555868e29bace42cc76d2ae692755421a05f8bd5
7
+ data.tar.gz: d0665b9ec01879f5898c7638a9325c156c2665966552edc20cd013d3dfefbbbbada8e7e22d32e31ce7d8e6e0f59576c9a377ff92ffd1eb8e674e4cef533c7659
@@ -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.1.0'
22
+ interpreter: yes
23
+ - ruby: "3.0"
24
+ graphql: '~> 1.12.0'
25
+ anycable: '~> 1.1.0'
26
+ interpreter: no
20
27
  - ruby: 2.7
21
- graphql: '~> 1.11.0'
22
- anycable: '~> 1.0.0'
28
+ graphql: '~> 1.12.0'
29
+ anycable: '~> 1.1.0'
23
30
  interpreter: yes
24
31
  - ruby: 2.7
25
- graphql: '~> 1.11.0'
26
- anycable: '~> 1.0.0'
32
+ graphql: '~> 1.12.0'
33
+ anycable: '~> 1.1.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,91 @@ 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.0 - 2021-11-17
11
+
12
+ ### Added
13
+
14
+ - Support for generating unique channel IDs server-side and storing them in the channel states.
15
+
16
+ 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.
17
+
18
+ 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.
19
+
20
+ NOTE: Relying on client-side IDs is deprecated and will be removed in the future versions.
21
+
22
+ You must also update your cleanup code in the `Channel#unsubscribed`:
23
+
24
+ ```diff
25
+ - channel_id = params.fetch("channelId")
26
+ - MySchema.subscriptions.delete_channel_subscriptions(channel_id)
27
+ + MySchema.subscriptions.delete_channel_subscriptions(self)
28
+ ```
29
+
30
+ ## 1.0.1 - 2021-04-16
31
+
32
+ ### Added
33
+
34
+ - Guard check for presence of ActionCable channel instance in the GraphQL execution context.
35
+
36
+ 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.
37
+
38
+ ## 1.0.0 - 2021-04-01
39
+
40
+ ### Added
41
+
42
+ - 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))
43
+
44
+ ### Changed
45
+
46
+ - Subscription data storage format changed to support broadcasting feature (see [#15](https://github.com/anycable/graphql-anycable/pull/15))
47
+
48
+ ### Removed
49
+
50
+ - Drop support for GraphQL-Ruby before 1.11
51
+
52
+ - Drop support for AnyCable before 1.0
53
+
54
+ - Drop `:action_cable_stream` option from context: it is not used in reality.
55
+
56
+ See [rmosolgo/graphql-ruby#3076](https://github.com/rmosolgo/graphql-ruby/pull/3076) for details
57
+
58
+ ### Upgrading notes
59
+
60
+ 1. Change method of plugging in of this gem from `use GraphQL::Subscriptions::AnyCableSubscriptions` to `use GraphQL::AnyCable`:
61
+
62
+ ```ruby
63
+ use GraphQL::AnyCable
64
+ ```
65
+
66
+ If you need broadcasting, add `broadcast: true` option and ensure that [Interpreter mode](https://graphql-ruby.org/queries/interpreter.html) is enabled.
67
+
68
+ ```ruby
69
+ use GraphQL::Execution::Interpreter
70
+ use GraphQL::Analysis::AST
71
+ use GraphQL::AnyCable, broadcast: true, default_broadcastable: true
72
+ ```
73
+
74
+ 2. Enable `handle_legacy_subscriptions` setting for seamless upgrade from previous versions:
75
+
76
+ ```sh
77
+ GRAPHQL_ANYCABLE_HANDLE_LEGACY_SUBSCRIPTIONS=true
78
+ ```
79
+
80
+ 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.
81
+
82
+ ## 0.5.0 - 2020-08-26
83
+
84
+ ### Changed
85
+
86
+ - Allow to plug in this gem by calling `use GraphQL::AnyCable` instead of `use GraphQL::Subscriptions::AnyCableSubscriptions`. [@Envek]
87
+ - Rename `GraphQL::Anycable` constant to `GraphQL::AnyCable` for consistency with AnyCable itself. [@Envek]
88
+
89
+ ## 0.4.2 - 2020-08-25
90
+
91
+ Technical release to test publishing via GitHub Actions.
92
+
8
93
  ## 0.4.1 - 2020-08-21
9
94
 
10
95
  ### 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::Subscriptions::AnyCableSubscriptions
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:
@@ -111,13 +133,15 @@ To avoid filling Redis storage with stale subscription data:
111
133
 
112
134
  ## Configuration
113
135
 
114
- GraphQL-Anycable uses [anyway_config] to configure itself. There are several possibilities to configure this gem:
136
+ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several possibilities to configure this gem:
115
137
 
116
138
  1. Environment variables:
117
139
 
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,12 +151,14 @@ 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:
133
159
 
134
160
  ```ruby
135
- GraphQL::Anycable.configure do |config|
161
+ GraphQL::AnyCable.configure do |config|
136
162
  config.subscription_expiration_seconds = 3600 # 1 hour
137
163
  end
138
164
  ```
@@ -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.
@@ -6,7 +6,7 @@ require "graphql/anycable/version"
6
6
 
7
7
  Gem::Specification.new do |spec|
8
8
  spec.name = "graphql-anycable"
9
- spec.version = GraphQL::Anycable::VERSION
9
+ spec.version = GraphQL::AnyCable::VERSION
10
10
  spec.authors = ["Andrey Novikov"]
11
11
  spec.email = ["envek@envek.name"]
12
12
 
@@ -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"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GraphQL
4
- module Anycable
4
+ module AnyCable
5
5
  module Cleaner
6
6
  extend self
7
7
 
@@ -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
@@ -55,11 +80,11 @@ module GraphQL
55
80
  end
56
81
 
57
82
  def redis
58
- GraphQL::Anycable.redis
83
+ GraphQL::AnyCable.redis
59
84
  end
60
85
 
61
86
  def config
62
- GraphQL::Anycable.config
87
+ GraphQL::AnyCable.config
63
88
  end
64
89
  end
65
90
  end
@@ -3,13 +3,23 @@
3
3
  require "anyway"
4
4
 
5
5
  module GraphQL
6
- module Anycable
6
+ module AnyCable
7
7
  class Config < Anyway::Config
8
8
  config_name :graphql_anycable
9
9
  env_prefix :graphql_anycable
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
@@ -3,7 +3,7 @@
3
3
  require "rails"
4
4
 
5
5
  module GraphQL
6
- module Anycable
6
+ module AnyCable
7
7
  class Railtie < ::Rails::Railtie
8
8
  rake_tasks do
9
9
  path = File.expand_path(__dir__)
@@ -5,25 +5,32 @@ 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
15
12
  task :channels do
16
- GraphQL::Anycable::Cleaner.clean_channels
13
+ GraphQL::AnyCable::Cleaner.clean_channels
17
14
  end
18
15
 
19
16
  # Clean up old subscriptions (they should have expired by themselves)
20
17
  task :subscriptions do
21
- GraphQL::Anycable::Cleaner.clean_subscriptions
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
- GraphQL::Anycable::Cleaner.clean_events
23
+ GraphQL::AnyCable::Cleaner.clean_events
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
27
34
  end
28
35
  end
29
36
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GraphQL
4
- module Anycable
5
- VERSION = "0.4.2"
4
+ module AnyCable
5
+ VERSION = "1.1.0"
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
  #
@@ -54,12 +54,15 @@ module GraphQL
54
54
  class AnyCableSubscriptions < GraphQL::Subscriptions
55
55
  extend Forwardable
56
56
 
57
- def_delegators :"GraphQL::Anycable", :redis, :config
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,52 @@ 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)
139
228
  redis.smembers(CHANNEL_PREFIX + channel_id).each do |subscription_id|
140
229
  delete_subscription(subscription_id)
141
230
  end
@@ -147,6 +236,17 @@ module GraphQL
147
236
  def anycable
148
237
  @anycable ||= ::AnyCable.broadcast_adapter
149
238
  end
239
+
240
+ def read_subscription_id(channel)
241
+ return channel.instance_variable_get(:@__sid__) if channel.instance_variable_defined?(:@__sid__)
242
+
243
+ channel.instance_variable_set(:@__sid__, channel.connection.socket.istate["sid"])
244
+ end
245
+
246
+ def write_subscription_id(channel, val)
247
+ channel.connection.socket.istate["sid"] = val
248
+ channel.instance_variable_set(:@__sid__, val)
249
+ end
150
250
  end
151
251
  end
152
252
  end
@@ -9,7 +9,11 @@ require_relative "graphql/anycable/railtie" if defined?(Rails)
9
9
  require_relative "graphql/subscriptions/anycable_subscriptions"
10
10
 
11
11
  module GraphQL
12
- module Anycable
12
+ module AnyCable
13
+ def self.use(schema, **options)
14
+ schema.use GraphQL::Subscriptions::AnyCableSubscriptions, **options
15
+ end
16
+
13
17
  module_function
14
18
 
15
19
  def redis
@@ -24,7 +28,7 @@ module GraphQL
24
28
  end
25
29
 
26
30
  def config
27
- @config ||= GraphQL::Anycable::Config.new
31
+ @config ||= GraphQL::AnyCable::Config.new
28
32
  end
29
33
 
30
34
  def configure
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.4.2
4
+ version: 1.1.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-11-17 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
@@ -161,6 +155,7 @@ files:
161
155
  - lib/graphql/anycable.rb
162
156
  - lib/graphql/anycable/cleaner.rb
163
157
  - lib/graphql/anycable/config.rb
158
+ - lib/graphql/anycable/errors.rb
164
159
  - lib/graphql/anycable/railtie.rb
165
160
  - lib/graphql/anycable/tasks/clean_expired_subscriptions.rake
166
161
  - lib/graphql/anycable/version.rb
@@ -184,7 +179,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
184
179
  - !ruby/object:Gem::Version
185
180
  version: '0'
186
181
  requirements: []
187
- rubygems_version: 3.1.2
182
+ rubygems_version: 3.1.6
188
183
  signing_key:
189
184
  specification_version: 4
190
185
  summary: A drop-in replacement for GraphQL ActionCable subscriptions for AnyCable.