faye-redis-ng 1.0.9 → 1.0.11

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: 5a2266042c42929dc9203cf1160bbfd64d429c87cdb076b3369cd55bbf77032e
4
+ data.tar.gz: 51509e6fb5fb775bfbdcd4db0dada3dfa26f6bee5f5729d2b3fa165b5b417644
5
5
  SHA512:
6
- metadata.gz: c2a3f9d350a9aeab02bcfc6589a6f3e81e2973396d54397510d46787e1d7cde81de064a97f44428bf3534f266ccda104ae1535fd8108197e0862aeae1c28b71e
7
- data.tar.gz: 4f41d51dae3a17b7d44171384f6a2646ac7492ae68c4f85845feac100f20d59f609105ebfc90d5fdffd415e893cb3e7556bb4ade191da84d867e16a90e8bf664
6
+ metadata.gz: 79c6d4bee6b55572a772a4977fd5b5a5857746c415bac1f6f0a802ad69b16e137642b90d20ec02ec21abb95f69e5fab9ab830f018c6aaa974ad56b2c3202f269
7
+ data.tar.gz: 38c29ca6cd4330c5a862951b2810e8e095e4dec2939691bd8379ef714f947ad05a0e875bcf55a53de42d35913357d2f05c38f08b0ce16da12e553e2c7a22f1b1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.11] - 2025-10-31
11
+
12
+ ### Changed - Optimized Subscription TTL
13
+ - **Reduced subscription_ttl from 24 hours to 5 minutes**: More aggressive cleanup of orphaned subscription data
14
+ - **Previous**: `subscription_ttl: 86400` (24 hours)
15
+ - **New**: `subscription_ttl: 300` (5 minutes = 5x client_timeout)
16
+ - **Rationale**: Client keys expire after 60 seconds, keeping subscription data for 24 hours was excessive
17
+ - **Impact**: Faster memory reclamation for disconnected clients while maintaining safety net
18
+ - **Benefits**:
19
+ - Reduces memory footprint by cleaning up orphaned subscriptions faster
20
+ - Maintains 5-minute safety window (5 GC cycles) for proper cleanup
21
+ - Allows short-lived reconnections within 5 minutes to reuse subscription data
22
+ - **Affected keys**:
23
+ - `{namespace}:subscriptions:{client_id}` (SET)
24
+ - `{namespace}:channels:{channel}` (SET)
25
+ - `{namespace}:subscription:{client_id}:{channel}` (Hash)
26
+ - `{namespace}:patterns` (SET)
27
+ - **Backward compatibility**: Users can still override with custom `subscription_ttl` option
28
+
29
+ ### Notes
30
+ - This change is safe because automatic GC runs every 60 seconds by default
31
+ - The 5-minute TTL provides sufficient buffer (5 GC cycles) for cleanup
32
+ - TTL-safe implementation (v1.0.10) ensures active subscriptions maintain their original TTL
33
+
34
+ ## [1.0.10] - 2025-10-30
35
+
36
+ ### Fixed - Critical Memory Leak (P0 - Critical Priority)
37
+ - **TTL Reset on Every Operation**: Fixed message queue and subscription TTL being reset on every operation
38
+ - **Problem**: `EXPIRE` was called on every `enqueue` and `subscribe`, resetting TTL to full duration
39
+ - **Impact**: Hot/active queues and subscriptions never expired, causing unbounded memory growth
40
+ - **Solution**: Implemented Lua scripts to only set TTL if key has no TTL (TTL == -1)
41
+ - Message queues: `enqueue` now checks TTL before setting expiration
42
+ - Subscriptions: `subscribe` now checks TTL before setting expiration on all keys:
43
+ - `faye:subscriptions:{client_id}` (SET)
44
+ - `faye:channels:{channel}` (SET)
45
+ - `faye:subscription:{client_id}:{channel}` (Hash)
46
+ - `faye:patterns` (SET)
47
+ - **Impact**: Prevents memory leak for active clients with frequent messages/re-subscriptions
48
+
49
+ - **Orphaned Message Queue Cleanup**: Added dedicated cleanup for orphaned message queues
50
+ - Added `cleanup_orphaned_message_queues_async` to scan and remove orphaned message queues
51
+ - Added `cleanup_message_queues_batched` for batched deletion with EventMachine yielding
52
+ - Integrated into `cleanup_orphaned_data` workflow (Phase 3)
53
+ - **Impact**: Ensures message queues are cleaned up even if subscription cleanup misses them
54
+
55
+ ### Added
56
+ - Lua script-based TTL management for atomic operations
57
+ - Comprehensive TTL behavior tests (3 new tests):
58
+ - `sets TTL on first enqueue`
59
+ - `does not reset TTL on subsequent enqueues`
60
+ - `sets TTL again after queue expires and is recreated`
61
+
62
+ ### Changed
63
+ - `MessageQueue#enqueue`: Uses Lua script to prevent TTL reset
64
+ - `SubscriptionManager#subscribe`: Uses Lua script to prevent TTL reset on all subscription keys
65
+ - `SubscriptionManager#cleanup_orphaned_data`: Added Phase 3 for message queue cleanup
66
+
67
+ ### Technical Details
68
+ **Lua Script Approach**:
69
+ ```lua
70
+ redis.call('RPUSH', KEYS[1], ARGV[1])
71
+ local ttl = redis.call('TTL', KEYS[1])
72
+ if ttl == -1 then -- Only set if no TTL exists
73
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
74
+ end
75
+ ```
76
+
77
+ **Test Coverage**: 213 examples, 0 failures, 87.22% line coverage
78
+
10
79
  ## [1.0.9] - 2025-10-30
11
80
 
12
81
  ### Fixed - Concurrency Issues (P1 - High Priority)
@@ -116,9 +185,12 @@ end
116
185
  ```
117
186
 
118
187
  ### Test Coverage
119
- - All 177 tests passing
120
- - Line Coverage: 85.77%
121
- - Branch Coverage: 55.04%
188
+ - **210 tests passing** (+33 new tests, +18.6%)
189
+ - **Line Coverage: 89.69%** (+3.92% from v1.0.8)
190
+ - **Branch Coverage: 60.08%** (+5.04% from v1.0.8)
191
+ - Added comprehensive tests for all P1/P2 fixes
192
+ - Added edge case and error handling tests
193
+ - All new features have corresponding test coverage
122
194
 
123
195
  ### Upgrade Notes
124
196
  This release includes important concurrency and performance fixes. Recommended for all users, especially:
data/README.md CHANGED
@@ -80,6 +80,7 @@ bayeux = Faye::RackAdapter.new(app, {
80
80
  # Data expiration
81
81
  client_timeout: 60, # Client session timeout (seconds)
82
82
  message_ttl: 3600, # Message TTL (seconds)
83
+ subscription_ttl: 300, # Subscription keys TTL (seconds, default: 5 minutes)
83
84
 
84
85
  # Garbage collection
85
86
  gc_interval: 60, # Automatic GC interval (seconds), set to 0 or false to disable
@@ -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
@@ -12,36 +12,50 @@ module Faye
12
12
  # Subscribe a client to a channel
13
13
  def subscribe(client_id, channel, &callback)
14
14
  timestamp = Time.now.to_i
15
- subscription_ttl = @options[:subscription_ttl] || 86400 # 24 hours default
15
+ subscription_ttl = @options[:subscription_ttl] || 300 # 5 minutes default (5x client_timeout)
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)
16
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.11'
4
4
  end
5
5
  end
data/lib/faye/redis.rb CHANGED
@@ -25,7 +25,7 @@ module Faye
25
25
  retry_delay: 1,
26
26
  client_timeout: 60,
27
27
  message_ttl: 3600,
28
- subscription_ttl: 86400, # Subscription keys TTL (24 hours), provides safety net if GC fails
28
+ subscription_ttl: 300, # Subscription keys TTL (5 minutes = 5x client_timeout), provides safety net if GC fails
29
29
  namespace: 'faye',
30
30
  gc_interval: 60, # Automatic garbage collection interval (seconds), set to 0 or false to disable
31
31
  cleanup_batch_size: 50 # Number of items per batch during cleanup (min: 1, max: 1000, prevents blocking)
metadata CHANGED
@@ -1,14 +1,14 @@
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.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-10-30 00:00:00.000000000 Z
11
+ date: 2025-10-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis