graphql-anycable 1.3.1 → 1.3.2
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/lint.yml +2 -2
- data/.github/workflows/release.yml +1 -1
- data/.github/workflows/test.yml +5 -1
- data/.rubocop/rspec.yml +1 -1
- data/.rubocop.yml +1 -2
- data/CHANGELOG.md +13 -0
- data/README.md +9 -3
- data/gemfiles/rubocop.gemfile +3 -4
- data/graphql-anycable.gemspec +5 -1
- data/lib/graphql/anycable/cleaner.rb +48 -25
- data/lib/graphql/anycable/config.rb +1 -0
- data/lib/graphql/anycable/version.rb +1 -1
- data/lib/graphql/subscriptions/anycable_subscriptions.rb +0 -3
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14133c519749fc9821bc5e9af2c28978b620afe77a7ec03cb26a51f41ef56c66
|
|
4
|
+
data.tar.gz: c8f8ee0f3c378bc72b80f4df1623e2ab402dc379097af315422b742abe09e17b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 667c1ecce687b40287dbd930144480cc7a14e4e663ca2ab7cd29538b586d4b2e3d4fdae38a0d3b4f37187ef7d2d25aa7b1f6112d89d79db481efc79f46d49c01
|
|
7
|
+
data.tar.gz: fc431ad886ce163e17bd1d257491f7964fd1140ebe89365ecdca7c7f5d66c661408c51bea51a3e36bc26c1b380d421b5d7d8ab0814fb6a94bd1a0ead45bad595
|
data/.github/workflows/lint.yml
CHANGED
|
@@ -24,10 +24,10 @@ jobs:
|
|
|
24
24
|
env:
|
|
25
25
|
BUNDLE_GEMFILE: "gemfiles/rubocop.gemfile"
|
|
26
26
|
steps:
|
|
27
|
-
- uses: actions/checkout@
|
|
27
|
+
- uses: actions/checkout@v6
|
|
28
28
|
- uses: ruby/setup-ruby@v1
|
|
29
29
|
with:
|
|
30
|
-
ruby-version: 3.
|
|
30
|
+
ruby-version: 3.4
|
|
31
31
|
bundler-cache: true
|
|
32
32
|
- name: Lint Ruby code with RuboCop
|
|
33
33
|
run: |
|
data/.github/workflows/test.yml
CHANGED
|
@@ -18,6 +18,10 @@ jobs:
|
|
|
18
18
|
fail-fast: false
|
|
19
19
|
matrix:
|
|
20
20
|
include:
|
|
21
|
+
- ruby: "4.0"
|
|
22
|
+
graphql: '~> 2.3'
|
|
23
|
+
anycable: '~> 1.6'
|
|
24
|
+
redis_version: latest
|
|
21
25
|
- ruby: "3.3"
|
|
22
26
|
graphql: '~> 2.3'
|
|
23
27
|
anycable: '~> 1.5'
|
|
@@ -52,7 +56,7 @@ jobs:
|
|
|
52
56
|
ports:
|
|
53
57
|
- 6379:6379
|
|
54
58
|
steps:
|
|
55
|
-
- uses: actions/checkout@
|
|
59
|
+
- uses: actions/checkout@v6
|
|
56
60
|
- uses: ruby/setup-ruby@v1
|
|
57
61
|
with:
|
|
58
62
|
ruby-version: ${{ matrix.ruby }}
|
data/.rubocop/rspec.yml
CHANGED
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 1.3.2 - 2026-08-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Cleanup tasks are much faster and use bounded memory: `SCAN`/`SSCAN`/`ZSCAN` are called with a configurable `COUNT` (new `redis_scan_count` setting, default `1000`) instead of Redis' default of 10, members are read incrementally with `SSCAN`/`ZSCAN` instead of `SMEMBERS`/`ZRANGE` (which materialized entire sets in the rake process), and the per-member `EXISTS`, `OBJECT IDLETIME`, `SREM`, `ZREM` and `DEL` calls are pipelined in batches instead of being issued one round trip at a time. [@jjb] ([#56](https://github.com/anycable/graphql-anycable/pull/56))
|
|
15
|
+
|
|
16
|
+
## 1.3.1 - 2025-03-29
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Remove `clean:events` from `clean_expired_subscriptions.rake`. [@Geesu] ([#46](https://github.com/anycable/graphql-anycable/pull/46))
|
|
21
|
+
|
|
10
22
|
## 1.3.0 - 2024-08-13
|
|
11
23
|
|
|
12
24
|
### Changed
|
|
@@ -211,6 +223,7 @@ Technical release to test publishing via GitHub Actions.
|
|
|
211
223
|
|
|
212
224
|
Initial version: store subscriptions on redis, re-execute queries in sync. [@Envek]
|
|
213
225
|
|
|
226
|
+
[@jjb]: https://github.com/jjb "John Bachir"
|
|
214
227
|
[@prog-supdex]: https://github.com/prog-supdex "Igor Platonov"
|
|
215
228
|
[@ilyasgaraev]: https://github.com/ilyasgaraev "Ilyas Garaev"
|
|
216
229
|
[@smasry]: https://github.com/smasry "Samer Masry"
|
data/README.md
CHANGED
|
@@ -5,9 +5,9 @@ A (mostly) drop-in replacement for default ActionCable subscriptions adapter shi
|
|
|
5
5
|
[](https://badge.fury.io/rb/graphql-anycable)
|
|
6
6
|
[](https://github.com/anycable/graphql-anycable/actions/workflows/test.yml)
|
|
7
7
|
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
</a>
|
|
8
|
+
<br/>
|
|
9
|
+
|
|
10
|
+
<img src="https://cdn.evilmartians.com/badges/logo-no-label.svg" alt="Evil Martians logo" width="22" height="16" /> <b>GraphQL-AnyCable</b> is built by <b><a href="https://evilmartians.com/">Evil Martians</a></b>, an American design and engineering consultancy for <b>developer tools, AI, and cybersecurity startups</b>.
|
|
11
11
|
|
|
12
12
|
## Why?
|
|
13
13
|
|
|
@@ -134,6 +134,10 @@ To avoid filling Redis storage with stale subscription data:
|
|
|
134
134
|
|
|
135
135
|
Heroku users should set up `use_redis_object_on_cleanup` setting to `false` due to [limitations in Heroku Redis](https://devcenter.heroku.com/articles/heroku-redis#connection-permissions).
|
|
136
136
|
|
|
137
|
+
The task is also available as granular sub-tasks, so you can run only some of them, or run them on different schedules: `rake graphql:anycable:clean:channels`, `rake graphql:anycable:clean:subscriptions`, `rake graphql:anycable:clean:fingerprint_subscriptions`, `rake graphql:anycable:clean:topic_fingerprints`.
|
|
138
|
+
|
|
139
|
+
Cleanup iterates over Redis with the `SCAN` family of commands and pipelines the checks it performs on each batch. Use the `redis_scan_count` setting to tune the batch size: larger values mean fewer round trips (so faster cleanup), at the cost of doing more work per Redis call.
|
|
140
|
+
|
|
137
141
|
## Configuration
|
|
138
142
|
|
|
139
143
|
GraphQL-AnyCable uses [anyway_config] to configure itself. There are several possibilities to configure this gem:
|
|
@@ -144,6 +148,7 @@ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several pos
|
|
|
144
148
|
GRAPHQL_ANYCABLE_SUBSCRIPTION_EXPIRATION_SECONDS=604800
|
|
145
149
|
GRAPHQL_ANYCABLE_USE_REDIS_OBJECT_ON_CLEANUP=true
|
|
146
150
|
GRAPHQL_ANYCABLE_REDIS_PREFIX=graphql
|
|
151
|
+
GRAPHQL_ANYCABLE_REDIS_SCAN_COUNT=1000
|
|
147
152
|
```
|
|
148
153
|
|
|
149
154
|
2. YAML configuration files (note that this is `config/graphql_anycable.yml`, *not* `config/anycable.yml`):
|
|
@@ -154,6 +159,7 @@ GraphQL-AnyCable uses [anyway_config] to configure itself. There are several pos
|
|
|
154
159
|
subscription_expiration_seconds: 300 # 5 minutes
|
|
155
160
|
use_redis_object_on_cleanup: false # For restricted redis installations
|
|
156
161
|
redis_prefix: graphql # You can configure redis_prefix for anycable-graphql subscription prefixes. Default value "graphql"
|
|
162
|
+
redis_scan_count: 1000 # Batch size used by the cleanup rake tasks. Default value 1000
|
|
157
163
|
```
|
|
158
164
|
|
|
159
165
|
3. Configuration from your application code:
|
data/gemfiles/rubocop.gemfile
CHANGED
data/graphql-anycable.gemspec
CHANGED
|
@@ -17,6 +17,10 @@ Gem::Specification.new do |spec|
|
|
|
17
17
|
spec.homepage = "https://github.com/Envek/graphql-anycable"
|
|
18
18
|
spec.license = "MIT"
|
|
19
19
|
|
|
20
|
+
spec.metadata = {
|
|
21
|
+
"rubygems_mfa_required" => "true"
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
# Specify which files should be added to the gem when it is released.
|
|
21
25
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
22
26
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
@@ -34,7 +38,7 @@ Gem::Specification.new do |spec|
|
|
|
34
38
|
spec.add_dependency "redis", ">= 4.2.0"
|
|
35
39
|
|
|
36
40
|
spec.add_development_dependency "anycable-rails"
|
|
37
|
-
spec.add_development_dependency "bundler", "
|
|
41
|
+
spec.add_development_dependency "bundler", ">= 2.0"
|
|
38
42
|
spec.add_development_dependency "rack"
|
|
39
43
|
spec.add_development_dependency "railties"
|
|
40
44
|
spec.add_development_dependency "rake", ">= 12.3.3"
|
|
@@ -16,37 +16,22 @@ module GraphQL
|
|
|
16
16
|
return unless config.subscription_expiration_seconds
|
|
17
17
|
return unless config.use_redis_object_on_cleanup
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
redis.scan_each(match: "#{redis_key(adapter::CHANNEL_PREFIX)}*") do |key|
|
|
21
|
-
idle = redis.object("IDLETIME", key)
|
|
22
|
-
next if idle&.<= config.subscription_expiration_seconds
|
|
23
|
-
|
|
24
|
-
redis.del(key)
|
|
25
|
-
end
|
|
26
|
-
end
|
|
19
|
+
clean_idle_keys(adapter::CHANNEL_PREFIX)
|
|
27
20
|
end
|
|
28
21
|
|
|
29
22
|
def clean_subscriptions
|
|
30
23
|
return unless config.subscription_expiration_seconds
|
|
31
24
|
return unless config.use_redis_object_on_cleanup
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
redis.scan_each(match: "#{redis_key(adapter::SUBSCRIPTION_PREFIX)}*") do |key|
|
|
35
|
-
idle = redis.object("IDLETIME", key)
|
|
36
|
-
next if idle&.<= config.subscription_expiration_seconds
|
|
37
|
-
|
|
38
|
-
redis.del(key)
|
|
39
|
-
end
|
|
40
|
-
end
|
|
26
|
+
clean_idle_keys(adapter::SUBSCRIPTION_PREFIX)
|
|
41
27
|
end
|
|
42
28
|
|
|
43
29
|
def clean_fingerprint_subscriptions
|
|
44
30
|
AnyCable.with_redis do |redis|
|
|
45
|
-
redis
|
|
46
|
-
redis.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
redis.srem(key, subscription_id)
|
|
31
|
+
each_key(redis, adapter::SUBSCRIPTIONS_PREFIX) do |key|
|
|
32
|
+
each_batch(redis.sscan_each(key, count: redis_scan_count)) do |subscription_ids|
|
|
33
|
+
stale = missing_key_ids(redis, adapter::SUBSCRIPTION_PREFIX, subscription_ids)
|
|
34
|
+
redis.srem(key, stale) unless stale.empty?
|
|
50
35
|
end
|
|
51
36
|
end
|
|
52
37
|
end
|
|
@@ -54,12 +39,13 @@ module GraphQL
|
|
|
54
39
|
|
|
55
40
|
def clean_topic_fingerprints
|
|
56
41
|
AnyCable.with_redis do |redis|
|
|
57
|
-
redis
|
|
42
|
+
each_key(redis, adapter::FINGERPRINTS_PREFIX) do |key|
|
|
58
43
|
redis.zremrangebyscore(key, "-inf", "0")
|
|
59
|
-
redis.zrange(key, 0, -1).each do |fingerprint|
|
|
60
|
-
next if redis.exists?(redis_key(adapter::SUBSCRIPTIONS_PREFIX) + fingerprint)
|
|
61
44
|
|
|
62
|
-
|
|
45
|
+
each_batch(redis.zscan_each(key, count: redis_scan_count)) do |members|
|
|
46
|
+
fingerprints = members.map(&:first)
|
|
47
|
+
stale = missing_key_ids(redis, adapter::SUBSCRIPTIONS_PREFIX, fingerprints)
|
|
48
|
+
redis.zrem(key, stale) unless stale.empty?
|
|
63
49
|
end
|
|
64
50
|
end
|
|
65
51
|
end
|
|
@@ -67,6 +53,39 @@ module GraphQL
|
|
|
67
53
|
|
|
68
54
|
private
|
|
69
55
|
|
|
56
|
+
def clean_idle_keys(prefix)
|
|
57
|
+
AnyCable.with_redis do |redis|
|
|
58
|
+
each_batch(redis.scan_each(match: "#{redis_key(prefix)}*", count: redis_scan_count)) do |keys|
|
|
59
|
+
idle_times = redis.pipelined do |pipeline|
|
|
60
|
+
keys.each { |key| pipeline.object("IDLETIME", key) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
expired = keys.reject.with_index { |_key, index| idle_times[index]&.<= config.subscription_expiration_seconds }
|
|
64
|
+
redis.del(*expired) unless expired.empty?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Iterates over the keys matching the given prefix, one key at a time.
|
|
70
|
+
def each_key(redis, prefix, &block)
|
|
71
|
+
redis.scan_each(match: "#{redis_key(prefix)}*", count: redis_scan_count, &block)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Consumes a lazy enumerator (SCAN family) in batches to keep memory usage bounded.
|
|
75
|
+
def each_batch(enumerator, &block)
|
|
76
|
+
enumerator.each_slice(redis_scan_count, &block)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns the ids which have no corresponding key in redis anymore, checking them in a single round trip.
|
|
80
|
+
def missing_key_ids(redis, prefix, ids)
|
|
81
|
+
exists = redis.pipelined do |pipeline|
|
|
82
|
+
ids.each { |id| pipeline.exists?(redis_key(prefix) + id) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Reject ids that has corresponding key in Redis in `exists` array of pipelined responses.
|
|
86
|
+
ids.reject.with_index { |_id, index| exists[index] }
|
|
87
|
+
end
|
|
88
|
+
|
|
70
89
|
def adapter
|
|
71
90
|
GraphQL::Subscriptions::AnyCableSubscriptions
|
|
72
91
|
end
|
|
@@ -75,6 +94,10 @@ module GraphQL
|
|
|
75
94
|
GraphQL::AnyCable.config
|
|
76
95
|
end
|
|
77
96
|
|
|
97
|
+
def redis_scan_count
|
|
98
|
+
config.redis_scan_count.to_i
|
|
99
|
+
end
|
|
100
|
+
|
|
78
101
|
def redis_key(prefix)
|
|
79
102
|
"#{config.redis_prefix}-#{prefix}"
|
|
80
103
|
end
|
|
@@ -11,6 +11,7 @@ module GraphQL
|
|
|
11
11
|
attr_config subscription_expiration_seconds: nil
|
|
12
12
|
attr_config use_redis_object_on_cleanup: true
|
|
13
13
|
attr_config redis_prefix: "graphql" # Here, we set clear redis_prefix without any hyphen. The hyphen is added at the end of this value on our side.
|
|
14
|
+
attr_config redis_scan_count: 1_000 # COUNT for SCAN commands and batch size for pipelined commands during cleanup.
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
end
|
|
@@ -4,8 +4,6 @@ require "anycable"
|
|
|
4
4
|
require "graphql/subscriptions"
|
|
5
5
|
require "graphql/anycable/errors"
|
|
6
6
|
|
|
7
|
-
# rubocop: disable Metrics/AbcSize, Metrics/LineLength, Metrics/MethodLength
|
|
8
|
-
|
|
9
7
|
# A subscriptions implementation that sends data as AnyCable broadcastings.
|
|
10
8
|
#
|
|
11
9
|
# Since AnyCable is aimed to be compatible with ActionCable, this adapter
|
|
@@ -249,4 +247,3 @@ module GraphQL
|
|
|
249
247
|
end
|
|
250
248
|
end
|
|
251
249
|
end
|
|
252
|
-
# rubocop: enable Metrics/AbcSize, Metrics/LineLength, Metrics/MethodLength
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: graphql-anycable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.3.
|
|
4
|
+
version: 1.3.2
|
|
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: 2026-08-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: anycable-core
|
|
@@ -96,14 +96,14 @@ dependencies:
|
|
|
96
96
|
name: bundler
|
|
97
97
|
requirement: !ruby/object:Gem::Requirement
|
|
98
98
|
requirements:
|
|
99
|
-
- - "
|
|
99
|
+
- - ">="
|
|
100
100
|
- !ruby/object:Gem::Version
|
|
101
101
|
version: '2.0'
|
|
102
102
|
type: :development
|
|
103
103
|
prerelease: false
|
|
104
104
|
version_requirements: !ruby/object:Gem::Requirement
|
|
105
105
|
requirements:
|
|
106
|
-
- - "
|
|
106
|
+
- - ">="
|
|
107
107
|
- !ruby/object:Gem::Version
|
|
108
108
|
version: '2.0'
|
|
109
109
|
- !ruby/object:Gem::Dependency
|
|
@@ -202,7 +202,8 @@ files:
|
|
|
202
202
|
homepage: https://github.com/Envek/graphql-anycable
|
|
203
203
|
licenses:
|
|
204
204
|
- MIT
|
|
205
|
-
metadata:
|
|
205
|
+
metadata:
|
|
206
|
+
rubygems_mfa_required: 'true'
|
|
206
207
|
post_install_message:
|
|
207
208
|
rdoc_options: []
|
|
208
209
|
require_paths:
|