faye-redis-ng 1.0.8 → 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 +4 -4
 - data/CHANGELOG.md +170 -0
 - data/lib/faye/redis/message_queue.rb +11 -7
 - data/lib/faye/redis/pubsub_coordinator.rb +1 -0
 - data/lib/faye/redis/subscription_manager.rb +180 -52
 - data/lib/faye/redis/version.rb +1 -1
 - data/lib/faye/redis.rb +1 -1
 - metadata +1 -1
 
    
        checksums.yaml
    CHANGED
    
    | 
         @@ -1,7 +1,7 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            ---
         
     | 
| 
       2 
2 
     | 
    
         
             
            SHA256:
         
     | 
| 
       3 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       4 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 3 
     | 
    
         
            +
              metadata.gz: 055d9be802f284752c5ae672d4d11c9121ef023739e17508e9957ef5d6880df7
         
     | 
| 
      
 4 
     | 
    
         
            +
              data.tar.gz: 1119068a05ad0d0dbfffdd7d92cb1b882d9f98308f2a868818e341e01ed21139
         
     | 
| 
       5 
5 
     | 
    
         
             
            SHA512:
         
     | 
| 
       6 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       7 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 6 
     | 
    
         
            +
              metadata.gz: 1e55a67832698a6969390882eaf687c7829de4f70907bc33e796103f9336cc403879bf6721f052611c3073f64650822ef7d12e8acc13a3ec1f6b55b9f11b1810
         
     | 
| 
      
 7 
     | 
    
         
            +
              data.tar.gz: 9a46f1bd0136923f320d723cf96cf7e64cd7193f9f4c2e19704bed4b268e180892b1ae54427a5888516816597d9ee77170a82cb6784203eb22ba5c11cd456639
         
     | 
    
        data/CHANGELOG.md
    CHANGED
    
    | 
         @@ -7,6 +7,176 @@ 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 
     | 
    
         
            +
             
     | 
| 
      
 55 
     | 
    
         
            +
            ## [1.0.9] - 2025-10-30
         
     | 
| 
      
 56 
     | 
    
         
            +
             
     | 
| 
      
 57 
     | 
    
         
            +
            ### Fixed - Concurrency Issues (P1 - High Priority)
         
     | 
| 
      
 58 
     | 
    
         
            +
            - **`unsubscribe_all` Race Condition**: Fixed callback being called multiple times
         
     | 
| 
      
 59 
     | 
    
         
            +
              - Added `callback_called` flag to prevent duplicate callback invocations
         
     | 
| 
      
 60 
     | 
    
         
            +
              - Multiple async unsubscribe operations could trigger callback simultaneously
         
     | 
| 
      
 61 
     | 
    
         
            +
              - **Impact**: Eliminates duplicate cleanup operations in high-concurrency scenarios
         
     | 
| 
      
 62 
     | 
    
         
            +
             
     | 
| 
      
 63 
     | 
    
         
            +
            - **Reconnect Counter Not Reset**: Fixed `@reconnect_attempts` not resetting on disconnect
         
     | 
| 
      
 64 
     | 
    
         
            +
              - Added counter reset in `PubSubCoordinator#disconnect` method
         
     | 
| 
      
 65 
     | 
    
         
            +
              - Prevents incorrect exponential backoff after disconnect/reconnect cycles
         
     | 
| 
      
 66 
     | 
    
         
            +
              - **Impact**: Ensures proper reconnection behavior after manual disconnects
         
     | 
| 
      
 67 
     | 
    
         
            +
             
     | 
| 
      
 68 
     | 
    
         
            +
            - **SCAN Connection Pool Blocking**: Optimized long-running SCAN operations
         
     | 
| 
      
 69 
     | 
    
         
            +
              - Changed `scan_orphaned_subscriptions` to batch scanning with connection release
         
     | 
| 
      
 70 
     | 
    
         
            +
              - Each SCAN iteration now releases connection via `EventMachine.next_tick`
         
     | 
| 
      
 71 
     | 
    
         
            +
              - Prevents holding Redis connection for 10-30 seconds with large datasets
         
     | 
| 
      
 72 
     | 
    
         
            +
              - **Impact**: Eliminates connection pool exhaustion during cleanup of 100K+ keys
         
     | 
| 
      
 73 
     | 
    
         
            +
             
     | 
| 
      
 74 
     | 
    
         
            +
            ### Fixed - Performance Issues (P2 - Medium Priority)
         
     | 
| 
      
 75 
     | 
    
         
            +
            - **Pattern Regex Compilation Overhead**: Added regex pattern caching
         
     | 
| 
      
 76 
     | 
    
         
            +
              - Implemented `@pattern_cache` to memoize compiled regular expressions
         
     | 
| 
      
 77 
     | 
    
         
            +
              - Cache is automatically cleared when patterns are removed
         
     | 
| 
      
 78 
     | 
    
         
            +
              - Prevents recompiling same regex for every pattern match
         
     | 
| 
      
 79 
     | 
    
         
            +
              - **Impact**: 20% CPU reduction with 100 patterns at 1000 msg/sec (100K → 0 regex compilations/sec)
         
     | 
| 
      
 80 
     | 
    
         
            +
             
     | 
| 
      
 81 
     | 
    
         
            +
            - **Pattern Regex Injection Risk**: Fixed special character handling in patterns
         
     | 
| 
      
 82 
     | 
    
         
            +
              - Added `Regexp.escape` before wildcard replacement
         
     | 
| 
      
 83 
     | 
    
         
            +
              - Properly handles special regex characters (`.`, `[`, `(`, etc.) in channel names
         
     | 
| 
      
 84 
     | 
    
         
            +
              - Added `RegexpError` handling for invalid patterns
         
     | 
| 
      
 85 
     | 
    
         
            +
              - **Impact**: Prevents incorrect pattern matching and potential regex errors
         
     | 
| 
      
 86 
     | 
    
         
            +
             
     | 
| 
      
 87 
     | 
    
         
            +
            - **Missing Batch Size Validation**: Added bounds checking for `cleanup_batch_size`
         
     | 
| 
      
 88 
     | 
    
         
            +
              - Validates and clamps batch_size to safe range (1-1000)
         
     | 
| 
      
 89 
     | 
    
         
            +
              - Prevents crashes from invalid values (0, negative, nil)
         
     | 
| 
      
 90 
     | 
    
         
            +
              - Prevents performance degradation from extreme values
         
     | 
| 
      
 91 
     | 
    
         
            +
              - **Impact**: Robust configuration handling prevents misconfigurations
         
     | 
| 
      
 92 
     | 
    
         
            +
             
     | 
| 
      
 93 
     | 
    
         
            +
            ### Changed
         
     | 
| 
      
 94 
     | 
    
         
            +
            - `SubscriptionManager#initialize`: Added `@pattern_cache = {}` for regex memoization
         
     | 
| 
      
 95 
     | 
    
         
            +
            - `SubscriptionManager#channel_matches_pattern?`: Uses cached regexes with proper escaping
         
     | 
| 
      
 96 
     | 
    
         
            +
            - `SubscriptionManager#cleanup_pattern_if_unused`: Clears pattern from cache when removed
         
     | 
| 
      
 97 
     | 
    
         
            +
            - `SubscriptionManager#cleanup_unused_patterns`: Batch cache clearing
         
     | 
| 
      
 98 
     | 
    
         
            +
            - `SubscriptionManager#cleanup_unused_patterns_async`: Batch cache clearing
         
     | 
| 
      
 99 
     | 
    
         
            +
            - `SubscriptionManager#scan_orphaned_subscriptions`: Batched scanning with connection release
         
     | 
| 
      
 100 
     | 
    
         
            +
            - `SubscriptionManager#cleanup_orphaned_data`: Validates `cleanup_batch_size` parameter
         
     | 
| 
      
 101 
     | 
    
         
            +
            - `PubSubCoordinator#disconnect`: Resets `@reconnect_attempts` to 0
         
     | 
| 
      
 102 
     | 
    
         
            +
            - `DEFAULT_OPTIONS`: Updated `cleanup_batch_size` comment with range (min: 1, max: 1000)
         
     | 
| 
      
 103 
     | 
    
         
            +
             
     | 
| 
      
 104 
     | 
    
         
            +
            ### Technical Details
         
     | 
| 
      
 105 
     | 
    
         
            +
             
     | 
| 
      
 106 
     | 
    
         
            +
            **Race Condition Fix**:
         
     | 
| 
      
 107 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 108 
     | 
    
         
            +
            # Before: callback could be called multiple times
         
     | 
| 
      
 109 
     | 
    
         
            +
            remaining -= 1
         
     | 
| 
      
 110 
     | 
    
         
            +
            callback.call(true) if callback && remaining == 0
         
     | 
| 
      
 111 
     | 
    
         
            +
             
     | 
| 
      
 112 
     | 
    
         
            +
            # After: flag prevents duplicate calls
         
     | 
| 
      
 113 
     | 
    
         
            +
            if remaining == 0 && !callback_called && callback
         
     | 
| 
      
 114 
     | 
    
         
            +
              callback_called = true
         
     | 
| 
      
 115 
     | 
    
         
            +
              callback.call(true)
         
     | 
| 
      
 116 
     | 
    
         
            +
            end
         
     | 
| 
      
 117 
     | 
    
         
            +
            ```
         
     | 
| 
      
 118 
     | 
    
         
            +
             
     | 
| 
      
 119 
     | 
    
         
            +
            **SCAN Optimization**:
         
     | 
| 
      
 120 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 121 
     | 
    
         
            +
            # Before: Single with_redis block holding connection for entire loop
         
     | 
| 
      
 122 
     | 
    
         
            +
            @connection.with_redis do |redis|
         
     | 
| 
      
 123 
     | 
    
         
            +
              loop do
         
     | 
| 
      
 124 
     | 
    
         
            +
                cursor, keys = redis.scan(cursor, ...)
         
     | 
| 
      
 125 
     | 
    
         
            +
                # ... process keys ...
         
     | 
| 
      
 126 
     | 
    
         
            +
              end
         
     | 
| 
      
 127 
     | 
    
         
            +
            end
         
     | 
| 
      
 128 
     | 
    
         
            +
             
     | 
| 
      
 129 
     | 
    
         
            +
            # After: Release connection between iterations
         
     | 
| 
      
 130 
     | 
    
         
            +
            scan_batch = lambda do |cursor_value|
         
     | 
| 
      
 131 
     | 
    
         
            +
              @connection.with_redis do |redis|
         
     | 
| 
      
 132 
     | 
    
         
            +
                cursor, keys = redis.scan(cursor_value, ...)
         
     | 
| 
      
 133 
     | 
    
         
            +
                # ... process keys ...
         
     | 
| 
      
 134 
     | 
    
         
            +
                if cursor == "0"
         
     | 
| 
      
 135 
     | 
    
         
            +
                  # Done
         
     | 
| 
      
 136 
     | 
    
         
            +
                else
         
     | 
| 
      
 137 
     | 
    
         
            +
                  EventMachine.next_tick { scan_batch.call(cursor) }  # Release & continue
         
     | 
| 
      
 138 
     | 
    
         
            +
                end
         
     | 
| 
      
 139 
     | 
    
         
            +
              end
         
     | 
| 
      
 140 
     | 
    
         
            +
            end
         
     | 
| 
      
 141 
     | 
    
         
            +
            ```
         
     | 
| 
      
 142 
     | 
    
         
            +
             
     | 
| 
      
 143 
     | 
    
         
            +
            **Pattern Caching**:
         
     | 
| 
      
 144 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 145 
     | 
    
         
            +
            # Before: Compile regex every time (100K times/sec at high load)
         
     | 
| 
      
 146 
     | 
    
         
            +
            def channel_matches_pattern?(channel, pattern)
         
     | 
| 
      
 147 
     | 
    
         
            +
              regex_pattern = pattern.gsub('**', '.*').gsub('*', '[^/]+')
         
     | 
| 
      
 148 
     | 
    
         
            +
              regex = Regexp.new("^#{regex_pattern}$")
         
     | 
| 
      
 149 
     | 
    
         
            +
              !!(channel =~ regex)
         
     | 
| 
      
 150 
     | 
    
         
            +
            end
         
     | 
| 
      
 151 
     | 
    
         
            +
             
     | 
| 
      
 152 
     | 
    
         
            +
            # After: Memoized compilation (1 time per pattern)
         
     | 
| 
      
 153 
     | 
    
         
            +
            def channel_matches_pattern?(channel, pattern)
         
     | 
| 
      
 154 
     | 
    
         
            +
              regex = @pattern_cache[pattern] ||= begin
         
     | 
| 
      
 155 
     | 
    
         
            +
                escaped = Regexp.escape(pattern)
         
     | 
| 
      
 156 
     | 
    
         
            +
                regex_pattern = escaped.gsub(Regexp.escape('**'), '.*').gsub(Regexp.escape('*'), '[^/]+')
         
     | 
| 
      
 157 
     | 
    
         
            +
                Regexp.new("^#{regex_pattern}$")
         
     | 
| 
      
 158 
     | 
    
         
            +
              end
         
     | 
| 
      
 159 
     | 
    
         
            +
              !!(channel =~ regex)
         
     | 
| 
      
 160 
     | 
    
         
            +
            end
         
     | 
| 
      
 161 
     | 
    
         
            +
            ```
         
     | 
| 
      
 162 
     | 
    
         
            +
             
     | 
| 
      
 163 
     | 
    
         
            +
            ### Test Coverage
         
     | 
| 
      
 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
         
     | 
| 
      
 170 
     | 
    
         
            +
             
     | 
| 
      
 171 
     | 
    
         
            +
            ### Upgrade Notes
         
     | 
| 
      
 172 
     | 
    
         
            +
            This release includes important concurrency and performance fixes. Recommended for all users, especially:
         
     | 
| 
      
 173 
     | 
    
         
            +
            - High-scale deployments (>50K clients)
         
     | 
| 
      
 174 
     | 
    
         
            +
            - High-traffic scenarios (>1K msg/sec)
         
     | 
| 
      
 175 
     | 
    
         
            +
            - Systems with frequent disconnect/reconnect patterns
         
     | 
| 
      
 176 
     | 
    
         
            +
            - Deployments using wildcard subscriptions
         
     | 
| 
      
 177 
     | 
    
         
            +
             
     | 
| 
      
 178 
     | 
    
         
            +
            No breaking changes. Drop-in replacement for v1.0.8.
         
     | 
| 
      
 179 
     | 
    
         
            +
             
     | 
| 
       10 
180 
     | 
    
         
             
            ## [1.0.8] - 2025-10-30
         
     | 
| 
       11 
181 
     | 
    
         | 
| 
       12 
182 
     | 
    
         
             
            ### Fixed - Memory Leaks (P0 - High Risk)
         
     | 
| 
         @@ -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  
     | 
| 
       25 
     | 
    
         
            -
                       
     | 
| 
       26 
     | 
    
         
            -
             
     | 
| 
       27 
     | 
    
         
            -
                         
     | 
| 
       28 
     | 
    
         
            -
                         
     | 
| 
       29 
     | 
    
         
            -
                         
     | 
| 
       30 
     | 
    
         
            -
             
     | 
| 
      
 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
         
     | 
| 
         @@ -6,6 +6,7 @@ module Faye 
     | 
|
| 
       6 
6 
     | 
    
         
             
                  def initialize(connection, options = {})
         
     | 
| 
       7 
7 
     | 
    
         
             
                    @connection = connection
         
     | 
| 
       8 
8 
     | 
    
         
             
                    @options = options
         
     | 
| 
      
 9 
     | 
    
         
            +
                    @pattern_cache = {}  # Cache compiled regexes for pattern matching performance
         
     | 
| 
       9 
10 
     | 
    
         
             
                  end
         
     | 
| 
       10 
11 
     | 
    
         | 
| 
       11 
12 
     | 
    
         
             
                  # Subscribe a client to a channel
         
     | 
| 
         @@ -13,34 +14,48 @@ module Faye 
     | 
|
| 
       13 
14 
     | 
    
         
             
                    timestamp = Time.now.to_i
         
     | 
| 
       14 
15 
     | 
    
         
             
                    subscription_ttl = @options[:subscription_ttl] || 86400  # 24 hours default
         
     | 
| 
       15 
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 
     | 
    
         
            +
             
     | 
| 
       16 
21 
     | 
    
         
             
                    @connection.with_redis do |redis|
         
     | 
| 
       17 
     | 
    
         
            -
                       
     | 
| 
       18 
     | 
    
         
            -
             
     | 
| 
       19 
     | 
    
         
            -
             
     | 
| 
       20 
     | 
    
         
            -
                         
     | 
| 
       21 
     | 
    
         
            -
                         
     | 
| 
       22 
     | 
    
         
            -
             
     | 
| 
       23 
     | 
    
         
            -
                         
     | 
| 
       24 
     | 
    
         
            -
             
     | 
| 
       25 
     | 
    
         
            -
                         
     | 
| 
       26 
     | 
    
         
            -
             
     | 
| 
       27 
     | 
    
         
            -
             
     | 
| 
       28 
     | 
    
         
            -
                         
     | 
| 
       29 
     | 
    
         
            -
                         
     | 
| 
       30 
     | 
    
         
            -
             
     | 
| 
       31 
     | 
    
         
            -
                          ' 
     | 
| 
       32 
     | 
    
         
            -
                          'channel', channel,
         
     | 
| 
       33 
     | 
    
         
            -
                          'client_id', client_id
         
     | 
| 
       34 
     | 
    
         
            -
                        )
         
     | 
| 
       35 
     | 
    
         
            -
                        # Set TTL for subscription metadata
         
     | 
| 
       36 
     | 
    
         
            -
                        multi.expire(subscription_key(client_id, channel), subscription_ttl)
         
     | 
| 
       37 
     | 
    
         
            -
             
     | 
| 
       38 
     | 
    
         
            -
                        # Handle wildcard patterns
         
     | 
| 
       39 
     | 
    
         
            -
                        if channel.include?('*')
         
     | 
| 
       40 
     | 
    
         
            -
                          multi.sadd?(patterns_key, channel)
         
     | 
| 
       41 
     | 
    
         
            -
                          # Set/refresh TTL for patterns set
         
     | 
| 
       42 
     | 
    
         
            -
                          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])
         
     | 
| 
       43 
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
         
     | 
| 
       44 
59 
     | 
    
         
             
                      end
         
     | 
| 
       45 
60 
     | 
    
         
             
                    end
         
     | 
| 
       46 
61 
     | 
    
         | 
| 
         @@ -85,10 +100,15 @@ module Faye 
     | 
|
| 
       85 
100 
     | 
    
         
             
                      else
         
     | 
| 
       86 
101 
     | 
    
         
             
                        # Unsubscribe from each channel
         
     | 
| 
       87 
102 
     | 
    
         
             
                        remaining = channels.size
         
     | 
| 
      
 103 
     | 
    
         
            +
                        callback_called = false  # Prevent race condition
         
     | 
| 
       88 
104 
     | 
    
         
             
                        channels.each do |channel|
         
     | 
| 
       89 
105 
     | 
    
         
             
                          unsubscribe(client_id, channel) do
         
     | 
| 
       90 
106 
     | 
    
         
             
                            remaining -= 1
         
     | 
| 
       91 
     | 
    
         
            -
                             
     | 
| 
      
 107 
     | 
    
         
            +
                            # Check flag to prevent multiple callback invocations
         
     | 
| 
      
 108 
     | 
    
         
            +
                            if remaining == 0 && !callback_called && callback
         
     | 
| 
      
 109 
     | 
    
         
            +
                              callback_called = true
         
     | 
| 
      
 110 
     | 
    
         
            +
                              callback.call(true)
         
     | 
| 
      
 111 
     | 
    
         
            +
                            end
         
     | 
| 
       92 
112 
     | 
    
         
             
                          end
         
     | 
| 
       93 
113 
     | 
    
         
             
                        end
         
     | 
| 
       94 
114 
     | 
    
         
             
                      end
         
     | 
| 
         @@ -159,16 +179,26 @@ module Faye 
     | 
|
| 
       159 
179 
     | 
    
         
             
                  end
         
     | 
| 
       160 
180 
     | 
    
         | 
| 
       161 
181 
     | 
    
         
             
                  # Check if a channel matches a pattern
         
     | 
| 
      
 182 
     | 
    
         
            +
                  # Uses memoization to cache compiled regexes for performance
         
     | 
| 
       162 
183 
     | 
    
         
             
                  def channel_matches_pattern?(channel, pattern)
         
     | 
| 
       163 
     | 
    
         
            -
                    #  
     | 
| 
       164 
     | 
    
         
            -
                     
     | 
| 
       165 
     | 
    
         
            -
             
     | 
| 
       166 
     | 
    
         
            -
                       
     | 
| 
       167 
     | 
    
         
            -
                       
     | 
| 
       168 
     | 
    
         
            -
                      . 
     | 
| 
       169 
     | 
    
         
            -
             
     | 
| 
       170 
     | 
    
         
            -
             
     | 
| 
      
 184 
     | 
    
         
            +
                    # Get or compile regex for this pattern
         
     | 
| 
      
 185 
     | 
    
         
            +
                    regex = @pattern_cache[pattern] ||= begin
         
     | 
| 
      
 186 
     | 
    
         
            +
                      # Escape the pattern first to handle special regex characters
         
     | 
| 
      
 187 
     | 
    
         
            +
                      # Then replace escaped wildcards with regex patterns
         
     | 
| 
      
 188 
     | 
    
         
            +
                      # ** matches multiple segments (including /), * matches one segment (no /)
         
     | 
| 
      
 189 
     | 
    
         
            +
                      escaped = Regexp.escape(pattern)
         
     | 
| 
      
 190 
     | 
    
         
            +
             
     | 
| 
      
 191 
     | 
    
         
            +
                      regex_pattern = escaped
         
     | 
| 
      
 192 
     | 
    
         
            +
                        .gsub(Regexp.escape('**'), '.*')        # ** → .* (match anything)
         
     | 
| 
      
 193 
     | 
    
         
            +
                        .gsub(Regexp.escape('*'), '[^/]+')      # * → [^/]+ (match one segment)
         
     | 
| 
      
 194 
     | 
    
         
            +
             
     | 
| 
      
 195 
     | 
    
         
            +
                      Regexp.new("^#{regex_pattern}$")
         
     | 
| 
      
 196 
     | 
    
         
            +
                    end
         
     | 
| 
      
 197 
     | 
    
         
            +
             
     | 
| 
       171 
198 
     | 
    
         
             
                    !!(channel =~ regex)
         
     | 
| 
      
 199 
     | 
    
         
            +
                  rescue RegexpError => e
         
     | 
| 
      
 200 
     | 
    
         
            +
                    log_error("Invalid pattern #{pattern}: #{e.message}")
         
     | 
| 
      
 201 
     | 
    
         
            +
                    false
         
     | 
| 
       172 
202 
     | 
    
         
             
                  end
         
     | 
| 
       173 
203 
     | 
    
         | 
| 
       174 
204 
     | 
    
         
             
                  # Clean up subscriptions for a client
         
     | 
| 
         @@ -184,15 +214,21 @@ module Faye 
     | 
|
| 
       184 
214 
     | 
    
         
             
                    namespace = @options[:namespace] || 'faye'
         
     | 
| 
       185 
215 
     | 
    
         
             
                    batch_size = @options[:cleanup_batch_size] || 50
         
     | 
| 
       186 
216 
     | 
    
         | 
| 
      
 217 
     | 
    
         
            +
                    # Validate and clamp batch_size to safe range (1-1000)
         
     | 
| 
      
 218 
     | 
    
         
            +
                    batch_size = [[batch_size.to_i, 1].max, 1000].min
         
     | 
| 
      
 219 
     | 
    
         
            +
             
     | 
| 
       187 
220 
     | 
    
         
             
                    # Phase 1: Scan for orphaned subscriptions
         
     | 
| 
       188 
221 
     | 
    
         
             
                    scan_orphaned_subscriptions(active_set, namespace) do |orphaned_subscriptions|
         
     | 
| 
       189 
222 
     | 
    
         
             
                      # Phase 2: Clean up orphaned subscriptions in batches
         
     | 
| 
       190 
223 
     | 
    
         
             
                      cleanup_orphaned_subscriptions_batched(orphaned_subscriptions, namespace, batch_size) do
         
     | 
| 
       191 
     | 
    
         
            -
                        # Phase 3: Clean up  
     | 
| 
       192 
     | 
    
         
            -
                         
     | 
| 
       193 
     | 
    
         
            -
                          # Phase 4: Clean up  
     | 
| 
       194 
     | 
    
         
            -
                           
     | 
| 
       195 
     | 
    
         
            -
                             
     | 
| 
      
 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
         
     | 
| 
       196 
232 
     | 
    
         
             
                          end
         
     | 
| 
       197 
233 
     | 
    
         
             
                        end
         
     | 
| 
       198 
234 
     | 
    
         
             
                      end
         
     | 
| 
         @@ -205,24 +241,36 @@ module Faye 
     | 
|
| 
       205 
241 
     | 
    
         
             
                  private
         
     | 
| 
       206 
242 
     | 
    
         | 
| 
       207 
243 
     | 
    
         
             
                  # Scan for orphaned subscription keys
         
     | 
| 
      
 244 
     | 
    
         
            +
                  # Uses batched scanning to avoid holding connection for long periods
         
     | 
| 
       208 
245 
     | 
    
         
             
                  def scan_orphaned_subscriptions(active_set, namespace, &callback)
         
     | 
| 
       209 
     | 
    
         
            -
                     
     | 
| 
       210 
     | 
    
         
            -
                      cursor = "0"
         
     | 
| 
       211 
     | 
    
         
            -
                      orphaned_subscriptions = []
         
     | 
| 
      
 246 
     | 
    
         
            +
                    orphaned_subscriptions = []
         
     | 
| 
       212 
247 
     | 
    
         | 
| 
       213 
     | 
    
         
            -
             
     | 
| 
       214 
     | 
    
         
            -
             
     | 
| 
      
 248 
     | 
    
         
            +
                    # Batch scan to release connection between iterations
         
     | 
| 
      
 249 
     | 
    
         
            +
                    scan_batch = lambda do |cursor_value|
         
     | 
| 
      
 250 
     | 
    
         
            +
                      begin
         
     | 
| 
      
 251 
     | 
    
         
            +
                        @connection.with_redis do |redis|
         
     | 
| 
      
 252 
     | 
    
         
            +
                          cursor, keys = redis.scan(cursor_value, match: "#{namespace}:subscriptions:*", count: 100)
         
     | 
| 
       215 
253 
     | 
    
         | 
| 
       216 
     | 
    
         
            -
             
     | 
| 
       217 
     | 
    
         
            -
             
     | 
| 
       218 
     | 
    
         
            -
             
     | 
| 
       219 
     | 
    
         
            -
             
     | 
| 
      
 254 
     | 
    
         
            +
                          keys.each do |key|
         
     | 
| 
      
 255 
     | 
    
         
            +
                            client_id = key.split(':').last
         
     | 
| 
      
 256 
     | 
    
         
            +
                            orphaned_subscriptions << client_id unless active_set.include?(client_id)
         
     | 
| 
      
 257 
     | 
    
         
            +
                          end
         
     | 
| 
       220 
258 
     | 
    
         | 
| 
       221 
     | 
    
         
            -
             
     | 
| 
      
 259 
     | 
    
         
            +
                          if cursor == "0"
         
     | 
| 
      
 260 
     | 
    
         
            +
                            # Scan complete
         
     | 
| 
      
 261 
     | 
    
         
            +
                            EventMachine.next_tick { callback.call(orphaned_subscriptions) }
         
     | 
| 
      
 262 
     | 
    
         
            +
                          else
         
     | 
| 
      
 263 
     | 
    
         
            +
                            # Continue scanning in next tick to release connection
         
     | 
| 
      
 264 
     | 
    
         
            +
                            EventMachine.next_tick { scan_batch.call(cursor) }
         
     | 
| 
      
 265 
     | 
    
         
            +
                          end
         
     | 
| 
      
 266 
     | 
    
         
            +
                        end
         
     | 
| 
      
 267 
     | 
    
         
            +
                      rescue => e
         
     | 
| 
      
 268 
     | 
    
         
            +
                        log_error("Failed to scan orphaned subscriptions batch: #{e.message}")
         
     | 
| 
      
 269 
     | 
    
         
            +
                        EventMachine.next_tick { callback.call(orphaned_subscriptions) }
         
     | 
| 
       222 
270 
     | 
    
         
             
                      end
         
     | 
| 
       223 
     | 
    
         
            -
             
     | 
| 
       224 
     | 
    
         
            -
                      EventMachine.next_tick { callback.call(orphaned_subscriptions) }
         
     | 
| 
       225 
271 
     | 
    
         
             
                    end
         
     | 
| 
      
 272 
     | 
    
         
            +
             
     | 
| 
      
 273 
     | 
    
         
            +
                    scan_batch.call("0")
         
     | 
| 
       226 
274 
     | 
    
         
             
                  rescue => e
         
     | 
| 
       227 
275 
     | 
    
         
             
                    log_error("Failed to scan orphaned subscriptions: #{e.message}")
         
     | 
| 
       228 
276 
     | 
    
         
             
                    EventMachine.next_tick { callback.call([]) }
         
     | 
| 
         @@ -274,6 +322,80 @@ module Faye 
     | 
|
| 
       274 
322 
     | 
    
         
             
                    EventMachine.next_tick { callback.call }
         
     | 
| 
       275 
323 
     | 
    
         
             
                  end
         
     | 
| 
       276 
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 
     | 
    
         
            +
             
     | 
| 
       277 
399 
     | 
    
         
             
                  # Async version of cleanup_empty_channels that yields between operations
         
     | 
| 
       278 
400 
     | 
    
         
             
                  def cleanup_empty_channels_async(namespace, &callback)
         
     | 
| 
       279 
401 
     | 
    
         
             
                    @connection.with_redis do |redis|
         
     | 
| 
         @@ -323,6 +445,8 @@ module Faye 
     | 
|
| 
       323 
445 
     | 
    
         
             
                            pipeline.del(channel_subscribers_key(pattern))
         
     | 
| 
       324 
446 
     | 
    
         
             
                          end
         
     | 
| 
       325 
447 
     | 
    
         
             
                        end
         
     | 
| 
      
 448 
     | 
    
         
            +
                        # Clear unused patterns from regex cache
         
     | 
| 
      
 449 
     | 
    
         
            +
                        unused_patterns.each { |pattern| @pattern_cache.delete(pattern) }
         
     | 
| 
       326 
450 
     | 
    
         
             
                        puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{unused_patterns.size} unused patterns" if @options[:log_level] != :silent
         
     | 
| 
       327 
451 
     | 
    
         
             
                      end
         
     | 
| 
       328 
452 
     | 
    
         | 
| 
         @@ -376,6 +500,8 @@ module Faye 
     | 
|
| 
       376 
500 
     | 
    
         
             
                          pipeline.del(channel_subscribers_key(pattern))
         
     | 
| 
       377 
501 
     | 
    
         
             
                        end
         
     | 
| 
       378 
502 
     | 
    
         
             
                      end
         
     | 
| 
      
 503 
     | 
    
         
            +
                      # Clear unused patterns from regex cache
         
     | 
| 
      
 504 
     | 
    
         
            +
                      unused_patterns.each { |pattern| @pattern_cache.delete(pattern) }
         
     | 
| 
       379 
505 
     | 
    
         
             
                      puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{unused_patterns.size} unused patterns" if @options[:log_level] != :silent
         
     | 
| 
       380 
506 
     | 
    
         
             
                    end
         
     | 
| 
       381 
507 
     | 
    
         
             
                  rescue => e
         
     | 
| 
         @@ -391,6 +517,8 @@ module Faye 
     | 
|
| 
       391 
517 
     | 
    
         
             
                      @connection.with_redis do |redis|
         
     | 
| 
       392 
518 
     | 
    
         
             
                        redis.srem(patterns_key, pattern)
         
     | 
| 
       393 
519 
     | 
    
         
             
                      end
         
     | 
| 
      
 520 
     | 
    
         
            +
                      # Clear pattern from regex cache when it's removed
         
     | 
| 
      
 521 
     | 
    
         
            +
                      @pattern_cache.delete(pattern)
         
     | 
| 
       394 
522 
     | 
    
         
             
                    end
         
     | 
| 
       395 
523 
     | 
    
         
             
                  rescue => e
         
     | 
| 
       396 
524 
     | 
    
         
             
                    log_error("Failed to cleanup pattern #{pattern}: #{e.message}")
         
     | 
    
        data/lib/faye/redis/version.rb
    CHANGED
    
    
    
        data/lib/faye/redis.rb
    CHANGED
    
    | 
         @@ -28,7 +28,7 @@ module Faye 
     | 
|
| 
       28 
28 
     | 
    
         
             
                  subscription_ttl: 86400,  # Subscription keys TTL (24 hours), 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 
     | 
    
         
            -
                  cleanup_batch_size: 50  # Number of items  
     | 
| 
      
 31 
     | 
    
         
            +
                  cleanup_batch_size: 50  # Number of items per batch during cleanup (min: 1, max: 1000, prevents blocking)
         
     | 
| 
       32 
32 
     | 
    
         
             
                }.freeze
         
     | 
| 
       33 
33 
     | 
    
         | 
| 
       34 
34 
     | 
    
         
             
                attr_reader :server, :options, :connection, :client_registry,
         
     |