graphql-anycable 0.5.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/build-release.yml +29 -11
- data/.github/workflows/test.yml +18 -11
- data/CHANGELOG.md +57 -0
- data/Gemfile +1 -1
- data/README.md +46 -14
- data/graphql-anycable.gemspec +2 -2
- data/lib/graphql-anycable.rb +2 -5
- data/lib/graphql/anycable/cleaner.rb +25 -0
- data/lib/graphql/anycable/config.rb +1 -0
- data/lib/graphql/anycable/tasks/clean_expired_subscriptions.rake +12 -5
- data/lib/graphql/anycable/version.rb +1 -1
- data/lib/graphql/subscriptions/anycable_subscriptions.rb +99 -20
- metadata +9 -15
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6e8f296cdfe805ab7eb4ea8247b3880b785ba1314c3c2ef25d3ac316765714b5
|
4
|
+
data.tar.gz: d010e06e309c4724a111ebb5cff503678bf53b0ba8039f438ecd86892073ef7d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c84765a3b9461f431da8e3ad4a31fdc50e28836d9b331e04af7374676002bb9b02c78d6e18512b1e776a8d13fa431232c5c019b059497ccdee6953adecbde53c
|
7
|
+
data.tar.gz: 3a741fbaaf27eea28bd0a1885cee1e7ac1b24e8bb8a270537751465fa78e1f73f1ef1a3ca1538670e7d90503292dc5e05d8511a8f81861144ee999c6a9f30e25
|
@@ -1,4 +1,4 @@
|
|
1
|
-
name: Build and release gem
|
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:
|
45
|
-
- name: Upload
|
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:
|
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
|
-
|
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
|
82
|
+
gem push graphql-anycable-${{ steps.tag.outputs.version }}.gem
|
data/.github/workflows/test.yml
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
name:
|
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: "
|
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.
|
28
|
+
graphql: '~> 1.12.0'
|
22
29
|
anycable: '~> 1.0.0'
|
23
30
|
interpreter: yes
|
24
31
|
- ruby: 2.7
|
25
|
-
graphql: '~> 1.
|
32
|
+
graphql: '~> 1.12.0'
|
26
33
|
anycable: '~> 1.0.0'
|
27
34
|
interpreter: no
|
28
35
|
- ruby: 2.6
|
29
|
-
graphql: '~> 1.
|
36
|
+
graphql: '~> 1.11.0'
|
30
37
|
anycable: '~> 1.0.0'
|
31
38
|
interpreter: yes
|
32
39
|
- ruby: 2.6
|
33
|
-
graphql: '~> 1.
|
40
|
+
graphql: '~> 1.11.0'
|
34
41
|
anycable: '~> 1.0.0'
|
35
42
|
interpreter: no
|
36
43
|
- ruby: 2.5
|
37
|
-
graphql: '~> 1.
|
38
|
-
anycable: '~> 0.
|
44
|
+
graphql: '~> 1.11.0'
|
45
|
+
anycable: '~> 1.0.0'
|
39
46
|
interpreter: yes
|
40
47
|
- ruby: 2.5
|
41
|
-
graphql: '~> 1.
|
42
|
-
anycable: '~> 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.
|
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
|
[](https://badge.fury.io/rb/graphql-anycable)
|
6
|
+
[](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.
|
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
|
-
|
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
|
-
|
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"}
|
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
|
-
|
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.
|
data/graphql-anycable.gemspec
CHANGED
@@ -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", "
|
29
|
+
spec.add_dependency "anycable", "~> 1.0"
|
30
30
|
spec.add_dependency "anyway_config", ">= 1.3", "< 3"
|
31
|
-
spec.add_dependency "graphql", "~> 1.
|
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"
|
data/lib/graphql-anycable.rb
CHANGED
@@ -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
|
@@ -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
|
@@ -56,10 +56,13 @@ module GraphQL
|
|
56
56
|
|
57
57
|
def_delegators :"GraphQL::AnyCable", :redis, :config
|
58
58
|
|
59
|
-
SUBSCRIPTION_PREFIX
|
60
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
82
|
-
|
83
|
-
|
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
|
140
|
+
subscription_id = context.delete(:subscription_id) || build_id
|
90
141
|
channel = context.delete(:channel)
|
91
|
-
|
92
|
-
|
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.
|
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
|
-
|
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
|
-
|
131
|
-
|
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.
|
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:
|
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: '
|
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: '
|
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.
|
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.
|
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.
|
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.
|