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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14133c519749fc9821bc5e9af2c28978b620afe77a7ec03cb26a51f41ef56c66
4
- data.tar.gz: c8f8ee0f3c378bc72b80f4df1623e2ab402dc379097af315422b742abe09e17b
3
+ metadata.gz: ba22f385767198acc338809a6b60237248dfc15ecdd2f279a31628e6dad013e8
4
+ data.tar.gz: 6e59e6c8119969ee8358219b080abecb05df38d6ffe5549adbc58869f214872d
5
5
  SHA512:
6
- metadata.gz: 667c1ecce687b40287dbd930144480cc7a14e4e663ca2ab7cd29538b586d4b2e3d4fdae38a0d3b4f37187ef7d2d25aa7b1f6112d89d79db481efc79f46d49c01
7
- data.tar.gz: fc431ad886ce163e17bd1d257491f7964fd1140ebe89365ecdca7c7f5d66c661408c51bea51a3e36bc26c1b380d421b5d7d8ab0814fb6a94bd1a0ead45bad595
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:#{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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module AnyCable
5
- VERSION = "1.3.2"
5
+ VERSION = "1.3.3"
6
6
  end
7
7
  end
@@ -127,8 +127,12 @@ module GraphQL
127
127
 
128
128
  raise GraphQL::AnyCable::ChannelConfigurationError unless channel
129
129
 
130
- # Store subscription_id in the channel state to cleanup on disconnect
131
- write_subscription_id(channel, subscription_id)
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) + subscription_id, [subscription_id])
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) + subscription_id, config.subscription_expiration_seconds)
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 = read_subscription_id(channel)
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
- def read_subscription_id(channel)
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 write_subscription_id(channel, val)
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.2
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-05 00:00:00.000000000 Z
11
+ date: 2026-08-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: anycable-core