graphql-anycable 1.3.2 → 1.3.4

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: 14133c519749fc9821bc5e9af2c28978b620afe77a7ec03cb26a51f41ef56c66
4
- data.tar.gz: c8f8ee0f3c378bc72b80f4df1623e2ab402dc379097af315422b742abe09e17b
3
+ metadata.gz: 8d1012a281016a5fdd02aa9a1decc2d3935f545fef5899c15505dfeef64c9628
4
+ data.tar.gz: ae8f4ff4cb25da208b31b8a257f60307bf66fe626362741ea29705c97bcab8e1
5
5
  SHA512:
6
- metadata.gz: 667c1ecce687b40287dbd930144480cc7a14e4e663ca2ab7cd29538b586d4b2e3d4fdae38a0d3b4f37187ef7d2d25aa7b1f6112d89d79db481efc79f46d49c01
7
- data.tar.gz: fc431ad886ce163e17bd1d257491f7964fd1140ebe89365ecdca7c7f5d66c661408c51bea51a3e36bc26c1b380d421b5d7d8ab0814fb6a94bd1a0ead45bad595
6
+ metadata.gz: 3c37a74df3ffc723b7b2fcf7bbfc009b3d67df79275d1c027b22bec3c9f45d6b3a5896b270582c50fc0deb78e38c1f7bc4f38a03288aa941ac20ed584538a3c8
7
+ data.tar.gz: 7dff7ea7638ee30a09bbc889648db4e0ebf4a12ba2798f215b013f29bb92e146241b0a9e4cad167c4691fbe15141ef085966f4799d708352ed1729628c97d634
@@ -19,13 +19,17 @@ jobs:
19
19
  matrix:
20
20
  include:
21
21
  - ruby: "4.0"
22
- graphql: '~> 2.3'
22
+ graphql: '~> 2.6'
23
23
  anycable: '~> 1.6'
24
24
  redis_version: latest
25
+ - ruby: "3.4"
26
+ graphql: '~> 2.5.0'
27
+ anycable: '~> 1.6'
28
+ redis_version: '7.4'
25
29
  - ruby: "3.3"
26
- graphql: '~> 2.3'
30
+ graphql: '~> 2.3.0'
27
31
  anycable: '~> 1.5'
28
- redis_version: latest
32
+ redis_version: '7.4'
29
33
  - ruby: "3.2"
30
34
  graphql: '~> 2.2.0'
31
35
  anycable: '~> 1.5.0'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 1.3.4 - 2026-08-21
11
+
12
+ ### Fixed
13
+
14
+ - Return `nil` from `read_subscription` if subscription data has gone from Redis, That was resulted in a "No query string was present" errors received on graphql subscription clients receiving a hash with an `nil` `query_string` instead of receiving `nil` directly. [@thlacroix] ([#51](https://github.com/anycable/graphql-anycable/pull/51))
15
+
16
+ - Subscription events are no longer dropped when another subscription with the same fingerprint expires during execution. [@prog-supdex] ([#53](https://github.com/anycable/graphql-anycable/pull/53))
17
+
18
+ - Deleting a subscription no longer goes through the deprecated `GraphQL::AnyCable.redis`; this avoids both the deprecation warning and reusing a connection after it has been returned to the pool. [@Envek] ([#53](https://github.com/anycable/graphql-anycable/pull/53))
19
+
20
+ ### Changed
21
+
22
+ - `GraphQL::Subscriptions::AnyCableSubscriptions#read_subscription` now raises `GraphQL::AnyCable::SubscriptionExpiredError` when the subscription is no longer stored in Redis, instead of returning `nil`. [@Envek] ([#53](https://github.com/anycable/graphql-anycable/pull/53))
23
+
24
+ ## 1.3.3 - 2026-08-10
25
+
26
+ ### Fixed
27
+
28
+ - Subscriptions are no longer leaked when a channel carries more than one of them. The first subscription's id now names the channel and later subscriptions are added to that same set. On disconnect, the channel's set is read and all subscriptions in it are deleted instead of only the most recent one as before. [@jjb] ([#58](https://github.com/anycable/graphql-anycable/pull/58))
29
+
30
+ - Further optimize cleanup tasks with pipelining their reads and removals across keys. [@jjb] ([#57](https://github.com/anycable/graphql-anycable/pull/57))
31
+
10
32
  ## 1.3.2 - 2026-08-05
11
33
 
12
34
  ### Fixed
@@ -223,6 +245,7 @@ Technical release to test publishing via GitHub Actions.
223
245
 
224
246
  Initial version: store subscriptions on redis, re-execute queries in sync. [@Envek]
225
247
 
248
+ [@thlacroix]: https://github.com/thlacroix "Thomas Lacroix"
226
249
  [@jjb]: https://github.com/jjb "John Bachir"
227
250
  [@prog-supdex]: https://github.com/prog-supdex "Igor Platonov"
228
251
  [@ilyasgaraev]: https://github.com/ilyasgaraev "Ilyas Garaev"
data/README.md CHANGED
@@ -246,11 +246,12 @@ As in AnyCable there is no place to store subscription data in-memory, it should
246
246
  }
247
247
  ```
248
248
 
249
- 4. Channel subscriptions: `graphql-channel:#{subscription_id}` set containing identifiers for subscriptions created in ActionCable channel to delete them on client disconnect.
249
+ 4. Channel subscriptions: `graphql-channel:#{channel_id}` set containing identifiers for all subscriptions created on one ActionCable channel, used to delete them on client disconnect. The channel is named after its first subscription's id (stored in the channel's AnyCable state); every later subscription on the same channel is added to that same set.
250
250
 
251
251
  ```sh
252
- SMEMBERS graphql-channel:17420c6ed9e
252
+ SMEMBERS graphql-channel:52ee8d65-275e-4d22-94af-313129116388
253
253
  => 52ee8d65-275e-4d22-94af-313129116388
254
+ a1b2c3d4-5678-90ab-cdef-111213141516
254
255
  ```
255
256
 
256
257
  ## Stats
@@ -28,10 +28,18 @@ module GraphQL
28
28
 
29
29
  def clean_fingerprint_subscriptions
30
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?
31
+ each_key_batch(redis, adapter::SUBSCRIPTIONS_PREFIX) do |keys|
32
+ bulk_readable, oversized = partition_by_size(redis, keys, :scard)
33
+
34
+ each_capped_chunk(bulk_readable) do |chunk|
35
+ members = redis.pipelined { |pipeline| chunk.each { |key| pipeline.smembers(key) } }
36
+ remove_stale(redis, adapter::SUBSCRIPTION_PREFIX, chunk.zip(members), :srem)
37
+ end
38
+
39
+ oversized.each do |key|
40
+ each_batch(redis.sscan_each(key, count: redis_scan_count)) do |subscription_ids|
41
+ remove_stale(redis, adapter::SUBSCRIPTION_PREFIX, [[key, subscription_ids]], :srem)
42
+ end
35
43
  end
36
44
  end
37
45
  end
@@ -39,13 +47,20 @@ module GraphQL
39
47
 
40
48
  def clean_topic_fingerprints
41
49
  AnyCable.with_redis do |redis|
42
- each_key(redis, adapter::FINGERPRINTS_PREFIX) do |key|
43
- redis.zremrangebyscore(key, "-inf", "0")
50
+ each_key_batch(redis, adapter::FINGERPRINTS_PREFIX) do |keys|
51
+ redis.pipelined { |pipeline| keys.each { |key| pipeline.zremrangebyscore(key, "-inf", "0") } }
52
+
53
+ bulk_readable, oversized = partition_by_size(redis, keys, :zcard)
54
+
55
+ each_capped_chunk(bulk_readable) do |chunk|
56
+ fingerprints = redis.pipelined { |pipeline| chunk.each { |key| pipeline.zrange(key, 0, -1) } }
57
+ remove_stale(redis, adapter::SUBSCRIPTIONS_PREFIX, chunk.zip(fingerprints), :zrem)
58
+ end
44
59
 
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?
60
+ oversized.each do |key|
61
+ each_batch(redis.zscan_each(key, count: redis_scan_count)) do |members|
62
+ remove_stale(redis, adapter::SUBSCRIPTIONS_PREFIX, [[key, members.map(&:first)]], :zrem)
63
+ end
49
64
  end
50
65
  end
51
66
  end
@@ -55,7 +70,7 @@ module GraphQL
55
70
 
56
71
  def clean_idle_keys(prefix)
57
72
  AnyCable.with_redis do |redis|
58
- each_batch(redis.scan_each(match: "#{redis_key(prefix)}*", count: redis_scan_count)) do |keys|
73
+ each_key_batch(redis, prefix) do |keys|
59
74
  idle_times = redis.pipelined do |pipeline|
60
75
  keys.each { |key| pipeline.object("IDLETIME", key) }
61
76
  end
@@ -66,9 +81,56 @@ module GraphQL
66
81
  end
67
82
  end
68
83
 
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)
84
+ # Splits keys into those small enough to read in one go and those that have to be iterated
85
+ # with a cursor. Most collections hold only a handful of members, and reading many of them
86
+ # per round trip is what keeps cleanup bearable when there are a lot of keys; the rare huge
87
+ # ones still have to be iterated, or they would blow up memory in the process.
88
+ def partition_by_size(redis, keys, size_command)
89
+ sizes = redis.pipelined do |pipeline|
90
+ keys.each { |key| pipeline.public_send(size_command, key) }
91
+ end
92
+
93
+ bulk_readable, oversized = keys.zip(sizes).partition { |_key, size| size <= redis_scan_count }
94
+ [bulk_readable, oversized.map(&:first)]
95
+ end
96
+
97
+ # Yields key chunks whose combined size stays within redis_scan_count.
98
+ def each_capped_chunk(sized_keys)
99
+ chunk, total = [], 0
100
+
101
+ sized_keys.each do |key, size|
102
+ if chunk.any? && total + size > redis_scan_count
103
+ yield chunk
104
+ chunk, total = [], 0
105
+ end
106
+
107
+ chunk << key
108
+ total += size
109
+ end
110
+
111
+ yield chunk if chunk.any?
112
+ end
113
+
114
+ # Removes the members which no longer have a corresponding key in redis, given `[key, members]`
115
+ # pairs for one or more collections. Takes two round trips for the whole lot, however many
116
+ # collections and members it is handed: one for the existence checks and one for the removals.
117
+ def remove_stale(redis, prefix, members_by_key, remove_command)
118
+ pairs = members_by_key.flat_map { |key, members| members.map { |member| [key, member] } }
119
+ return if pairs.empty?
120
+
121
+ stale = missing_key_pairs(redis, prefix, pairs)
122
+ return if stale.empty?
123
+
124
+ redis.pipelined do |pipeline|
125
+ stale.group_by(&:first).each do |key, key_pairs|
126
+ pipeline.public_send(remove_command, key, key_pairs.map(&:last))
127
+ end
128
+ end
129
+ end
130
+
131
+ # Iterates over the keys matching the given prefix in batches, to allow pipelining.
132
+ def each_key_batch(redis, prefix, &block)
133
+ each_batch(redis.scan_each(match: "#{redis_key(prefix)}*", count: redis_scan_count), &block)
72
134
  end
73
135
 
74
136
  # Consumes a lazy enumerator (SCAN family) in batches to keep memory usage bounded.
@@ -76,14 +138,16 @@ module GraphQL
76
138
  enumerator.each_slice(redis_scan_count, &block)
77
139
  end
78
140
 
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)
141
+ # Returns the `[key, member]` pairs whose member has no corresponding key in redis anymore,
142
+ # checking all of them in a single round trip. The member cannot be looked up on its own,
143
+ # since the same member may well appear in more than one collection.
144
+ def missing_key_pairs(redis, prefix, pairs)
81
145
  exists = redis.pipelined do |pipeline|
82
- ids.each { |id| pipeline.exists?(redis_key(prefix) + id) }
146
+ pairs.each { |_key, member| pipeline.exists?(redis_key(prefix) + member) }
83
147
  end
84
148
 
85
- # Reject ids that has corresponding key in Redis in `exists` array of pipelined responses.
86
- ids.reject.with_index { |_id, index| exists[index] }
149
+ # Reject pairs whose member has a corresponding key in Redis in `exists` array of pipelined responses.
150
+ pairs.reject.with_index { |_pair, index| exists[index] }
87
151
  end
88
152
 
89
153
  def adapter
@@ -16,5 +16,9 @@ module GraphQL
16
16
  DEFAULT_MESSAGE
17
17
  end
18
18
  end
19
+
20
+ # This error is raised when a subscription is gone from Redis by the time it is read,
21
+ # so that the caller can tell it from an update GraphQL has deliberately skipped.
22
+ class SubscriptionExpiredError < ::RuntimeError; end
19
23
  end
20
24
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module AnyCable
5
- VERSION = "1.3.2"
5
+ VERSION = "1.3.4"
6
6
  end
7
7
  end
@@ -95,10 +95,20 @@ module GraphQL
95
95
  def execute_grouped(fingerprint, subscription_ids, event, object)
96
96
  return if subscription_ids.empty?
97
97
 
98
- subscription_id = with_redis { |redis| subscription_ids.find { |sid| redis.exists?(redis_key(SUBSCRIPTION_PREFIX) + sid) } }
99
- return unless subscription_id # All subscriptions has expired but haven't cleaned up yet
98
+ result = nil
99
+
100
+ subscription_ids.each do |subscription_id|
101
+ result = execute_update(subscription_id, event, object)
102
+
103
+ # Whatever GraphQL has decided here — including a nil result for NO_UPDATE or
104
+ # for #unsubscribe without a final update — applies to the whole group, as all
105
+ # of its subscriptions share the same fingerprint. So, ask only one of them.
106
+ break
107
+ rescue GraphQL::AnyCable::SubscriptionExpiredError
108
+ # This one is gone, but the rest of the group is still waiting for the update
109
+ next
110
+ end
100
111
 
101
- result = execute_update(subscription_id, event, object)
102
112
  return unless result
103
113
 
104
114
  # Having calculated the result _once_, send the same payload to all subscribers
@@ -127,8 +137,12 @@ module GraphQL
127
137
 
128
138
  raise GraphQL::AnyCable::ChannelConfigurationError unless channel
129
139
 
130
- # Store subscription_id in the channel state to cleanup on disconnect
131
- write_subscription_id(channel, subscription_id)
140
+ # Store the channel's id in its state to cleanup on disconnect. A channel may carry more
141
+ # than one subscription, so the first subscription's id names the channel and every later
142
+ # subscription is added to that same set: otherwise each new subscription would overwrite
143
+ # the stored id, and #delete_channel_subscriptions could only ever find the last one.
144
+ channel_id = read_channel_id(channel) || subscription_id
145
+ write_channel_id(channel, channel_id)
132
146
 
133
147
  events.each do |event|
134
148
  channel.stream_from(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint)
@@ -144,40 +158,48 @@ module GraphQL
144
158
 
145
159
  with_redis do |redis|
146
160
  redis.multi do |pipeline|
147
- pipeline.sadd(redis_key(CHANNEL_PREFIX) + subscription_id, [subscription_id])
161
+ pipeline.sadd(redis_key(CHANNEL_PREFIX) + channel_id, [subscription_id])
148
162
  pipeline.mapped_hmset(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, data)
149
163
  events.each do |event|
150
164
  pipeline.zincrby(redis_key(FINGERPRINTS_PREFIX) + event.topic, 1, event.fingerprint)
151
165
  pipeline.sadd(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint, [subscription_id])
152
166
  end
153
167
  next unless config.subscription_expiration_seconds
154
- pipeline.expire(redis_key(CHANNEL_PREFIX) + subscription_id, config.subscription_expiration_seconds)
168
+ pipeline.expire(redis_key(CHANNEL_PREFIX) + channel_id, config.subscription_expiration_seconds)
155
169
  pipeline.expire(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, config.subscription_expiration_seconds)
156
170
  end
157
171
  end
158
172
  end
159
173
 
160
174
  # Return the query from "storage" (in redis)
175
+ # @raise [GraphQL::AnyCable::SubscriptionExpiredError] if it is not stored anymore
161
176
  def read_subscription(subscription_id)
162
- with_redis do |redis|
177
+ subscription = with_redis do |redis|
163
178
  redis.mapped_hmget(
164
179
  "#{redis_key(SUBSCRIPTION_PREFIX)}#{subscription_id}",
165
180
  :query_string, :variables, :context, :operation_name
166
- ).tap do |subscription|
167
- next if subscription.values.all?(&:nil?) # Redis returns hash with all nils for missing key
168
-
169
- subscription[:context] = @serializer.load(subscription[:context])
170
- subscription[:variables] = JSON.parse(subscription[:variables])
171
- subscription[:operation_name] = nil if subscription[:operation_name].strip == ""
172
- end
181
+ )
173
182
  end
183
+
184
+ # Give the connection back before raising or deserializing: a connector may be
185
+ # backed by a pool, and neither of these needs Redis anymore.
186
+ #
187
+ # Redis returns a hash with nil values for a missing key. A subscription without
188
+ # a query string is unusable anyway, so treat a half-written one as missing, too.
189
+ raise GraphQL::AnyCable::SubscriptionExpiredError, subscription_id if subscription[:query_string].nil?
190
+
191
+ subscription[:context] = @serializer.load(subscription[:context])
192
+ subscription[:variables] = JSON.parse(subscription[:variables])
193
+ subscription[:operation_name] = nil if subscription[:operation_name].to_s.strip == ""
194
+
195
+ subscription
174
196
  end
175
197
 
176
198
  # The channel was closed, forget about it and its subscriptions
177
199
  def delete_channel_subscriptions(channel)
178
200
  raise(ArgumentError, "Please pass channel instance to #{__method__} in your #unsubscribed method") if channel.is_a?(String)
179
201
 
180
- channel_id = read_subscription_id(channel)
202
+ channel_id = read_channel_id(channel)
181
203
 
182
204
  # Missing in case disconnect happens before #execute
183
205
  return unless channel_id
@@ -190,7 +212,9 @@ module GraphQL
190
212
  end
191
213
  end
192
214
 
193
- def delete_subscription(subscription_id, redis: AnyCable.redis)
215
+ def delete_subscription(subscription_id, redis: nil)
216
+ return with_redis { |connection| delete_subscription(subscription_id, redis: connection) } unless redis
217
+
194
218
  events = redis.hget(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, :events)
195
219
  events = events ? JSON.parse(events) : {}
196
220
  fingerprint_subscriptions = {}
@@ -213,7 +237,8 @@ module GraphQL
213
237
 
214
238
  private
215
239
 
216
- def read_subscription_id(channel)
240
+ # Channel cleanup key kept in AnyCable istate as "sid"
241
+ def read_channel_id(channel)
217
242
  return channel.instance_variable_get(:@__sid__) if channel.instance_variable_defined?(:@__sid__)
218
243
 
219
244
  istate = fetch_channel_istate(channel)
@@ -223,7 +248,7 @@ module GraphQL
223
248
  channel.instance_variable_set(:@__sid__, istate["sid"])
224
249
  end
225
250
 
226
- def write_subscription_id(channel, val)
251
+ def write_channel_id(channel, val)
227
252
  channel.connection.anycable_socket.istate["sid"] = val
228
253
  channel.instance_variable_set(:@__sid__, val)
229
254
  end
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.2
4
+ version: 1.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Novikov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-05 00:00:00.000000000 Z
11
+ date: 2026-08-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: anycable-core