faye-redis-ng 1.0.9 → 1.0.10

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: cd86d6fdb530405ff0dd146705d45d9c665cfa9f778987ead89d4bb497db06eb
4
- data.tar.gz: ddc3a783e0007e452d69eb97e6f2540a089427420119a4414e91281e1b4a9d9d
3
+ metadata.gz: 055d9be802f284752c5ae672d4d11c9121ef023739e17508e9957ef5d6880df7
4
+ data.tar.gz: 1119068a05ad0d0dbfffdd7d92cb1b882d9f98308f2a868818e341e01ed21139
5
5
  SHA512:
6
- metadata.gz: c2a3f9d350a9aeab02bcfc6589a6f3e81e2973396d54397510d46787e1d7cde81de064a97f44428bf3534f266ccda104ae1535fd8108197e0862aeae1c28b71e
7
- data.tar.gz: 4f41d51dae3a17b7d44171384f6a2646ac7492ae68c4f85845feac100f20d59f609105ebfc90d5fdffd415e893cb3e7556bb4ade191da84d867e16a90e8bf664
6
+ metadata.gz: 1e55a67832698a6969390882eaf687c7829de4f70907bc33e796103f9336cc403879bf6721f052611c3073f64650822ef7d12e8acc13a3ec1f6b55b9f11b1810
7
+ data.tar.gz: 9a46f1bd0136923f320d723cf96cf7e64cd7193f9f4c2e19704bed4b268e180892b1ae54427a5888516816597d9ee77170a82cb6784203eb22ba5c11cd456639
data/CHANGELOG.md CHANGED
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.10] - 2025-10-30
11
+
12
+ ### Fixed - Critical Memory Leak (P0 - Critical Priority)
13
+ - **TTL Reset on Every Operation**: Fixed message queue and subscription TTL being reset on every operation
14
+ - **Problem**: `EXPIRE` was called on every `enqueue` and `subscribe`, resetting TTL to full duration
15
+ - **Impact**: Hot/active queues and subscriptions never expired, causing unbounded memory growth
16
+ - **Solution**: Implemented Lua scripts to only set TTL if key has no TTL (TTL == -1)
17
+ - Message queues: `enqueue` now checks TTL before setting expiration
18
+ - Subscriptions: `subscribe` now checks TTL before setting expiration on all keys:
19
+ - `faye:subscriptions:{client_id}` (SET)
20
+ - `faye:channels:{channel}` (SET)
21
+ - `faye:subscription:{client_id}:{channel}` (Hash)
22
+ - `faye:patterns` (SET)
23
+ - **Impact**: Prevents memory leak for active clients with frequent messages/re-subscriptions
24
+
25
+ - **Orphaned Message Queue Cleanup**: Added dedicated cleanup for orphaned message queues
26
+ - Added `cleanup_orphaned_message_queues_async` to scan and remove orphaned message queues
27
+ - Added `cleanup_message_queues_batched` for batched deletion with EventMachine yielding
28
+ - Integrated into `cleanup_orphaned_data` workflow (Phase 3)
29
+ - **Impact**: Ensures message queues are cleaned up even if subscription cleanup misses them
30
+
31
+ ### Added
32
+ - Lua script-based TTL management for atomic operations
33
+ - Comprehensive TTL behavior tests (3 new tests):
34
+ - `sets TTL on first enqueue`
35
+ - `does not reset TTL on subsequent enqueues`
36
+ - `sets TTL again after queue expires and is recreated`
37
+
38
+ ### Changed
39
+ - `MessageQueue#enqueue`: Uses Lua script to prevent TTL reset
40
+ - `SubscriptionManager#subscribe`: Uses Lua script to prevent TTL reset on all subscription keys
41
+ - `SubscriptionManager#cleanup_orphaned_data`: Added Phase 3 for message queue cleanup
42
+
43
+ ### Technical Details
44
+ **Lua Script Approach**:
45
+ ```lua
46
+ redis.call('RPUSH', KEYS[1], ARGV[1])
47
+ local ttl = redis.call('TTL', KEYS[1])
48
+ if ttl == -1 then -- Only set if no TTL exists
49
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
50
+ end
51
+ ```
52
+
53
+ **Test Coverage**: 213 examples, 0 failures, 87.22% line coverage
54
+
10
55
  ## [1.0.9] - 2025-10-30
11
56
 
12
57
  ### Fixed - Concurrency Issues (P1 - High Priority)
@@ -116,9 +161,12 @@ end
116
161
  ```
117
162
 
118
163
  ### Test Coverage
119
- - All 177 tests passing
120
- - Line Coverage: 85.77%
121
- - Branch Coverage: 55.04%
164
+ - **210 tests passing** (+33 new tests, +18.6%)
165
+ - **Line Coverage: 89.69%** (+3.92% from v1.0.8)
166
+ - **Branch Coverage: 60.08%** (+5.04% from v1.0.8)
167
+ - Added comprehensive tests for all P1/P2 fixes
168
+ - Added edge case and error handling tests
169
+ - All new features have corresponding test coverage
122
170
 
123
171
  ### Upgrade Notes
124
172
  This release includes important concurrency and performance fixes. Recommended for all users, especially:
@@ -19,15 +19,19 @@ module Faye
19
19
 
20
20
  # Store message directly as JSON
21
21
  message_json = message_with_id.to_json
22
+ key = queue_key(client_id)
22
23
 
23
24
  @connection.with_redis do |redis|
24
- # Use RPUSH with EXPIRE in a single pipeline
25
- redis.pipelined do |pipeline|
26
- # Add message to client's queue
27
- pipeline.rpush(queue_key(client_id), message_json)
28
- # Set TTL on queue (only if it doesn't already have one)
29
- pipeline.expire(queue_key(client_id), message_ttl)
30
- end
25
+ # Use Lua script to atomically RPUSH and set TTL only if key has no TTL
26
+ # This prevents resetting TTL on every enqueue for hot queues
27
+ redis.eval(<<~LUA, keys: [key], argv: [message_json, message_ttl.to_s])
28
+ redis.call('RPUSH', KEYS[1], ARGV[1])
29
+ local ttl = redis.call('TTL', KEYS[1])
30
+ if ttl == -1 then
31
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
32
+ end
33
+ return 1
34
+ LUA
31
35
  end
32
36
 
33
37
  EventMachine.next_tick { callback.call(true) } if callback
@@ -14,34 +14,48 @@ module Faye
14
14
  timestamp = Time.now.to_i
15
15
  subscription_ttl = @options[:subscription_ttl] || 86400 # 24 hours default
16
16
 
17
+ client_subs_key = client_subscriptions_key(client_id)
18
+ channel_subs_key = channel_subscribers_key(channel)
19
+ sub_key = subscription_key(client_id, channel)
20
+
17
21
  @connection.with_redis do |redis|
18
- redis.multi do |multi|
19
- # Add channel to client's subscriptions
20
- multi.sadd?(client_subscriptions_key(client_id), channel)
21
- # Set/refresh TTL for client subscriptions list
22
- multi.expire(client_subscriptions_key(client_id), subscription_ttl)
23
-
24
- # Add client to channel's subscribers
25
- multi.sadd?(channel_subscribers_key(channel), client_id)
26
- # Set/refresh TTL for channel subscribers list
27
- multi.expire(channel_subscribers_key(channel), subscription_ttl)
28
-
29
- # Store subscription metadata
30
- multi.hset(
31
- subscription_key(client_id, channel),
32
- 'subscribed_at', timestamp,
33
- 'channel', channel,
34
- 'client_id', client_id
35
- )
36
- # Set TTL for subscription metadata
37
- multi.expire(subscription_key(client_id, channel), subscription_ttl)
38
-
39
- # Handle wildcard patterns
40
- if channel.include?('*')
41
- multi.sadd?(patterns_key, channel)
42
- # Set/refresh TTL for patterns set
43
- multi.expire(patterns_key, subscription_ttl)
22
+ # Use Lua script to atomically add subscriptions and set TTL only if keys have no TTL
23
+ # This prevents resetting TTL on re-subscription
24
+ redis.eval(<<-LUA, keys: [client_subs_key, channel_subs_key, sub_key], argv: [channel, client_id, timestamp.to_s, subscription_ttl])
25
+ -- Add channel to client's subscriptions
26
+ redis.call('SADD', KEYS[1], ARGV[1])
27
+ local ttl1 = redis.call('TTL', KEYS[1])
28
+ if ttl1 == -1 then
29
+ redis.call('EXPIRE', KEYS[1], ARGV[4])
30
+ end
31
+
32
+ -- Add client to channel's subscribers
33
+ redis.call('SADD', KEYS[2], ARGV[2])
34
+ local ttl2 = redis.call('TTL', KEYS[2])
35
+ if ttl2 == -1 then
36
+ redis.call('EXPIRE', KEYS[2], ARGV[4])
44
37
  end
38
+
39
+ -- Store subscription metadata
40
+ redis.call('HSET', KEYS[3], 'subscribed_at', ARGV[3], 'channel', ARGV[1], 'client_id', ARGV[2])
41
+ local ttl3 = redis.call('TTL', KEYS[3])
42
+ if ttl3 == -1 then
43
+ redis.call('EXPIRE', KEYS[3], ARGV[4])
44
+ end
45
+
46
+ return 1
47
+ LUA
48
+
49
+ # Handle wildcard patterns separately
50
+ if channel.include?('*')
51
+ redis.eval(<<-LUA, keys: [patterns_key], argv: [channel, subscription_ttl])
52
+ redis.call('SADD', KEYS[1], ARGV[1])
53
+ local ttl = redis.call('TTL', KEYS[1])
54
+ if ttl == -1 then
55
+ redis.call('EXPIRE', KEYS[1], ARGV[2])
56
+ end
57
+ return 1
58
+ LUA
45
59
  end
46
60
  end
47
61
 
@@ -207,11 +221,14 @@ module Faye
207
221
  scan_orphaned_subscriptions(active_set, namespace) do |orphaned_subscriptions|
208
222
  # Phase 2: Clean up orphaned subscriptions in batches
209
223
  cleanup_orphaned_subscriptions_batched(orphaned_subscriptions, namespace, batch_size) do
210
- # Phase 3: Clean up empty channels (yields between operations)
211
- cleanup_empty_channels_async(namespace) do
212
- # Phase 4: Clean up unused patterns
213
- cleanup_unused_patterns_async do
214
- callback.call if callback
224
+ # Phase 3: Clean up orphaned message queues
225
+ cleanup_orphaned_message_queues_async(active_set, namespace, batch_size) do
226
+ # Phase 4: Clean up empty channels (yields between operations)
227
+ cleanup_empty_channels_async(namespace) do
228
+ # Phase 5: Clean up unused patterns
229
+ cleanup_unused_patterns_async do
230
+ callback.call if callback
231
+ end
215
232
  end
216
233
  end
217
234
  end
@@ -305,6 +322,80 @@ module Faye
305
322
  EventMachine.next_tick { callback.call }
306
323
  end
307
324
 
325
+ # Clean up orphaned message queues for non-existent clients
326
+ # Scans for message queues that belong to clients not in the active set
327
+ def cleanup_orphaned_message_queues_async(active_set, namespace, batch_size, &callback)
328
+ orphaned_queues = []
329
+
330
+ # Batch scan to avoid holding connection
331
+ scan_batch = lambda do |cursor_value|
332
+ begin
333
+ @connection.with_redis do |redis|
334
+ cursor, keys = redis.scan(cursor_value, match: "#{namespace}:messages:*", count: 100)
335
+
336
+ keys.each do |key|
337
+ client_id = key.split(':').last
338
+ orphaned_queues << key unless active_set.include?(client_id)
339
+ end
340
+
341
+ if cursor == "0"
342
+ # Scan complete, now clean up in batches
343
+ if orphaned_queues.any?
344
+ cleanup_message_queues_batched(orphaned_queues, batch_size) do
345
+ EventMachine.next_tick { callback.call }
346
+ end
347
+ else
348
+ EventMachine.next_tick { callback.call }
349
+ end
350
+ else
351
+ # Continue scanning
352
+ EventMachine.next_tick { scan_batch.call(cursor) }
353
+ end
354
+ end
355
+ rescue => e
356
+ log_error("Failed to scan orphaned message queues: #{e.message}")
357
+ EventMachine.next_tick { callback.call }
358
+ end
359
+ end
360
+
361
+ scan_batch.call("0")
362
+ rescue => e
363
+ log_error("Failed to cleanup orphaned message queues: #{e.message}")
364
+ EventMachine.next_tick { callback.call }
365
+ end
366
+
367
+ # Delete message queues in batches
368
+ def cleanup_message_queues_batched(queue_keys, batch_size, &callback)
369
+ return EventMachine.next_tick { callback.call } if queue_keys.empty?
370
+
371
+ total = queue_keys.size
372
+ batches = queue_keys.each_slice(batch_size).to_a
373
+
374
+ process_batch = lambda do |batch_index|
375
+ if batch_index >= batches.size
376
+ puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{total} orphaned message queues" if @options[:log_level] != :silent
377
+ EventMachine.next_tick { callback.call }
378
+ return
379
+ end
380
+
381
+ batch = batches[batch_index]
382
+
383
+ @connection.with_redis do |redis|
384
+ redis.pipelined do |pipeline|
385
+ batch.each { |key| pipeline.del(key) }
386
+ end
387
+ end
388
+
389
+ # Yield control between batches
390
+ EventMachine.next_tick { process_batch.call(batch_index + 1) }
391
+ end
392
+
393
+ process_batch.call(0)
394
+ rescue => e
395
+ log_error("Failed to cleanup message queues batch: #{e.message}")
396
+ EventMachine.next_tick { callback.call }
397
+ end
398
+
308
399
  # Async version of cleanup_empty_channels that yields between operations
309
400
  def cleanup_empty_channels_async(namespace, &callback)
310
401
  @connection.with_redis do |redis|
@@ -1,5 +1,5 @@
1
1
  module Faye
2
2
  class Redis
3
- VERSION = '1.0.9'
3
+ VERSION = '1.0.10'
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: faye-redis-ng
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.9
4
+ version: 1.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac