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 +4 -4
- data/.github/workflows/test.yml +7 -3
- data/CHANGELOG.md +23 -0
- data/README.md +3 -2
- data/lib/graphql/anycable/cleaner.rb +83 -19
- data/lib/graphql/anycable/errors.rb +4 -0
- data/lib/graphql/anycable/version.rb +1 -1
- data/lib/graphql/subscriptions/anycable_subscriptions.rb +44 -19
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d1012a281016a5fdd02aa9a1decc2d3935f545fef5899c15505dfeef64c9628
|
|
4
|
+
data.tar.gz: ae8f4ff4cb25da208b31b8a257f60307bf66fe626362741ea29705c97bcab8e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3c37a74df3ffc723b7b2fcf7bbfc009b3d67df79275d1c027b22bec3c9f45d6b3a5896b270582c50fc0deb78e38c1f7bc4f38a03288aa941ac20ed584538a3c8
|
|
7
|
+
data.tar.gz: 7dff7ea7638ee30a09bbc889648db4e0ebf4a12ba2798f215b013f29bb92e146241b0a9e4cad167c4691fbe15141ef085966f4799d708352ed1729628c97d634
|
data/.github/workflows/test.yml
CHANGED
|
@@ -19,13 +19,17 @@ jobs:
|
|
|
19
19
|
matrix:
|
|
20
20
|
include:
|
|
21
21
|
- ruby: "4.0"
|
|
22
|
-
graphql: '~> 2.
|
|
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:
|
|
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:#{
|
|
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:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
80
|
-
|
|
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
|
-
|
|
146
|
+
pairs.each { |_key, member| pipeline.exists?(redis_key(prefix) + member) }
|
|
83
147
|
end
|
|
84
148
|
|
|
85
|
-
# Reject
|
|
86
|
-
|
|
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
|
|
@@ -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
|
-
|
|
99
|
-
|
|
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
|
|
131
|
-
|
|
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) +
|
|
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) +
|
|
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
|
-
)
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-08-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: anycable-core
|