graphql-anycable 1.3.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d85da4ea44364a70b75af1157b5dd613e22eddea2b8ca8c0b20fb06041d9618
4
- data.tar.gz: 915f82f6e2e746b968638454d5bf4adccf8a5c783a1203131e83fec6b0d28f5c
3
+ metadata.gz: 14133c519749fc9821bc5e9af2c28978b620afe77a7ec03cb26a51f41ef56c66
4
+ data.tar.gz: c8f8ee0f3c378bc72b80f4df1623e2ab402dc379097af315422b742abe09e17b
5
5
  SHA512:
6
- metadata.gz: 01d1d65ae70213dbc9ce22334174fb2ea0f703da7cfa824f0409d39f24ce79993c360833dd67c1e18471668b83c43bdcf86b1bcbe699547ef5a8e0bfacdffebf
7
- data.tar.gz: 99c5f3fc3ee7ad597dc3ee870e55cee7ca775df9fa0bb58f3babcbed0097d36ff2857349b3177cf13e3323013ca6b5304cb1a16a7240883847ed7b40a2c93323
6
+ metadata.gz: 667c1ecce687b40287dbd930144480cc7a14e4e663ca2ab7cd29538b586d4b2e3d4fdae38a0d3b4f37187ef7d2d25aa7b1f6112d89d79db481efc79f46d49c01
7
+ data.tar.gz: fc431ad886ce163e17bd1d257491f7964fd1140ebe89365ecdca7c7f5d66c661408c51bea51a3e36bc26c1b380d421b5d7d8ab0814fb6a94bd1a0ead45bad595
@@ -24,10 +24,10 @@ jobs:
24
24
  env:
25
25
  BUNDLE_GEMFILE: "gemfiles/rubocop.gemfile"
26
26
  steps:
27
- - uses: actions/checkout@v4
27
+ - uses: actions/checkout@v6
28
28
  - uses: ruby/setup-ruby@v1
29
29
  with:
30
- ruby-version: 3.1
30
+ ruby-version: 3.4
31
31
  bundler-cache: true
32
32
  - name: Lint Ruby code with RuboCop
33
33
  run: |
@@ -13,7 +13,7 @@ jobs:
13
13
  id-token: write
14
14
  packages: write
15
15
  steps:
16
- - uses: actions/checkout@v4
16
+ - uses: actions/checkout@v6
17
17
  with:
18
18
  fetch-depth: 0 # Fetch current tag as annotated. See https://github.com/actions/checkout/issues/290
19
19
  - uses: ruby/setup-ruby@v1
@@ -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@v4
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
@@ -1,4 +1,4 @@
1
- require:
1
+ plugins:
2
2
  - rubocop-rspec
3
3
 
4
4
  # Disable all cops by default,
data/.rubocop.yml CHANGED
@@ -2,8 +2,7 @@ inherit_mode:
2
2
  merge:
3
3
  - Exclude
4
4
 
5
- require:
6
- - standard
5
+ plugins:
7
6
  - standard-custom
8
7
  - standard-performance
9
8
  - rubocop-performance
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
  [![Gem Version](https://badge.fury.io/rb/graphql-anycable.svg)](https://badge.fury.io/rb/graphql-anycable)
6
6
  [![Tests](https://github.com/anycable/graphql-anycable/actions/workflows/test.yml/badge.svg)](https://github.com/anycable/graphql-anycable/actions/workflows/test.yml)
7
7
 
8
- <a href="https://evilmartians.com/?utm_source=graphql-anycable&utm_campaign=project_page">
9
- <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54">
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:
@@ -1,5 +1,4 @@
1
- source "https://rubygems.org" do
2
- gem "standard", "~> 1.28"
1
+ source "https://rubygems.org"
3
2
 
4
- gem "rubocop-rspec"
5
- end
3
+ gem "standard"
4
+ gem "rubocop-rspec"
@@ -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", "~> 2.0"
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,61 +16,88 @@ module GraphQL
16
16
  return unless config.subscription_expiration_seconds
17
17
  return unless config.use_redis_object_on_cleanup
18
18
 
19
- redis.scan_each(match: "#{redis_key(adapter::CHANNEL_PREFIX)}*") do |key|
20
- idle = redis.object("IDLETIME", key)
21
- next if idle&.<= config.subscription_expiration_seconds
22
-
23
- redis.del(key)
24
- end
19
+ clean_idle_keys(adapter::CHANNEL_PREFIX)
25
20
  end
26
21
 
27
22
  def clean_subscriptions
28
23
  return unless config.subscription_expiration_seconds
29
24
  return unless config.use_redis_object_on_cleanup
30
25
 
31
- redis.scan_each(match: "#{redis_key(adapter::SUBSCRIPTION_PREFIX)}*") do |key|
32
- idle = redis.object("IDLETIME", key)
33
- next if idle&.<= config.subscription_expiration_seconds
34
-
35
- redis.del(key)
36
- end
26
+ clean_idle_keys(adapter::SUBSCRIPTION_PREFIX)
37
27
  end
38
28
 
39
29
  def clean_fingerprint_subscriptions
40
- redis.scan_each(match: "#{redis_key(adapter::SUBSCRIPTIONS_PREFIX)}*") do |key|
41
- redis.smembers(key).each do |subscription_id|
42
- next if redis.exists?(redis_key(adapter::SUBSCRIPTION_PREFIX) + subscription_id)
43
-
44
- redis.srem(key, subscription_id)
30
+ AnyCable.with_redis do |redis|
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?
35
+ end
45
36
  end
46
37
  end
47
38
  end
48
39
 
49
40
  def clean_topic_fingerprints
50
- redis.scan_each(match: "#{redis_key(adapter::FINGERPRINTS_PREFIX)}*") do |key|
51
- redis.zremrangebyscore(key, "-inf", "0")
52
- redis.zrange(key, 0, -1).each do |fingerprint|
53
- next if redis.exists?(redis_key(adapter::SUBSCRIPTIONS_PREFIX) + fingerprint)
54
-
55
- redis.zrem(key, fingerprint)
41
+ AnyCable.with_redis do |redis|
42
+ each_key(redis, adapter::FINGERPRINTS_PREFIX) do |key|
43
+ redis.zremrangebyscore(key, "-inf", "0")
44
+
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?
49
+ end
56
50
  end
57
51
  end
58
52
  end
59
53
 
60
54
  private
61
55
 
62
- def adapter
63
- GraphQL::Subscriptions::AnyCableSubscriptions
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
64
67
  end
65
68
 
66
- def redis
67
- GraphQL::AnyCable.redis
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
+
89
+ def adapter
90
+ GraphQL::Subscriptions::AnyCableSubscriptions
68
91
  end
69
92
 
70
93
  def config
71
94
  GraphQL::AnyCable.config
72
95
  end
73
96
 
97
+ def redis_scan_count
98
+ config.redis_scan_count.to_i
99
+ end
100
+
74
101
  def redis_key(prefix)
75
102
  "#{config.redis_prefix}-#{prefix}"
76
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
@@ -5,7 +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 clean:fingerprint_subscriptions clean:topic_fingerprints]
8
+ task clean: %i[clean:channels clean:subscriptions clean:fingerprint_subscriptions clean:topic_fingerprints]
9
9
 
10
10
  namespace :clean do
11
11
  # Clean up old channels
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module AnyCable
5
- VERSION = "1.3.0"
5
+ VERSION = "1.3.2"
6
6
  end
7
7
  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.0
4
+ version: 1.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Novikov
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-13 00:00:00.000000000 Z
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
@@ -162,7 +162,7 @@ dependencies:
162
162
  - - "~>"
163
163
  - !ruby/object:Gem::Version
164
164
  version: '3.0'
165
- description:
165
+ description:
166
166
  email:
167
167
  - envek@envek.name
168
168
  executables: []
@@ -202,8 +202,9 @@ files:
202
202
  homepage: https://github.com/Envek/graphql-anycable
203
203
  licenses:
204
204
  - MIT
205
- metadata: {}
206
- post_install_message:
205
+ metadata:
206
+ rubygems_mfa_required: 'true'
207
+ post_install_message:
207
208
  rdoc_options: []
208
209
  require_paths:
209
210
  - lib
@@ -218,8 +219,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
218
219
  - !ruby/object:Gem::Version
219
220
  version: '0'
220
221
  requirements: []
221
- rubygems_version: 3.5.11
222
- signing_key:
222
+ rubygems_version: 3.5.22
223
+ signing_key:
223
224
  specification_version: 4
224
225
  summary: A drop-in replacement for GraphQL ActionCable subscriptions for AnyCable.
225
226
  test_files: []