graphql-anycable 1.3.2 → 1.3.3
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/CHANGELOG.md +8 -0
- data/README.md +3 -2
- data/lib/graphql/anycable/cleaner.rb +83 -19
- data/lib/graphql/anycable/version.rb +1 -1
- data/lib/graphql/subscriptions/anycable_subscriptions.rb +12 -7
- 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: ba22f385767198acc338809a6b60237248dfc15ecdd2f279a31628e6dad013e8
|
|
4
|
+
data.tar.gz: 6e59e6c8119969ee8358219b080abecb05df38d6ffe5549adbc58869f214872d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3479edd8cf0548d649ca3a0aaaeea728a8030c22342b50cb41beacca14b2ab7391bc9fc1cd2783ca861a07c8049b4ef1256e735fc97468469372213a822c9201
|
|
7
|
+
data.tar.gz: f6595e5f63dd71d375ec8d90816ae102839a8fda29bfc3f3400af10dedbcbf0cd4fb587d1462233b1a1b733801dfa5af8f172fc1f0906c4b4ba4db6e5250eebf
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 1.3.3 - 2026-08-10
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- 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))
|
|
15
|
+
|
|
16
|
+
- Further optimize cleanup tasks with pipelining their reads and removals across keys. [@jjb] ([#57](https://github.com/anycable/graphql-anycable/pull/57))
|
|
17
|
+
|
|
10
18
|
## 1.3.2 - 2026-08-05
|
|
11
19
|
|
|
12
20
|
### Fixed
|
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
|
|
@@ -127,8 +127,12 @@ module GraphQL
|
|
|
127
127
|
|
|
128
128
|
raise GraphQL::AnyCable::ChannelConfigurationError unless channel
|
|
129
129
|
|
|
130
|
-
# Store
|
|
131
|
-
|
|
130
|
+
# Store the channel's id in its state to cleanup on disconnect. A channel may carry more
|
|
131
|
+
# than one subscription, so the first subscription's id names the channel and every later
|
|
132
|
+
# subscription is added to that same set: otherwise each new subscription would overwrite
|
|
133
|
+
# the stored id, and #delete_channel_subscriptions could only ever find the last one.
|
|
134
|
+
channel_id = read_channel_id(channel) || subscription_id
|
|
135
|
+
write_channel_id(channel, channel_id)
|
|
132
136
|
|
|
133
137
|
events.each do |event|
|
|
134
138
|
channel.stream_from(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint)
|
|
@@ -144,14 +148,14 @@ module GraphQL
|
|
|
144
148
|
|
|
145
149
|
with_redis do |redis|
|
|
146
150
|
redis.multi do |pipeline|
|
|
147
|
-
pipeline.sadd(redis_key(CHANNEL_PREFIX) +
|
|
151
|
+
pipeline.sadd(redis_key(CHANNEL_PREFIX) + channel_id, [subscription_id])
|
|
148
152
|
pipeline.mapped_hmset(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, data)
|
|
149
153
|
events.each do |event|
|
|
150
154
|
pipeline.zincrby(redis_key(FINGERPRINTS_PREFIX) + event.topic, 1, event.fingerprint)
|
|
151
155
|
pipeline.sadd(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint, [subscription_id])
|
|
152
156
|
end
|
|
153
157
|
next unless config.subscription_expiration_seconds
|
|
154
|
-
pipeline.expire(redis_key(CHANNEL_PREFIX) +
|
|
158
|
+
pipeline.expire(redis_key(CHANNEL_PREFIX) + channel_id, config.subscription_expiration_seconds)
|
|
155
159
|
pipeline.expire(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, config.subscription_expiration_seconds)
|
|
156
160
|
end
|
|
157
161
|
end
|
|
@@ -177,7 +181,7 @@ module GraphQL
|
|
|
177
181
|
def delete_channel_subscriptions(channel)
|
|
178
182
|
raise(ArgumentError, "Please pass channel instance to #{__method__} in your #unsubscribed method") if channel.is_a?(String)
|
|
179
183
|
|
|
180
|
-
channel_id =
|
|
184
|
+
channel_id = read_channel_id(channel)
|
|
181
185
|
|
|
182
186
|
# Missing in case disconnect happens before #execute
|
|
183
187
|
return unless channel_id
|
|
@@ -213,7 +217,8 @@ module GraphQL
|
|
|
213
217
|
|
|
214
218
|
private
|
|
215
219
|
|
|
216
|
-
|
|
220
|
+
# Channel cleanup key kept in AnyCable istate as "sid"
|
|
221
|
+
def read_channel_id(channel)
|
|
217
222
|
return channel.instance_variable_get(:@__sid__) if channel.instance_variable_defined?(:@__sid__)
|
|
218
223
|
|
|
219
224
|
istate = fetch_channel_istate(channel)
|
|
@@ -223,7 +228,7 @@ module GraphQL
|
|
|
223
228
|
channel.instance_variable_set(:@__sid__, istate["sid"])
|
|
224
229
|
end
|
|
225
230
|
|
|
226
|
-
def
|
|
231
|
+
def write_channel_id(channel, val)
|
|
227
232
|
channel.connection.anycable_socket.istate["sid"] = val
|
|
228
233
|
channel.instance_variable_set(:@__sid__, val)
|
|
229
234
|
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.3
|
|
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-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: anycable-core
|