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 +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
|
[![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.
|
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.
|