graphql-anycable 0.5.0 → 1.0.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: 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.