faye-redis-ng 1.0.7 → 1.0.9
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 +205 -0
 - data/lib/faye/redis/client_registry.rb +49 -0
 - data/lib/faye/redis/pubsub_coordinator.rb +11 -7
 - data/lib/faye/redis/subscription_manager.rb +270 -16
 - data/lib/faye/redis/version.rb +1 -1
 - data/lib/faye/redis.rb +42 -59
 - 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: cd86d6fdb530405ff0dd146705d45d9c665cfa9f778987ead89d4bb497db06eb
         
     | 
| 
      
 4 
     | 
    
         
            +
              data.tar.gz: ddc3a783e0007e452d69eb97e6f2540a089427420119a4414e91281e1b4a9d9d
         
     | 
| 
       5 
5 
     | 
    
         
             
            SHA512:
         
     | 
| 
       6 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       7 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 6 
     | 
    
         
            +
              metadata.gz: c2a3f9d350a9aeab02bcfc6589a6f3e81e2973396d54397510d46787e1d7cde81de064a97f44428bf3534f266ccda104ae1535fd8108197e0862aeae1c28b71e
         
     | 
| 
      
 7 
     | 
    
         
            +
              data.tar.gz: 4f41d51dae3a17b7d44171384f6a2646ac7492ae68c4f85845feac100f20d59f609105ebfc90d5fdffd415e893cb3e7556bb4ade191da84d867e16a90e8bf664
         
     | 
    
        data/CHANGELOG.md
    CHANGED
    
    | 
         @@ -7,6 +7,211 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 
     | 
|
| 
       7 
7 
     | 
    
         | 
| 
       8 
8 
     | 
    
         
             
            ## [Unreleased]
         
     | 
| 
       9 
9 
     | 
    
         | 
| 
      
 10 
     | 
    
         
            +
            ## [1.0.9] - 2025-10-30
         
     | 
| 
      
 11 
     | 
    
         
            +
             
     | 
| 
      
 12 
     | 
    
         
            +
            ### Fixed - Concurrency Issues (P1 - High Priority)
         
     | 
| 
      
 13 
     | 
    
         
            +
            - **`unsubscribe_all` Race Condition**: Fixed callback being called multiple times
         
     | 
| 
      
 14 
     | 
    
         
            +
              - Added `callback_called` flag to prevent duplicate callback invocations
         
     | 
| 
      
 15 
     | 
    
         
            +
              - Multiple async unsubscribe operations could trigger callback simultaneously
         
     | 
| 
      
 16 
     | 
    
         
            +
              - **Impact**: Eliminates duplicate cleanup operations in high-concurrency scenarios
         
     | 
| 
      
 17 
     | 
    
         
            +
             
     | 
| 
      
 18 
     | 
    
         
            +
            - **Reconnect Counter Not Reset**: Fixed `@reconnect_attempts` not resetting on disconnect
         
     | 
| 
      
 19 
     | 
    
         
            +
              - Added counter reset in `PubSubCoordinator#disconnect` method
         
     | 
| 
      
 20 
     | 
    
         
            +
              - Prevents incorrect exponential backoff after disconnect/reconnect cycles
         
     | 
| 
      
 21 
     | 
    
         
            +
              - **Impact**: Ensures proper reconnection behavior after manual disconnects
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
            - **SCAN Connection Pool Blocking**: Optimized long-running SCAN operations
         
     | 
| 
      
 24 
     | 
    
         
            +
              - Changed `scan_orphaned_subscriptions` to batch scanning with connection release
         
     | 
| 
      
 25 
     | 
    
         
            +
              - Each SCAN iteration now releases connection via `EventMachine.next_tick`
         
     | 
| 
      
 26 
     | 
    
         
            +
              - Prevents holding Redis connection for 10-30 seconds with large datasets
         
     | 
| 
      
 27 
     | 
    
         
            +
              - **Impact**: Eliminates connection pool exhaustion during cleanup of 100K+ keys
         
     | 
| 
      
 28 
     | 
    
         
            +
             
     | 
| 
      
 29 
     | 
    
         
            +
            ### Fixed - Performance Issues (P2 - Medium Priority)
         
     | 
| 
      
 30 
     | 
    
         
            +
            - **Pattern Regex Compilation Overhead**: Added regex pattern caching
         
     | 
| 
      
 31 
     | 
    
         
            +
              - Implemented `@pattern_cache` to memoize compiled regular expressions
         
     | 
| 
      
 32 
     | 
    
         
            +
              - Cache is automatically cleared when patterns are removed
         
     | 
| 
      
 33 
     | 
    
         
            +
              - Prevents recompiling same regex for every pattern match
         
     | 
| 
      
 34 
     | 
    
         
            +
              - **Impact**: 20% CPU reduction with 100 patterns at 1000 msg/sec (100K → 0 regex compilations/sec)
         
     | 
| 
      
 35 
     | 
    
         
            +
             
     | 
| 
      
 36 
     | 
    
         
            +
            - **Pattern Regex Injection Risk**: Fixed special character handling in patterns
         
     | 
| 
      
 37 
     | 
    
         
            +
              - Added `Regexp.escape` before wildcard replacement
         
     | 
| 
      
 38 
     | 
    
         
            +
              - Properly handles special regex characters (`.`, `[`, `(`, etc.) in channel names
         
     | 
| 
      
 39 
     | 
    
         
            +
              - Added `RegexpError` handling for invalid patterns
         
     | 
| 
      
 40 
     | 
    
         
            +
              - **Impact**: Prevents incorrect pattern matching and potential regex errors
         
     | 
| 
      
 41 
     | 
    
         
            +
             
     | 
| 
      
 42 
     | 
    
         
            +
            - **Missing Batch Size Validation**: Added bounds checking for `cleanup_batch_size`
         
     | 
| 
      
 43 
     | 
    
         
            +
              - Validates and clamps batch_size to safe range (1-1000)
         
     | 
| 
      
 44 
     | 
    
         
            +
              - Prevents crashes from invalid values (0, negative, nil)
         
     | 
| 
      
 45 
     | 
    
         
            +
              - Prevents performance degradation from extreme values
         
     | 
| 
      
 46 
     | 
    
         
            +
              - **Impact**: Robust configuration handling prevents misconfigurations
         
     | 
| 
      
 47 
     | 
    
         
            +
             
     | 
| 
      
 48 
     | 
    
         
            +
            ### Changed
         
     | 
| 
      
 49 
     | 
    
         
            +
            - `SubscriptionManager#initialize`: Added `@pattern_cache = {}` for regex memoization
         
     | 
| 
      
 50 
     | 
    
         
            +
            - `SubscriptionManager#channel_matches_pattern?`: Uses cached regexes with proper escaping
         
     | 
| 
      
 51 
     | 
    
         
            +
            - `SubscriptionManager#cleanup_pattern_if_unused`: Clears pattern from cache when removed
         
     | 
| 
      
 52 
     | 
    
         
            +
            - `SubscriptionManager#cleanup_unused_patterns`: Batch cache clearing
         
     | 
| 
      
 53 
     | 
    
         
            +
            - `SubscriptionManager#cleanup_unused_patterns_async`: Batch cache clearing
         
     | 
| 
      
 54 
     | 
    
         
            +
            - `SubscriptionManager#scan_orphaned_subscriptions`: Batched scanning with connection release
         
     | 
| 
      
 55 
     | 
    
         
            +
            - `SubscriptionManager#cleanup_orphaned_data`: Validates `cleanup_batch_size` parameter
         
     | 
| 
      
 56 
     | 
    
         
            +
            - `PubSubCoordinator#disconnect`: Resets `@reconnect_attempts` to 0
         
     | 
| 
      
 57 
     | 
    
         
            +
            - `DEFAULT_OPTIONS`: Updated `cleanup_batch_size` comment with range (min: 1, max: 1000)
         
     | 
| 
      
 58 
     | 
    
         
            +
             
     | 
| 
      
 59 
     | 
    
         
            +
            ### Technical Details
         
     | 
| 
      
 60 
     | 
    
         
            +
             
     | 
| 
      
 61 
     | 
    
         
            +
            **Race Condition Fix**:
         
     | 
| 
      
 62 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 63 
     | 
    
         
            +
            # Before: callback could be called multiple times
         
     | 
| 
      
 64 
     | 
    
         
            +
            remaining -= 1
         
     | 
| 
      
 65 
     | 
    
         
            +
            callback.call(true) if callback && remaining == 0
         
     | 
| 
      
 66 
     | 
    
         
            +
             
     | 
| 
      
 67 
     | 
    
         
            +
            # After: flag prevents duplicate calls
         
     | 
| 
      
 68 
     | 
    
         
            +
            if remaining == 0 && !callback_called && callback
         
     | 
| 
      
 69 
     | 
    
         
            +
              callback_called = true
         
     | 
| 
      
 70 
     | 
    
         
            +
              callback.call(true)
         
     | 
| 
      
 71 
     | 
    
         
            +
            end
         
     | 
| 
      
 72 
     | 
    
         
            +
            ```
         
     | 
| 
      
 73 
     | 
    
         
            +
             
     | 
| 
      
 74 
     | 
    
         
            +
            **SCAN Optimization**:
         
     | 
| 
      
 75 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 76 
     | 
    
         
            +
            # Before: Single with_redis block holding connection for entire loop
         
     | 
| 
      
 77 
     | 
    
         
            +
            @connection.with_redis do |redis|
         
     | 
| 
      
 78 
     | 
    
         
            +
              loop do
         
     | 
| 
      
 79 
     | 
    
         
            +
                cursor, keys = redis.scan(cursor, ...)
         
     | 
| 
      
 80 
     | 
    
         
            +
                # ... process keys ...
         
     | 
| 
      
 81 
     | 
    
         
            +
              end
         
     | 
| 
      
 82 
     | 
    
         
            +
            end
         
     | 
| 
      
 83 
     | 
    
         
            +
             
     | 
| 
      
 84 
     | 
    
         
            +
            # After: Release connection between iterations
         
     | 
| 
      
 85 
     | 
    
         
            +
            scan_batch = lambda do |cursor_value|
         
     | 
| 
      
 86 
     | 
    
         
            +
              @connection.with_redis do |redis|
         
     | 
| 
      
 87 
     | 
    
         
            +
                cursor, keys = redis.scan(cursor_value, ...)
         
     | 
| 
      
 88 
     | 
    
         
            +
                # ... process keys ...
         
     | 
| 
      
 89 
     | 
    
         
            +
                if cursor == "0"
         
     | 
| 
      
 90 
     | 
    
         
            +
                  # Done
         
     | 
| 
      
 91 
     | 
    
         
            +
                else
         
     | 
| 
      
 92 
     | 
    
         
            +
                  EventMachine.next_tick { scan_batch.call(cursor) }  # Release & continue
         
     | 
| 
      
 93 
     | 
    
         
            +
                end
         
     | 
| 
      
 94 
     | 
    
         
            +
              end
         
     | 
| 
      
 95 
     | 
    
         
            +
            end
         
     | 
| 
      
 96 
     | 
    
         
            +
            ```
         
     | 
| 
      
 97 
     | 
    
         
            +
             
     | 
| 
      
 98 
     | 
    
         
            +
            **Pattern Caching**:
         
     | 
| 
      
 99 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 100 
     | 
    
         
            +
            # Before: Compile regex every time (100K times/sec at high load)
         
     | 
| 
      
 101 
     | 
    
         
            +
            def channel_matches_pattern?(channel, pattern)
         
     | 
| 
      
 102 
     | 
    
         
            +
              regex_pattern = pattern.gsub('**', '.*').gsub('*', '[^/]+')
         
     | 
| 
      
 103 
     | 
    
         
            +
              regex = Regexp.new("^#{regex_pattern}$")
         
     | 
| 
      
 104 
     | 
    
         
            +
              !!(channel =~ regex)
         
     | 
| 
      
 105 
     | 
    
         
            +
            end
         
     | 
| 
      
 106 
     | 
    
         
            +
             
     | 
| 
      
 107 
     | 
    
         
            +
            # After: Memoized compilation (1 time per pattern)
         
     | 
| 
      
 108 
     | 
    
         
            +
            def channel_matches_pattern?(channel, pattern)
         
     | 
| 
      
 109 
     | 
    
         
            +
              regex = @pattern_cache[pattern] ||= begin
         
     | 
| 
      
 110 
     | 
    
         
            +
                escaped = Regexp.escape(pattern)
         
     | 
| 
      
 111 
     | 
    
         
            +
                regex_pattern = escaped.gsub(Regexp.escape('**'), '.*').gsub(Regexp.escape('*'), '[^/]+')
         
     | 
| 
      
 112 
     | 
    
         
            +
                Regexp.new("^#{regex_pattern}$")
         
     | 
| 
      
 113 
     | 
    
         
            +
              end
         
     | 
| 
      
 114 
     | 
    
         
            +
              !!(channel =~ regex)
         
     | 
| 
      
 115 
     | 
    
         
            +
            end
         
     | 
| 
      
 116 
     | 
    
         
            +
            ```
         
     | 
| 
      
 117 
     | 
    
         
            +
             
     | 
| 
      
 118 
     | 
    
         
            +
            ### Test Coverage
         
     | 
| 
      
 119 
     | 
    
         
            +
            - All 177 tests passing
         
     | 
| 
      
 120 
     | 
    
         
            +
            - Line Coverage: 85.77%
         
     | 
| 
      
 121 
     | 
    
         
            +
            - Branch Coverage: 55.04%
         
     | 
| 
      
 122 
     | 
    
         
            +
             
     | 
| 
      
 123 
     | 
    
         
            +
            ### Upgrade Notes
         
     | 
| 
      
 124 
     | 
    
         
            +
            This release includes important concurrency and performance fixes. Recommended for all users, especially:
         
     | 
| 
      
 125 
     | 
    
         
            +
            - High-scale deployments (>50K clients)
         
     | 
| 
      
 126 
     | 
    
         
            +
            - High-traffic scenarios (>1K msg/sec)
         
     | 
| 
      
 127 
     | 
    
         
            +
            - Systems with frequent disconnect/reconnect patterns
         
     | 
| 
      
 128 
     | 
    
         
            +
            - Deployments using wildcard subscriptions
         
     | 
| 
      
 129 
     | 
    
         
            +
             
     | 
| 
      
 130 
     | 
    
         
            +
            No breaking changes. Drop-in replacement for v1.0.8.
         
     | 
| 
      
 131 
     | 
    
         
            +
             
     | 
| 
      
 132 
     | 
    
         
            +
            ## [1.0.8] - 2025-10-30
         
     | 
| 
      
 133 
     | 
    
         
            +
             
     | 
| 
      
 134 
     | 
    
         
            +
            ### Fixed - Memory Leaks (P0 - High Risk)
         
     | 
| 
      
 135 
     | 
    
         
            +
            - **@local_message_ids Memory Leak**: Fixed unbounded growth of message ID tracking
         
     | 
| 
      
 136 
     | 
    
         
            +
              - Changed from Set to Hash with timestamps for expiry tracking
         
     | 
| 
      
 137 
     | 
    
         
            +
              - Added `cleanup_stale_message_ids` to remove IDs older than 5 minutes
         
     | 
| 
      
 138 
     | 
    
         
            +
              - Integrated into automatic GC cycle
         
     | 
| 
      
 139 
     | 
    
         
            +
              - **Impact**: Prevents 90 MB/month memory leak in high-traffic scenarios
         
     | 
| 
      
 140 
     | 
    
         
            +
             
     | 
| 
      
 141 
     | 
    
         
            +
            - **Subscription Keys Without TTL**: Added TTL to all subscription-related Redis keys
         
     | 
| 
      
 142 
     | 
    
         
            +
              - Added `subscription_ttl` configuration option (default: 24 hours)
         
     | 
| 
      
 143 
     | 
    
         
            +
              - Set EXPIRE on: client subscriptions, channel subscribers, subscription metadata, patterns
         
     | 
| 
      
 144 
     | 
    
         
            +
              - Provides safety net if GC is disabled or crashes
         
     | 
| 
      
 145 
     | 
    
         
            +
              - **Impact**: Prevents unlimited Redis memory growth from orphaned subscriptions
         
     | 
| 
      
 146 
     | 
    
         
            +
             
     | 
| 
      
 147 
     | 
    
         
            +
            - **Multi-channel Message Deduplication**: Fixed duplicate message enqueue for multi-channel publishes
         
     | 
| 
      
 148 
     | 
    
         
            +
              - Changed message ID tracking from delete-on-check to check-only
         
     | 
| 
      
 149 
     | 
    
         
            +
              - Allows same message_id to be checked multiple times for different channels
         
     | 
| 
      
 150 
     | 
    
         
            +
              - Cleanup now handles expiry instead of immediate deletion
         
     | 
| 
      
 151 
     | 
    
         
            +
              - **Impact**: Eliminates duplicate messages when publishing to multiple channels
         
     | 
| 
      
 152 
     | 
    
         
            +
             
     | 
| 
      
 153 
     | 
    
         
            +
            ### Fixed - Performance Issues (P1 - Medium Risk)
         
     | 
| 
      
 154 
     | 
    
         
            +
            - **N+1 Query in Pattern Subscribers**: Optimized wildcard pattern subscriber lookup
         
     | 
| 
      
 155 
     | 
    
         
            +
              - Added Redis pipelining to fetch all matching pattern subscribers in one round-trip
         
     | 
| 
      
 156 
     | 
    
         
            +
              - Reduced from 101 calls to 2 calls for 100 patterns
         
     | 
| 
      
 157 
     | 
    
         
            +
              - Filter patterns in-memory before fetching subscribers
         
     | 
| 
      
 158 
     | 
    
         
            +
              - **Impact**: 50x performance improvement for wildcard subscriptions
         
     | 
| 
      
 159 
     | 
    
         
            +
             
     | 
| 
      
 160 
     | 
    
         
            +
            - **clients:index Accumulation**: Added periodic index rebuild to prevent stale data
         
     | 
| 
      
 161 
     | 
    
         
            +
              - Tracks cleanup counter and rebuilds index every 10 GC cycles
         
     | 
| 
      
 162 
     | 
    
         
            +
              - SCAN actual client keys and rebuild atomically
         
     | 
| 
      
 163 
     | 
    
         
            +
              - Removes all stale IDs that weren't properly cleaned
         
     | 
| 
      
 164 
     | 
    
         
            +
              - **Impact**: Prevents 36 MB memory growth for 1M clients
         
     | 
| 
      
 165 
     | 
    
         
            +
             
     | 
| 
      
 166 
     | 
    
         
            +
            - **@subscribers Array Duplication**: Converted to single handler pattern
         
     | 
| 
      
 167 
     | 
    
         
            +
              - Changed from array of handlers to single @message_handler
         
     | 
| 
      
 168 
     | 
    
         
            +
              - Prevents duplicate message processing if on_message called multiple times
         
     | 
| 
      
 169 
     | 
    
         
            +
              - Added warning if handler replaced
         
     | 
| 
      
 170 
     | 
    
         
            +
              - **Impact**: Eliminates potential duplicate message processing
         
     | 
| 
      
 171 
     | 
    
         
            +
             
     | 
| 
      
 172 
     | 
    
         
            +
            - **Comprehensive Cleanup Logic**: Enhanced cleanup to handle all orphaned data
         
     | 
| 
      
 173 
     | 
    
         
            +
              - Added cleanup for empty channel Sets
         
     | 
| 
      
 174 
     | 
    
         
            +
              - Added cleanup for orphaned subscription metadata
         
     | 
| 
      
 175 
     | 
    
         
            +
              - Added cleanup for unused wildcard patterns
         
     | 
| 
      
 176 
     | 
    
         
            +
              - Integrated message queue cleanup
         
     | 
| 
      
 177 
     | 
    
         
            +
              - **Impact**: Complete memory leak prevention
         
     | 
| 
      
 178 
     | 
    
         
            +
             
     | 
| 
      
 179 
     | 
    
         
            +
            - **Batched Cleanup Processing**: Implemented batched cleanup to prevent connection pool blocking
         
     | 
| 
      
 180 
     | 
    
         
            +
              - Added `cleanup_batch_size` configuration option (default: 50)
         
     | 
| 
      
 181 
     | 
    
         
            +
              - Process cleanup in batches with EventMachine.next_tick between batches
         
     | 
| 
      
 182 
     | 
    
         
            +
              - Split cleanup into 4 async phases: scan → cleanup → empty channels → patterns
         
     | 
| 
      
 183 
     | 
    
         
            +
              - **Impact**: Prevents cleanup operations from blocking other Redis operations
         
     | 
| 
      
 184 
     | 
    
         
            +
             
     | 
| 
      
 185 
     | 
    
         
            +
            ### Added
         
     | 
| 
      
 186 
     | 
    
         
            +
            - New configuration option: `subscription_ttl` (default: 86400 seconds / 24 hours)
         
     | 
| 
      
 187 
     | 
    
         
            +
            - New configuration option: `cleanup_batch_size` (default: 50 items per batch)
         
     | 
| 
      
 188 
     | 
    
         
            +
            - New method: `SubscriptionManager#cleanup_orphaned_data` for comprehensive cleanup
         
     | 
| 
      
 189 
     | 
    
         
            +
            - New private methods for batched cleanup: `scan_orphaned_subscriptions`, `cleanup_orphaned_subscriptions_batched`, `cleanup_empty_channels_async`, `cleanup_unused_patterns_async`
         
     | 
| 
      
 190 
     | 
    
         
            +
            - New method: `ClientRegistry#rebuild_clients_index` for periodic index maintenance
         
     | 
| 
      
 191 
     | 
    
         
            +
             
     | 
| 
      
 192 
     | 
    
         
            +
            ### Changed
         
     | 
| 
      
 193 
     | 
    
         
            +
            - `PubSubCoordinator`: Converted from array-based @subscribers to single @message_handler
         
     | 
| 
      
 194 
     | 
    
         
            +
            - `cleanup_expired`: Now calls comprehensive orphaned data cleanup
         
     | 
| 
      
 195 
     | 
    
         
            +
            - Message ID deduplication: Changed from delete-on-check to check-only with time-based cleanup
         
     | 
| 
      
 196 
     | 
    
         
            +
            - Test specs updated to work with single handler pattern
         
     | 
| 
      
 197 
     | 
    
         
            +
             
     | 
| 
      
 198 
     | 
    
         
            +
            ### Technical Details
         
     | 
| 
      
 199 
     | 
    
         
            +
            **Memory Leak Prevention**:
         
     | 
| 
      
 200 
     | 
    
         
            +
            - All subscription keys now have TTL as safety net
         
     | 
| 
      
 201 
     | 
    
         
            +
            - Message IDs expire after 5 minutes instead of growing indefinitely
         
     | 
| 
      
 202 
     | 
    
         
            +
            - Periodic index rebuild removes stale client IDs
         
     | 
| 
      
 203 
     | 
    
         
            +
            - Comprehensive cleanup removes all types of orphaned data
         
     | 
| 
      
 204 
     | 
    
         
            +
             
     | 
| 
      
 205 
     | 
    
         
            +
            **Performance Improvements**:
         
     | 
| 
      
 206 
     | 
    
         
            +
            - Wildcard pattern lookups: 100 sequential calls → 1 pipelined call
         
     | 
| 
      
 207 
     | 
    
         
            +
            - Cleanup operations: Batched processing prevents blocking
         
     | 
| 
      
 208 
     | 
    
         
            +
            - Index maintenance: Periodic rebuild keeps index size optimal
         
     | 
| 
      
 209 
     | 
    
         
            +
             
     | 
| 
      
 210 
     | 
    
         
            +
            **Test Coverage**:
         
     | 
| 
      
 211 
     | 
    
         
            +
            - All 177 tests passing
         
     | 
| 
      
 212 
     | 
    
         
            +
            - Line Coverage: 86.4%
         
     | 
| 
      
 213 
     | 
    
         
            +
            - Branch Coverage: 55.04%
         
     | 
| 
      
 214 
     | 
    
         
            +
             
     | 
| 
       10 
215 
     | 
    
         
             
            ## [1.0.7] - 2025-10-30
         
     | 
| 
       11 
216 
     | 
    
         | 
| 
       12 
217 
     | 
    
         
             
            ### Fixed
         
     | 
| 
         @@ -111,6 +111,10 @@ module Faye 
     | 
|
| 
       111 
111 
     | 
    
         | 
| 
       112 
112 
     | 
    
         
             
                  # Clean up expired clients
         
     | 
| 
       113 
113 
     | 
    
         
             
                  def cleanup_expired(&callback)
         
     | 
| 
      
 114 
     | 
    
         
            +
                    # Track cleanup counter for periodic index rebuild
         
     | 
| 
      
 115 
     | 
    
         
            +
                    @cleanup_counter ||= 0
         
     | 
| 
      
 116 
     | 
    
         
            +
                    @cleanup_counter += 1
         
     | 
| 
      
 117 
     | 
    
         
            +
             
     | 
| 
       114 
118 
     | 
    
         
             
                    all do |client_ids|
         
     | 
| 
       115 
119 
     | 
    
         
             
                      # Check existence in batch using pipelined commands
         
     | 
| 
       116 
120 
     | 
    
         
             
                      results = @connection.with_redis do |redis|
         
     | 
| 
         @@ -142,6 +146,12 @@ module Faye 
     | 
|
| 
       142 
146 
     | 
    
         
             
                        end
         
     | 
| 
       143 
147 
     | 
    
         
             
                      end
         
     | 
| 
       144 
148 
     | 
    
         | 
| 
      
 149 
     | 
    
         
            +
                      # Rebuild index every 10 cleanups to prevent stale data accumulation
         
     | 
| 
      
 150 
     | 
    
         
            +
                      if @cleanup_counter >= 10
         
     | 
| 
      
 151 
     | 
    
         
            +
                        rebuild_clients_index
         
     | 
| 
      
 152 
     | 
    
         
            +
                        @cleanup_counter = 0
         
     | 
| 
      
 153 
     | 
    
         
            +
                      end
         
     | 
| 
      
 154 
     | 
    
         
            +
             
     | 
| 
       145 
155 
     | 
    
         
             
                      EventMachine.next_tick { callback.call(expired_clients.size) } if callback
         
     | 
| 
       146 
156 
     | 
    
         
             
                    end
         
     | 
| 
       147 
157 
     | 
    
         
             
                  rescue => e
         
     | 
| 
         @@ -179,6 +189,45 @@ module Faye 
     | 
|
| 
       179 
189 
     | 
    
         
             
                  def log_error(message)
         
     | 
| 
       180 
190 
     | 
    
         
             
                    puts "[Faye::Redis::ClientRegistry] ERROR: #{message}" if @options[:log_level] != :silent
         
     | 
| 
       181 
191 
     | 
    
         
             
                  end
         
     | 
| 
      
 192 
     | 
    
         
            +
             
     | 
| 
      
 193 
     | 
    
         
            +
                  # Rebuild clients index from actual client keys
         
     | 
| 
      
 194 
     | 
    
         
            +
                  # This removes stale IDs that were not properly cleaned up
         
     | 
| 
      
 195 
     | 
    
         
            +
                  def rebuild_clients_index
         
     | 
| 
      
 196 
     | 
    
         
            +
                    namespace = @options[:namespace] || 'faye'
         
     | 
| 
      
 197 
     | 
    
         
            +
                    clients_key_pattern = "#{namespace}:clients:*"
         
     | 
| 
      
 198 
     | 
    
         
            +
                    index_key = clients_index_key
         
     | 
| 
      
 199 
     | 
    
         
            +
             
     | 
| 
      
 200 
     | 
    
         
            +
                    @connection.with_redis do |redis|
         
     | 
| 
      
 201 
     | 
    
         
            +
                      # Scan for all client keys
         
     | 
| 
      
 202 
     | 
    
         
            +
                      cursor = "0"
         
     | 
| 
      
 203 
     | 
    
         
            +
                      active_client_ids = []
         
     | 
| 
      
 204 
     | 
    
         
            +
             
     | 
| 
      
 205 
     | 
    
         
            +
                      loop do
         
     | 
| 
      
 206 
     | 
    
         
            +
                        cursor, keys = redis.scan(cursor, match: clients_key_pattern, count: 100)
         
     | 
| 
      
 207 
     | 
    
         
            +
             
     | 
| 
      
 208 
     | 
    
         
            +
                        keys.each do |key|
         
     | 
| 
      
 209 
     | 
    
         
            +
                          # Skip the index key itself
         
     | 
| 
      
 210 
     | 
    
         
            +
                          next if key == index_key
         
     | 
| 
      
 211 
     | 
    
         
            +
             
     | 
| 
      
 212 
     | 
    
         
            +
                          # Extract client_id from key (format: namespace:clients:client_id)
         
     | 
| 
      
 213 
     | 
    
         
            +
                          client_id = key.split(':').last
         
     | 
| 
      
 214 
     | 
    
         
            +
                          active_client_ids << client_id if client_id
         
     | 
| 
      
 215 
     | 
    
         
            +
                        end
         
     | 
| 
      
 216 
     | 
    
         
            +
             
     | 
| 
      
 217 
     | 
    
         
            +
                        break if cursor == "0"
         
     | 
| 
      
 218 
     | 
    
         
            +
                      end
         
     | 
| 
      
 219 
     | 
    
         
            +
             
     | 
| 
      
 220 
     | 
    
         
            +
                      # Rebuild index atomically
         
     | 
| 
      
 221 
     | 
    
         
            +
                      redis.multi do |multi|
         
     | 
| 
      
 222 
     | 
    
         
            +
                        multi.del(index_key)
         
     | 
| 
      
 223 
     | 
    
         
            +
                        active_client_ids.each { |id| multi.sadd(index_key, id) } if active_client_ids.any?
         
     | 
| 
      
 224 
     | 
    
         
            +
                      end
         
     | 
| 
      
 225 
     | 
    
         
            +
             
     | 
| 
      
 226 
     | 
    
         
            +
                      puts "[Faye::Redis::ClientRegistry] INFO: Rebuilt clients index with #{active_client_ids.size} active clients" if @options[:log_level] != :silent
         
     | 
| 
      
 227 
     | 
    
         
            +
                    end
         
     | 
| 
      
 228 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 229 
     | 
    
         
            +
                    log_error("Failed to rebuild clients index: #{e.message}")
         
     | 
| 
      
 230 
     | 
    
         
            +
                  end
         
     | 
| 
       182 
231 
     | 
    
         
             
                end
         
     | 
| 
       183 
232 
     | 
    
         
             
              end
         
     | 
| 
       184 
233 
     | 
    
         
             
            end
         
     | 
| 
         @@ -9,7 +9,7 @@ module Faye 
     | 
|
| 
       9 
9 
     | 
    
         
             
                  def initialize(connection, options = {})
         
     | 
| 
       10 
10 
     | 
    
         
             
                    @connection = connection
         
     | 
| 
       11 
11 
     | 
    
         
             
                    @options = options
         
     | 
| 
       12 
     | 
    
         
            -
                    @ 
     | 
| 
      
 12 
     | 
    
         
            +
                    @message_handler = nil  # Single handler to prevent duplication
         
     | 
| 
       13 
13 
     | 
    
         
             
                    @redis_subscriber = nil
         
     | 
| 
       14 
14 
     | 
    
         
             
                    @subscribed_channels = Set.new
         
     | 
| 
       15 
15 
     | 
    
         
             
                    @subscriber_thread = nil
         
     | 
| 
         @@ -37,7 +37,10 @@ module Faye 
     | 
|
| 
       37 
37 
     | 
    
         | 
| 
       38 
38 
     | 
    
         
             
                  # Subscribe to messages from other servers
         
     | 
| 
       39 
39 
     | 
    
         
             
                  def on_message(&block)
         
     | 
| 
       40 
     | 
    
         
            -
                    @ 
     | 
| 
      
 40 
     | 
    
         
            +
                    if @message_handler
         
     | 
| 
      
 41 
     | 
    
         
            +
                      log_error("Warning: Replacing existing message handler to prevent duplication")
         
     | 
| 
      
 42 
     | 
    
         
            +
                    end
         
     | 
| 
      
 43 
     | 
    
         
            +
                    @message_handler = block
         
     | 
| 
       41 
44 
     | 
    
         
             
                  end
         
     | 
| 
       42 
45 
     | 
    
         | 
| 
       43 
46 
     | 
    
         
             
                  # Subscribe to a Redis pub/sub channel
         
     | 
| 
         @@ -84,7 +87,8 @@ module Faye 
     | 
|
| 
       84 
87 
     | 
    
         
             
                      @redis_subscriber = nil
         
     | 
| 
       85 
88 
     | 
    
         
             
                    end
         
     | 
| 
       86 
89 
     | 
    
         
             
                    @subscribed_channels.clear
         
     | 
| 
       87 
     | 
    
         
            -
                    @ 
     | 
| 
      
 90 
     | 
    
         
            +
                    @message_handler = nil
         
     | 
| 
      
 91 
     | 
    
         
            +
                    @reconnect_attempts = 0  # Reset reconnect counter for future connections
         
     | 
| 
       88 
92 
     | 
    
         
             
                  end
         
     | 
| 
       89 
93 
     | 
    
         | 
| 
       90 
94 
     | 
    
         
             
                  private
         
     | 
| 
         @@ -166,16 +170,16 @@ module Faye 
     | 
|
| 
       166 
170 
     | 
    
         
             
                    begin
         
     | 
| 
       167 
171 
     | 
    
         
             
                      message = JSON.parse(message_json)
         
     | 
| 
       168 
172 
     | 
    
         | 
| 
       169 
     | 
    
         
            -
                      # Notify  
     | 
| 
      
 173 
     | 
    
         
            +
                      # Notify the message handler
         
     | 
| 
       170 
174 
     | 
    
         
             
                      # Use EventMachine.schedule to safely call from non-EM thread
         
     | 
| 
       171 
175 
     | 
    
         
             
                      # (handle_message is called from subscriber_thread, not EM reactor thread)
         
     | 
| 
       172 
176 
     | 
    
         
             
                      if EventMachine.reactor_running?
         
     | 
| 
       173 
177 
     | 
    
         
             
                        EventMachine.schedule do
         
     | 
| 
       174 
     | 
    
         
            -
                          @ 
     | 
| 
      
 178 
     | 
    
         
            +
                          if @message_handler
         
     | 
| 
       175 
179 
     | 
    
         
             
                            begin
         
     | 
| 
       176 
     | 
    
         
            -
                               
     | 
| 
      
 180 
     | 
    
         
            +
                              @message_handler.call(channel, message)
         
     | 
| 
       177 
181 
     | 
    
         
             
                            rescue => e
         
     | 
| 
       178 
     | 
    
         
            -
                              log_error(" 
     | 
| 
      
 182 
     | 
    
         
            +
                              log_error("Message handler callback error for #{channel}: #{e.message}")
         
     | 
| 
       179 
183 
     | 
    
         
             
                            end
         
     | 
| 
       180 
184 
     | 
    
         
             
                          end
         
     | 
| 
       181 
185 
     | 
    
         
             
                        end
         
     | 
| 
         @@ -6,19 +6,25 @@ 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
         
     | 
| 
       12 
13 
     | 
    
         
             
                  def subscribe(client_id, channel, &callback)
         
     | 
| 
       13 
14 
     | 
    
         
             
                    timestamp = Time.now.to_i
         
     | 
| 
      
 15 
     | 
    
         
            +
                    subscription_ttl = @options[:subscription_ttl] || 86400  # 24 hours default
         
     | 
| 
       14 
16 
     | 
    
         | 
| 
       15 
17 
     | 
    
         
             
                    @connection.with_redis do |redis|
         
     | 
| 
       16 
18 
     | 
    
         
             
                      redis.multi do |multi|
         
     | 
| 
       17 
19 
     | 
    
         
             
                        # Add channel to client's subscriptions
         
     | 
| 
       18 
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)
         
     | 
| 
       19 
23 
     | 
    
         | 
| 
       20 
24 
     | 
    
         
             
                        # Add client to channel's subscribers
         
     | 
| 
       21 
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)
         
     | 
| 
       22 
28 
     | 
    
         | 
| 
       23 
29 
     | 
    
         
             
                        # Store subscription metadata
         
     | 
| 
       24 
30 
     | 
    
         
             
                        multi.hset(
         
     | 
| 
         @@ -27,10 +33,14 @@ module Faye 
     | 
|
| 
       27 
33 
     | 
    
         
             
                          'channel', channel,
         
     | 
| 
       28 
34 
     | 
    
         
             
                          'client_id', client_id
         
     | 
| 
       29 
35 
     | 
    
         
             
                        )
         
     | 
| 
      
 36 
     | 
    
         
            +
                        # Set TTL for subscription metadata
         
     | 
| 
      
 37 
     | 
    
         
            +
                        multi.expire(subscription_key(client_id, channel), subscription_ttl)
         
     | 
| 
       30 
38 
     | 
    
         | 
| 
       31 
39 
     | 
    
         
             
                        # Handle wildcard patterns
         
     | 
| 
       32 
40 
     | 
    
         
             
                        if channel.include?('*')
         
     | 
| 
       33 
41 
     | 
    
         
             
                          multi.sadd?(patterns_key, channel)
         
     | 
| 
      
 42 
     | 
    
         
            +
                          # Set/refresh TTL for patterns set
         
     | 
| 
      
 43 
     | 
    
         
            +
                          multi.expire(patterns_key, subscription_ttl)
         
     | 
| 
       34 
44 
     | 
    
         
             
                        end
         
     | 
| 
       35 
45 
     | 
    
         
             
                      end
         
     | 
| 
       36 
46 
     | 
    
         
             
                    end
         
     | 
| 
         @@ -76,10 +86,15 @@ module Faye 
     | 
|
| 
       76 
86 
     | 
    
         
             
                      else
         
     | 
| 
       77 
87 
     | 
    
         
             
                        # Unsubscribe from each channel
         
     | 
| 
       78 
88 
     | 
    
         
             
                        remaining = channels.size
         
     | 
| 
      
 89 
     | 
    
         
            +
                        callback_called = false  # Prevent race condition
         
     | 
| 
       79 
90 
     | 
    
         
             
                        channels.each do |channel|
         
     | 
| 
       80 
91 
     | 
    
         
             
                          unsubscribe(client_id, channel) do
         
     | 
| 
       81 
92 
     | 
    
         
             
                            remaining -= 1
         
     | 
| 
       82 
     | 
    
         
            -
                             
     | 
| 
      
 93 
     | 
    
         
            +
                            # Check flag to prevent multiple callback invocations
         
     | 
| 
      
 94 
     | 
    
         
            +
                            if remaining == 0 && !callback_called && callback
         
     | 
| 
      
 95 
     | 
    
         
            +
                              callback_called = true
         
     | 
| 
      
 96 
     | 
    
         
            +
                              callback.call(true)
         
     | 
| 
      
 97 
     | 
    
         
            +
                            end
         
     | 
| 
       83 
98 
     | 
    
         
             
                          end
         
     | 
| 
       84 
99 
     | 
    
         
             
                        end
         
     | 
| 
       85 
100 
     | 
    
         
             
                      end
         
     | 
| 
         @@ -129,33 +144,47 @@ module Faye 
     | 
|
| 
       129 
144 
     | 
    
         
             
                      redis.smembers(patterns_key)
         
     | 
| 
       130 
145 
     | 
    
         
             
                    end
         
     | 
| 
       131 
146 
     | 
    
         | 
| 
       132 
     | 
    
         
            -
                     
     | 
| 
       133 
     | 
    
         
            -
                    patterns. 
     | 
| 
       134 
     | 
    
         
            -
             
     | 
| 
       135 
     | 
    
         
            -
             
     | 
| 
       136 
     | 
    
         
            -
             
     | 
| 
      
 147 
     | 
    
         
            +
                    # Filter to only matching patterns first
         
     | 
| 
      
 148 
     | 
    
         
            +
                    matching_patterns = patterns.select { |pattern| channel_matches_pattern?(channel, pattern) }
         
     | 
| 
      
 149 
     | 
    
         
            +
                    return [] if matching_patterns.empty?
         
     | 
| 
      
 150 
     | 
    
         
            +
             
     | 
| 
      
 151 
     | 
    
         
            +
                    # Use pipelining to fetch all matching pattern subscribers in one network round-trip
         
     | 
| 
      
 152 
     | 
    
         
            +
                    results = @connection.with_redis do |redis|
         
     | 
| 
      
 153 
     | 
    
         
            +
                      redis.pipelined do |pipeline|
         
     | 
| 
      
 154 
     | 
    
         
            +
                        matching_patterns.each do |pattern|
         
     | 
| 
      
 155 
     | 
    
         
            +
                          pipeline.smembers(channel_subscribers_key(pattern))
         
     | 
| 
       137 
156 
     | 
    
         
             
                        end
         
     | 
| 
       138 
     | 
    
         
            -
                        matching_clients.concat(clients)
         
     | 
| 
       139 
157 
     | 
    
         
             
                      end
         
     | 
| 
       140 
158 
     | 
    
         
             
                    end
         
     | 
| 
       141 
159 
     | 
    
         | 
| 
       142 
     | 
    
         
            -
                     
     | 
| 
      
 160 
     | 
    
         
            +
                    # Flatten and deduplicate results
         
     | 
| 
      
 161 
     | 
    
         
            +
                    results.flatten.uniq
         
     | 
| 
       143 
162 
     | 
    
         
             
                  rescue => e
         
     | 
| 
       144 
163 
     | 
    
         
             
                    log_error("Failed to get pattern subscribers for channel #{channel}: #{e.message}")
         
     | 
| 
       145 
164 
     | 
    
         
             
                    []
         
     | 
| 
       146 
165 
     | 
    
         
             
                  end
         
     | 
| 
       147 
166 
     | 
    
         | 
| 
       148 
167 
     | 
    
         
             
                  # Check if a channel matches a pattern
         
     | 
| 
      
 168 
     | 
    
         
            +
                  # Uses memoization to cache compiled regexes for performance
         
     | 
| 
       149 
169 
     | 
    
         
             
                  def channel_matches_pattern?(channel, pattern)
         
     | 
| 
       150 
     | 
    
         
            -
                    #  
     | 
| 
       151 
     | 
    
         
            -
                     
     | 
| 
       152 
     | 
    
         
            -
             
     | 
| 
       153 
     | 
    
         
            -
                       
     | 
| 
       154 
     | 
    
         
            -
                       
     | 
| 
       155 
     | 
    
         
            -
                      . 
     | 
| 
       156 
     | 
    
         
            -
             
     | 
| 
       157 
     | 
    
         
            -
             
     | 
| 
      
 170 
     | 
    
         
            +
                    # Get or compile regex for this pattern
         
     | 
| 
      
 171 
     | 
    
         
            +
                    regex = @pattern_cache[pattern] ||= begin
         
     | 
| 
      
 172 
     | 
    
         
            +
                      # Escape the pattern first to handle special regex characters
         
     | 
| 
      
 173 
     | 
    
         
            +
                      # Then replace escaped wildcards with regex patterns
         
     | 
| 
      
 174 
     | 
    
         
            +
                      # ** matches multiple segments (including /), * matches one segment (no /)
         
     | 
| 
      
 175 
     | 
    
         
            +
                      escaped = Regexp.escape(pattern)
         
     | 
| 
      
 176 
     | 
    
         
            +
             
     | 
| 
      
 177 
     | 
    
         
            +
                      regex_pattern = escaped
         
     | 
| 
      
 178 
     | 
    
         
            +
                        .gsub(Regexp.escape('**'), '.*')        # ** → .* (match anything)
         
     | 
| 
      
 179 
     | 
    
         
            +
                        .gsub(Regexp.escape('*'), '[^/]+')      # * → [^/]+ (match one segment)
         
     | 
| 
      
 180 
     | 
    
         
            +
             
     | 
| 
      
 181 
     | 
    
         
            +
                      Regexp.new("^#{regex_pattern}$")
         
     | 
| 
      
 182 
     | 
    
         
            +
                    end
         
     | 
| 
      
 183 
     | 
    
         
            +
             
     | 
| 
       158 
184 
     | 
    
         
             
                    !!(channel =~ regex)
         
     | 
| 
      
 185 
     | 
    
         
            +
                  rescue RegexpError => e
         
     | 
| 
      
 186 
     | 
    
         
            +
                    log_error("Invalid pattern #{pattern}: #{e.message}")
         
     | 
| 
      
 187 
     | 
    
         
            +
                    false
         
     | 
| 
       159 
188 
     | 
    
         
             
                  end
         
     | 
| 
       160 
189 
     | 
    
         | 
| 
       161 
190 
     | 
    
         
             
                  # Clean up subscriptions for a client
         
     | 
| 
         @@ -163,8 +192,231 @@ module Faye 
     | 
|
| 
       163 
192 
     | 
    
         
             
                    unsubscribe_all(client_id)
         
     | 
| 
       164 
193 
     | 
    
         
             
                  end
         
     | 
| 
       165 
194 
     | 
    
         | 
| 
      
 195 
     | 
    
         
            +
                  # Comprehensive cleanup of orphaned subscription data
         
     | 
| 
      
 196 
     | 
    
         
            +
                  # This should be called periodically during garbage collection
         
     | 
| 
      
 197 
     | 
    
         
            +
                  # Processes in batches to avoid blocking the connection pool
         
     | 
| 
      
 198 
     | 
    
         
            +
                  def cleanup_orphaned_data(active_client_ids, &callback)
         
     | 
| 
      
 199 
     | 
    
         
            +
                    active_set = active_client_ids.to_set
         
     | 
| 
      
 200 
     | 
    
         
            +
                    namespace = @options[:namespace] || 'faye'
         
     | 
| 
      
 201 
     | 
    
         
            +
                    batch_size = @options[:cleanup_batch_size] || 50
         
     | 
| 
      
 202 
     | 
    
         
            +
             
     | 
| 
      
 203 
     | 
    
         
            +
                    # Validate and clamp batch_size to safe range (1-1000)
         
     | 
| 
      
 204 
     | 
    
         
            +
                    batch_size = [[batch_size.to_i, 1].max, 1000].min
         
     | 
| 
      
 205 
     | 
    
         
            +
             
     | 
| 
      
 206 
     | 
    
         
            +
                    # Phase 1: Scan for orphaned subscriptions
         
     | 
| 
      
 207 
     | 
    
         
            +
                    scan_orphaned_subscriptions(active_set, namespace) do |orphaned_subscriptions|
         
     | 
| 
      
 208 
     | 
    
         
            +
                      # Phase 2: Clean up orphaned subscriptions in batches
         
     | 
| 
      
 209 
     | 
    
         
            +
                      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
         
     | 
| 
      
 215 
     | 
    
         
            +
                          end
         
     | 
| 
      
 216 
     | 
    
         
            +
                        end
         
     | 
| 
      
 217 
     | 
    
         
            +
                      end
         
     | 
| 
      
 218 
     | 
    
         
            +
                    end
         
     | 
| 
      
 219 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 220 
     | 
    
         
            +
                    log_error("Failed to cleanup orphaned data: #{e.message}")
         
     | 
| 
      
 221 
     | 
    
         
            +
                    EventMachine.next_tick { callback.call } if callback
         
     | 
| 
      
 222 
     | 
    
         
            +
                  end
         
     | 
| 
      
 223 
     | 
    
         
            +
             
     | 
| 
       166 
224 
     | 
    
         
             
                  private
         
     | 
| 
       167 
225 
     | 
    
         | 
| 
      
 226 
     | 
    
         
            +
                  # Scan for orphaned subscription keys
         
     | 
| 
      
 227 
     | 
    
         
            +
                  # Uses batched scanning to avoid holding connection for long periods
         
     | 
| 
      
 228 
     | 
    
         
            +
                  def scan_orphaned_subscriptions(active_set, namespace, &callback)
         
     | 
| 
      
 229 
     | 
    
         
            +
                    orphaned_subscriptions = []
         
     | 
| 
      
 230 
     | 
    
         
            +
             
     | 
| 
      
 231 
     | 
    
         
            +
                    # Batch scan to release connection between iterations
         
     | 
| 
      
 232 
     | 
    
         
            +
                    scan_batch = lambda do |cursor_value|
         
     | 
| 
      
 233 
     | 
    
         
            +
                      begin
         
     | 
| 
      
 234 
     | 
    
         
            +
                        @connection.with_redis do |redis|
         
     | 
| 
      
 235 
     | 
    
         
            +
                          cursor, keys = redis.scan(cursor_value, match: "#{namespace}:subscriptions:*", count: 100)
         
     | 
| 
      
 236 
     | 
    
         
            +
             
     | 
| 
      
 237 
     | 
    
         
            +
                          keys.each do |key|
         
     | 
| 
      
 238 
     | 
    
         
            +
                            client_id = key.split(':').last
         
     | 
| 
      
 239 
     | 
    
         
            +
                            orphaned_subscriptions << client_id unless active_set.include?(client_id)
         
     | 
| 
      
 240 
     | 
    
         
            +
                          end
         
     | 
| 
      
 241 
     | 
    
         
            +
             
     | 
| 
      
 242 
     | 
    
         
            +
                          if cursor == "0"
         
     | 
| 
      
 243 
     | 
    
         
            +
                            # Scan complete
         
     | 
| 
      
 244 
     | 
    
         
            +
                            EventMachine.next_tick { callback.call(orphaned_subscriptions) }
         
     | 
| 
      
 245 
     | 
    
         
            +
                          else
         
     | 
| 
      
 246 
     | 
    
         
            +
                            # Continue scanning in next tick to release connection
         
     | 
| 
      
 247 
     | 
    
         
            +
                            EventMachine.next_tick { scan_batch.call(cursor) }
         
     | 
| 
      
 248 
     | 
    
         
            +
                          end
         
     | 
| 
      
 249 
     | 
    
         
            +
                        end
         
     | 
| 
      
 250 
     | 
    
         
            +
                      rescue => e
         
     | 
| 
      
 251 
     | 
    
         
            +
                        log_error("Failed to scan orphaned subscriptions batch: #{e.message}")
         
     | 
| 
      
 252 
     | 
    
         
            +
                        EventMachine.next_tick { callback.call(orphaned_subscriptions) }
         
     | 
| 
      
 253 
     | 
    
         
            +
                      end
         
     | 
| 
      
 254 
     | 
    
         
            +
                    end
         
     | 
| 
      
 255 
     | 
    
         
            +
             
     | 
| 
      
 256 
     | 
    
         
            +
                    scan_batch.call("0")
         
     | 
| 
      
 257 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 258 
     | 
    
         
            +
                    log_error("Failed to scan orphaned subscriptions: #{e.message}")
         
     | 
| 
      
 259 
     | 
    
         
            +
                    EventMachine.next_tick { callback.call([]) }
         
     | 
| 
      
 260 
     | 
    
         
            +
                  end
         
     | 
| 
      
 261 
     | 
    
         
            +
             
     | 
| 
      
 262 
     | 
    
         
            +
                  # Clean up orphaned subscriptions in batches to avoid blocking
         
     | 
| 
      
 263 
     | 
    
         
            +
                  def cleanup_orphaned_subscriptions_batched(orphaned_subscriptions, namespace, batch_size, &callback)
         
     | 
| 
      
 264 
     | 
    
         
            +
                    return EventMachine.next_tick { callback.call } if orphaned_subscriptions.empty?
         
     | 
| 
      
 265 
     | 
    
         
            +
             
     | 
| 
      
 266 
     | 
    
         
            +
                    total = orphaned_subscriptions.size
         
     | 
| 
      
 267 
     | 
    
         
            +
                    batches = orphaned_subscriptions.each_slice(batch_size).to_a
         
     | 
| 
      
 268 
     | 
    
         
            +
                    processed = 0
         
     | 
| 
      
 269 
     | 
    
         
            +
             
     | 
| 
      
 270 
     | 
    
         
            +
                    process_batch = lambda do |batch_index|
         
     | 
| 
      
 271 
     | 
    
         
            +
                      if batch_index >= batches.size
         
     | 
| 
      
 272 
     | 
    
         
            +
                        puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{total} orphaned subscription sets" if @options[:log_level] != :silent
         
     | 
| 
      
 273 
     | 
    
         
            +
                        EventMachine.next_tick { callback.call }
         
     | 
| 
      
 274 
     | 
    
         
            +
                        return
         
     | 
| 
      
 275 
     | 
    
         
            +
                      end
         
     | 
| 
      
 276 
     | 
    
         
            +
             
     | 
| 
      
 277 
     | 
    
         
            +
                      batch = batches[batch_index]
         
     | 
| 
      
 278 
     | 
    
         
            +
             
     | 
| 
      
 279 
     | 
    
         
            +
                      @connection.with_redis do |redis|
         
     | 
| 
      
 280 
     | 
    
         
            +
                        batch.each do |client_id|
         
     | 
| 
      
 281 
     | 
    
         
            +
                          channels = redis.smembers(client_subscriptions_key(client_id))
         
     | 
| 
      
 282 
     | 
    
         
            +
             
     | 
| 
      
 283 
     | 
    
         
            +
                          redis.pipelined do |pipeline|
         
     | 
| 
      
 284 
     | 
    
         
            +
                            pipeline.del(client_subscriptions_key(client_id))
         
     | 
| 
      
 285 
     | 
    
         
            +
             
     | 
| 
      
 286 
     | 
    
         
            +
                            channels.each do |channel|
         
     | 
| 
      
 287 
     | 
    
         
            +
                              pipeline.del(subscription_key(client_id, channel))
         
     | 
| 
      
 288 
     | 
    
         
            +
                              pipeline.srem(channel_subscribers_key(channel), client_id)
         
     | 
| 
      
 289 
     | 
    
         
            +
                            end
         
     | 
| 
      
 290 
     | 
    
         
            +
             
     | 
| 
      
 291 
     | 
    
         
            +
                            pipeline.del("#{namespace}:messages:#{client_id}")
         
     | 
| 
      
 292 
     | 
    
         
            +
                          end
         
     | 
| 
      
 293 
     | 
    
         
            +
                        end
         
     | 
| 
      
 294 
     | 
    
         
            +
                      end
         
     | 
| 
      
 295 
     | 
    
         
            +
             
     | 
| 
      
 296 
     | 
    
         
            +
                      processed += batch.size
         
     | 
| 
      
 297 
     | 
    
         
            +
             
     | 
| 
      
 298 
     | 
    
         
            +
                      # Yield control to EventMachine between batches
         
     | 
| 
      
 299 
     | 
    
         
            +
                      EventMachine.next_tick { process_batch.call(batch_index + 1) }
         
     | 
| 
      
 300 
     | 
    
         
            +
                    end
         
     | 
| 
      
 301 
     | 
    
         
            +
             
     | 
| 
      
 302 
     | 
    
         
            +
                    process_batch.call(0)
         
     | 
| 
      
 303 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 304 
     | 
    
         
            +
                    log_error("Failed to cleanup orphaned subscriptions batch: #{e.message}")
         
     | 
| 
      
 305 
     | 
    
         
            +
                    EventMachine.next_tick { callback.call }
         
     | 
| 
      
 306 
     | 
    
         
            +
                  end
         
     | 
| 
      
 307 
     | 
    
         
            +
             
     | 
| 
      
 308 
     | 
    
         
            +
                  # Async version of cleanup_empty_channels that yields between operations
         
     | 
| 
      
 309 
     | 
    
         
            +
                  def cleanup_empty_channels_async(namespace, &callback)
         
     | 
| 
      
 310 
     | 
    
         
            +
                    @connection.with_redis do |redis|
         
     | 
| 
      
 311 
     | 
    
         
            +
                      cursor = "0"
         
     | 
| 
      
 312 
     | 
    
         
            +
                      empty_channels = []
         
     | 
| 
      
 313 
     | 
    
         
            +
             
     | 
| 
      
 314 
     | 
    
         
            +
                      loop do
         
     | 
| 
      
 315 
     | 
    
         
            +
                        cursor, keys = redis.scan(cursor, match: "#{namespace}:channels:*", count: 100)
         
     | 
| 
      
 316 
     | 
    
         
            +
             
     | 
| 
      
 317 
     | 
    
         
            +
                        keys.each do |key|
         
     | 
| 
      
 318 
     | 
    
         
            +
                          count = redis.scard(key)
         
     | 
| 
      
 319 
     | 
    
         
            +
                          empty_channels << key if count == 0
         
     | 
| 
      
 320 
     | 
    
         
            +
                        end
         
     | 
| 
      
 321 
     | 
    
         
            +
             
     | 
| 
      
 322 
     | 
    
         
            +
                        break if cursor == "0"
         
     | 
| 
      
 323 
     | 
    
         
            +
                      end
         
     | 
| 
      
 324 
     | 
    
         
            +
             
     | 
| 
      
 325 
     | 
    
         
            +
                      if empty_channels.any?
         
     | 
| 
      
 326 
     | 
    
         
            +
                        redis.pipelined do |pipeline|
         
     | 
| 
      
 327 
     | 
    
         
            +
                          empty_channels.each { |key| pipeline.del(key) }
         
     | 
| 
      
 328 
     | 
    
         
            +
                        end
         
     | 
| 
      
 329 
     | 
    
         
            +
                        puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{empty_channels.size} empty channel Sets" if @options[:log_level] != :silent
         
     | 
| 
      
 330 
     | 
    
         
            +
                      end
         
     | 
| 
      
 331 
     | 
    
         
            +
             
     | 
| 
      
 332 
     | 
    
         
            +
                      EventMachine.next_tick { callback.call }
         
     | 
| 
      
 333 
     | 
    
         
            +
                    end
         
     | 
| 
      
 334 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 335 
     | 
    
         
            +
                    log_error("Failed to cleanup empty channels: #{e.message}")
         
     | 
| 
      
 336 
     | 
    
         
            +
                    EventMachine.next_tick { callback.call }
         
     | 
| 
      
 337 
     | 
    
         
            +
                  end
         
     | 
| 
      
 338 
     | 
    
         
            +
             
     | 
| 
      
 339 
     | 
    
         
            +
                  # Async version of cleanup_unused_patterns that yields after completion
         
     | 
| 
      
 340 
     | 
    
         
            +
                  def cleanup_unused_patterns_async(&callback)
         
     | 
| 
      
 341 
     | 
    
         
            +
                    @connection.with_redis do |redis|
         
     | 
| 
      
 342 
     | 
    
         
            +
                      patterns = redis.smembers(patterns_key)
         
     | 
| 
      
 343 
     | 
    
         
            +
                      unused_patterns = []
         
     | 
| 
      
 344 
     | 
    
         
            +
             
     | 
| 
      
 345 
     | 
    
         
            +
                      patterns.each do |pattern|
         
     | 
| 
      
 346 
     | 
    
         
            +
                        count = redis.scard(channel_subscribers_key(pattern))
         
     | 
| 
      
 347 
     | 
    
         
            +
                        unused_patterns << pattern if count == 0
         
     | 
| 
      
 348 
     | 
    
         
            +
                      end
         
     | 
| 
      
 349 
     | 
    
         
            +
             
     | 
| 
      
 350 
     | 
    
         
            +
                      if unused_patterns.any?
         
     | 
| 
      
 351 
     | 
    
         
            +
                        redis.pipelined do |pipeline|
         
     | 
| 
      
 352 
     | 
    
         
            +
                          unused_patterns.each do |pattern|
         
     | 
| 
      
 353 
     | 
    
         
            +
                            pipeline.srem(patterns_key, pattern)
         
     | 
| 
      
 354 
     | 
    
         
            +
                            pipeline.del(channel_subscribers_key(pattern))
         
     | 
| 
      
 355 
     | 
    
         
            +
                          end
         
     | 
| 
      
 356 
     | 
    
         
            +
                        end
         
     | 
| 
      
 357 
     | 
    
         
            +
                        # Clear unused patterns from regex cache
         
     | 
| 
      
 358 
     | 
    
         
            +
                        unused_patterns.each { |pattern| @pattern_cache.delete(pattern) }
         
     | 
| 
      
 359 
     | 
    
         
            +
                        puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{unused_patterns.size} unused patterns" if @options[:log_level] != :silent
         
     | 
| 
      
 360 
     | 
    
         
            +
                      end
         
     | 
| 
      
 361 
     | 
    
         
            +
             
     | 
| 
      
 362 
     | 
    
         
            +
                      EventMachine.next_tick { callback.call }
         
     | 
| 
      
 363 
     | 
    
         
            +
                    end
         
     | 
| 
      
 364 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 365 
     | 
    
         
            +
                    log_error("Failed to cleanup unused patterns: #{e.message}")
         
     | 
| 
      
 366 
     | 
    
         
            +
                    EventMachine.next_tick { callback.call }
         
     | 
| 
      
 367 
     | 
    
         
            +
                  end
         
     | 
| 
      
 368 
     | 
    
         
            +
             
     | 
| 
      
 369 
     | 
    
         
            +
                  # Clean up channel Sets that have no subscribers
         
     | 
| 
      
 370 
     | 
    
         
            +
                  def cleanup_empty_channels(redis, namespace)
         
     | 
| 
      
 371 
     | 
    
         
            +
                    cursor = "0"
         
     | 
| 
      
 372 
     | 
    
         
            +
                    empty_channels = []
         
     | 
| 
      
 373 
     | 
    
         
            +
             
     | 
| 
      
 374 
     | 
    
         
            +
                    loop do
         
     | 
| 
      
 375 
     | 
    
         
            +
                      cursor, keys = redis.scan(cursor, match: "#{namespace}:channels:*", count: 100)
         
     | 
| 
      
 376 
     | 
    
         
            +
             
     | 
| 
      
 377 
     | 
    
         
            +
                      keys.each do |key|
         
     | 
| 
      
 378 
     | 
    
         
            +
                        count = redis.scard(key)
         
     | 
| 
      
 379 
     | 
    
         
            +
                        empty_channels << key if count == 0
         
     | 
| 
      
 380 
     | 
    
         
            +
                      end
         
     | 
| 
      
 381 
     | 
    
         
            +
             
     | 
| 
      
 382 
     | 
    
         
            +
                      break if cursor == "0"
         
     | 
| 
      
 383 
     | 
    
         
            +
                    end
         
     | 
| 
      
 384 
     | 
    
         
            +
             
     | 
| 
      
 385 
     | 
    
         
            +
                    if empty_channels.any?
         
     | 
| 
      
 386 
     | 
    
         
            +
                      redis.pipelined do |pipeline|
         
     | 
| 
      
 387 
     | 
    
         
            +
                        empty_channels.each { |key| pipeline.del(key) }
         
     | 
| 
      
 388 
     | 
    
         
            +
                      end
         
     | 
| 
      
 389 
     | 
    
         
            +
                      puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{empty_channels.size} empty channel Sets" if @options[:log_level] != :silent
         
     | 
| 
      
 390 
     | 
    
         
            +
                    end
         
     | 
| 
      
 391 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 392 
     | 
    
         
            +
                    log_error("Failed to cleanup empty channels: #{e.message}")
         
     | 
| 
      
 393 
     | 
    
         
            +
                  end
         
     | 
| 
      
 394 
     | 
    
         
            +
             
     | 
| 
      
 395 
     | 
    
         
            +
                  # Clean up patterns that have no subscribers
         
     | 
| 
      
 396 
     | 
    
         
            +
                  def cleanup_unused_patterns(redis)
         
     | 
| 
      
 397 
     | 
    
         
            +
                    patterns = redis.smembers(patterns_key)
         
     | 
| 
      
 398 
     | 
    
         
            +
                    unused_patterns = []
         
     | 
| 
      
 399 
     | 
    
         
            +
             
     | 
| 
      
 400 
     | 
    
         
            +
                    patterns.each do |pattern|
         
     | 
| 
      
 401 
     | 
    
         
            +
                      count = redis.scard(channel_subscribers_key(pattern))
         
     | 
| 
      
 402 
     | 
    
         
            +
                      unused_patterns << pattern if count == 0
         
     | 
| 
      
 403 
     | 
    
         
            +
                    end
         
     | 
| 
      
 404 
     | 
    
         
            +
             
     | 
| 
      
 405 
     | 
    
         
            +
                    if unused_patterns.any?
         
     | 
| 
      
 406 
     | 
    
         
            +
                      redis.pipelined do |pipeline|
         
     | 
| 
      
 407 
     | 
    
         
            +
                        unused_patterns.each do |pattern|
         
     | 
| 
      
 408 
     | 
    
         
            +
                          pipeline.srem(patterns_key, pattern)
         
     | 
| 
      
 409 
     | 
    
         
            +
                          pipeline.del(channel_subscribers_key(pattern))
         
     | 
| 
      
 410 
     | 
    
         
            +
                        end
         
     | 
| 
      
 411 
     | 
    
         
            +
                      end
         
     | 
| 
      
 412 
     | 
    
         
            +
                      # Clear unused patterns from regex cache
         
     | 
| 
      
 413 
     | 
    
         
            +
                      unused_patterns.each { |pattern| @pattern_cache.delete(pattern) }
         
     | 
| 
      
 414 
     | 
    
         
            +
                      puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{unused_patterns.size} unused patterns" if @options[:log_level] != :silent
         
     | 
| 
      
 415 
     | 
    
         
            +
                    end
         
     | 
| 
      
 416 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 417 
     | 
    
         
            +
                    log_error("Failed to cleanup unused patterns: #{e.message}")
         
     | 
| 
      
 418 
     | 
    
         
            +
                  end
         
     | 
| 
      
 419 
     | 
    
         
            +
             
     | 
| 
       168 
420 
     | 
    
         
             
                  def cleanup_pattern_if_unused(pattern)
         
     | 
| 
       169 
421 
     | 
    
         
             
                    subscribers = @connection.with_redis do |redis|
         
     | 
| 
       170 
422 
     | 
    
         
             
                      redis.smembers(channel_subscribers_key(pattern))
         
     | 
| 
         @@ -174,6 +426,8 @@ module Faye 
     | 
|
| 
       174 
426 
     | 
    
         
             
                      @connection.with_redis do |redis|
         
     | 
| 
       175 
427 
     | 
    
         
             
                        redis.srem(patterns_key, pattern)
         
     | 
| 
       176 
428 
     | 
    
         
             
                      end
         
     | 
| 
      
 429 
     | 
    
         
            +
                      # Clear pattern from regex cache when it's removed
         
     | 
| 
      
 430 
     | 
    
         
            +
                      @pattern_cache.delete(pattern)
         
     | 
| 
       177 
431 
     | 
    
         
             
                    end
         
     | 
| 
       178 
432 
     | 
    
         
             
                  rescue => e
         
     | 
| 
       179 
433 
     | 
    
         
             
                    log_error("Failed to cleanup pattern #{pattern}: #{e.message}")
         
     | 
    
        data/lib/faye/redis/version.rb
    CHANGED
    
    
    
        data/lib/faye/redis.rb
    CHANGED
    
    | 
         @@ -25,8 +25,10 @@ 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 
29 
     | 
    
         
             
                  namespace: 'faye',
         
     | 
| 
       29 
     | 
    
         
            -
                  gc_interval: 60  # Automatic garbage collection interval (seconds), set to 0 or false to disable
         
     | 
| 
      
 30 
     | 
    
         
            +
                  gc_interval: 60,  # Automatic garbage collection interval (seconds), set to 0 or false to disable
         
     | 
| 
      
 31 
     | 
    
         
            +
                  cleanup_batch_size: 50  # Number of items per batch during cleanup (min: 1, max: 1000, prevents blocking)
         
     | 
| 
       30 
32 
     | 
    
         
             
                }.freeze
         
     | 
| 
       31 
33 
     | 
    
         | 
| 
       32 
34 
     | 
    
         
             
                attr_reader :server, :options, :connection, :client_registry,
         
     | 
| 
         @@ -109,12 +111,13 @@ module Faye 
     | 
|
| 
       109 
111 
     | 
    
         
             
                    message = message.dup unless message.frozen?
         
     | 
| 
       110 
112 
     | 
    
         
             
                    message['id'] ||= generate_message_id
         
     | 
| 
       111 
113 
     | 
    
         | 
| 
       112 
     | 
    
         
            -
                    # Track this message as locally published
         
     | 
| 
      
 114 
     | 
    
         
            +
                    # Track this message as locally published with timestamp
         
     | 
| 
       113 
115 
     | 
    
         
             
                    if @local_message_ids
         
     | 
| 
      
 116 
     | 
    
         
            +
                      timestamp = Time.now.to_i
         
     | 
| 
       114 
117 
     | 
    
         
             
                      if @local_message_ids_mutex
         
     | 
| 
       115 
     | 
    
         
            -
                        @local_message_ids_mutex.synchronize { @local_message_ids 
     | 
| 
      
 118 
     | 
    
         
            +
                        @local_message_ids_mutex.synchronize { @local_message_ids[message['id']] = timestamp }
         
     | 
| 
       116 
119 
     | 
    
         
             
                      else
         
     | 
| 
       117 
     | 
    
         
            -
                        @local_message_ids 
     | 
| 
      
 120 
     | 
    
         
            +
                        @local_message_ids[message['id']] = timestamp
         
     | 
| 
       118 
121 
     | 
    
         
             
                      end
         
     | 
| 
       119 
122 
     | 
    
         
             
                    end
         
     | 
| 
       120 
123 
     | 
    
         | 
| 
         @@ -186,13 +189,20 @@ module Faye 
     | 
|
| 
       186 
189 
     | 
    
         | 
| 
       187 
190 
     | 
    
         
             
                # Clean up expired clients and their associated data
         
     | 
| 
       188 
191 
     | 
    
         
             
                def cleanup_expired(&callback)
         
     | 
| 
      
 192 
     | 
    
         
            +
                  # Clean up stale local message IDs first
         
     | 
| 
      
 193 
     | 
    
         
            +
                  cleanup_stale_message_ids
         
     | 
| 
      
 194 
     | 
    
         
            +
             
     | 
| 
       189 
195 
     | 
    
         
             
                  @client_registry.cleanup_expired do |expired_count|
         
     | 
| 
       190 
196 
     | 
    
         
             
                    @logger.info("Cleaned up #{expired_count} expired clients") if expired_count > 0
         
     | 
| 
       191 
197 
     | 
    
         | 
| 
       192 
     | 
    
         
            -
                    # Always clean up orphaned subscription  
     | 
| 
      
 198 
     | 
    
         
            +
                    # Always clean up orphaned subscription data (even if no expired clients)
         
     | 
| 
       193 
199 
     | 
    
         
             
                    # This handles cases where subscriptions were orphaned due to crashes
         
     | 
| 
       194 
     | 
    
         
            -
                     
     | 
| 
       195 
     | 
    
         
            -
             
     | 
| 
      
 200 
     | 
    
         
            +
                    # and removes empty channel Sets and unused patterns
         
     | 
| 
      
 201 
     | 
    
         
            +
                    # Uses batched processing to avoid blocking the connection pool
         
     | 
| 
      
 202 
     | 
    
         
            +
                    @client_registry.all do |active_clients|
         
     | 
| 
      
 203 
     | 
    
         
            +
                      @subscription_manager.cleanup_orphaned_data(active_clients) do
         
     | 
| 
      
 204 
     | 
    
         
            +
                        callback.call(expired_count) if callback
         
     | 
| 
      
 205 
     | 
    
         
            +
                      end
         
     | 
| 
       196 
206 
     | 
    
         
             
                    end
         
     | 
| 
       197 
207 
     | 
    
         
             
                  end
         
     | 
| 
       198 
208 
     | 
    
         
             
                end
         
     | 
| 
         @@ -240,65 +250,36 @@ module Faye 
     | 
|
| 
       240 
250 
     | 
    
         
             
                  end
         
     | 
| 
       241 
251 
     | 
    
         
             
                end
         
     | 
| 
       242 
252 
     | 
    
         | 
| 
       243 
     | 
    
         
            -
                 
     | 
| 
       244 
     | 
    
         
            -
             
     | 
| 
       245 
     | 
    
         
            -
                   
     | 
| 
       246 
     | 
    
         
            -
                    active_set = active_clients.to_set
         
     | 
| 
       247 
     | 
    
         
            -
                    namespace = @options[:namespace] || 'faye'
         
     | 
| 
       248 
     | 
    
         
            -
             
     | 
| 
       249 
     | 
    
         
            -
                    # Scan for subscription keys and clean up orphaned ones
         
     | 
| 
       250 
     | 
    
         
            -
                    @connection.with_redis do |redis|
         
     | 
| 
       251 
     | 
    
         
            -
                      cursor = "0"
         
     | 
| 
       252 
     | 
    
         
            -
                      orphaned_keys = []
         
     | 
| 
       253 
     | 
    
         
            -
             
     | 
| 
       254 
     | 
    
         
            -
                      loop do
         
     | 
| 
       255 
     | 
    
         
            -
                        cursor, keys = redis.scan(cursor, match: "#{namespace}:subscriptions:*", count: 100)
         
     | 
| 
       256 
     | 
    
         
            -
             
     | 
| 
       257 
     | 
    
         
            -
                        keys.each do |key|
         
     | 
| 
       258 
     | 
    
         
            -
                          # Extract client_id from key (format: namespace:subscriptions:client_id)
         
     | 
| 
       259 
     | 
    
         
            -
                          client_id = key.split(':').last
         
     | 
| 
       260 
     | 
    
         
            -
                          orphaned_keys << client_id unless active_set.include?(client_id)
         
     | 
| 
       261 
     | 
    
         
            -
                        end
         
     | 
| 
       262 
     | 
    
         
            -
             
     | 
| 
       263 
     | 
    
         
            -
                        break if cursor == "0"
         
     | 
| 
       264 
     | 
    
         
            -
                      end
         
     | 
| 
      
 253 
     | 
    
         
            +
                # Clean up stale local message IDs (older than 5 minutes)
         
     | 
| 
      
 254 
     | 
    
         
            +
                def cleanup_stale_message_ids
         
     | 
| 
      
 255 
     | 
    
         
            +
                  return unless @local_message_ids
         
     | 
| 
       265 
256 
     | 
    
         | 
| 
       266 
     | 
    
         
            -
             
     | 
| 
       267 
     | 
    
         
            -
             
     | 
| 
       268 
     | 
    
         
            -
                        @logger.info("Cleaning up #{orphaned_keys.size} orphaned subscription sets")
         
     | 
| 
      
 257 
     | 
    
         
            +
                  cutoff = Time.now.to_i - 300  # 5 minutes
         
     | 
| 
      
 258 
     | 
    
         
            +
                  stale_count = 0
         
     | 
| 
       269 
259 
     | 
    
         | 
| 
       270 
     | 
    
         
            -
             
     | 
| 
       271 
     | 
    
         
            -
             
     | 
| 
       272 
     | 
    
         
            -
             
     | 
| 
       273 
     | 
    
         
            -
             
     | 
| 
       274 
     | 
    
         
            -
             
     | 
| 
       275 
     | 
    
         
            -
                          redis.pipelined do |pipeline|
         
     | 
| 
       276 
     | 
    
         
            -
                            # Delete client's subscription list
         
     | 
| 
       277 
     | 
    
         
            -
                            pipeline.del("#{namespace}:subscriptions:#{client_id}")
         
     | 
| 
       278 
     | 
    
         
            -
             
     | 
| 
       279 
     | 
    
         
            -
                            # Delete each subscription metadata and remove from channel subscribers
         
     | 
| 
       280 
     | 
    
         
            -
                            channels.each do |channel|
         
     | 
| 
       281 
     | 
    
         
            -
                              pipeline.del("#{namespace}:subscription:#{client_id}:#{channel}")
         
     | 
| 
       282 
     | 
    
         
            -
                              pipeline.srem("#{namespace}:channels:#{channel}", client_id)
         
     | 
| 
       283 
     | 
    
         
            -
                            end
         
     | 
| 
       284 
     | 
    
         
            -
             
     | 
| 
       285 
     | 
    
         
            -
                            # Delete message queue if exists
         
     | 
| 
       286 
     | 
    
         
            -
                            pipeline.del("#{namespace}:messages:#{client_id}")
         
     | 
| 
       287 
     | 
    
         
            -
                          end
         
     | 
| 
       288 
     | 
    
         
            -
                        end
         
     | 
| 
       289 
     | 
    
         
            -
                      end
         
     | 
| 
      
 260 
     | 
    
         
            +
                  if @local_message_ids_mutex
         
     | 
| 
      
 261 
     | 
    
         
            +
                    @local_message_ids_mutex.synchronize do
         
     | 
| 
      
 262 
     | 
    
         
            +
                      initial_size = @local_message_ids.size
         
     | 
| 
      
 263 
     | 
    
         
            +
                      @local_message_ids.delete_if { |_id, timestamp| timestamp < cutoff }
         
     | 
| 
      
 264 
     | 
    
         
            +
                      stale_count = initial_size - @local_message_ids.size
         
     | 
| 
       290 
265 
     | 
    
         
             
                    end
         
     | 
| 
      
 266 
     | 
    
         
            +
                  else
         
     | 
| 
      
 267 
     | 
    
         
            +
                    initial_size = @local_message_ids.size
         
     | 
| 
      
 268 
     | 
    
         
            +
                    @local_message_ids.delete_if { |_id, timestamp| timestamp < cutoff }
         
     | 
| 
      
 269 
     | 
    
         
            +
                    stale_count = initial_size - @local_message_ids.size
         
     | 
| 
      
 270 
     | 
    
         
            +
                  end
         
     | 
| 
       291 
271 
     | 
    
         | 
| 
       292 
     | 
    
         
            -
             
     | 
| 
      
 272 
     | 
    
         
            +
                  if stale_count > 0
         
     | 
| 
      
 273 
     | 
    
         
            +
                    @logger.info("Cleaned up #{stale_count} stale local message IDs")
         
     | 
| 
       293 
274 
     | 
    
         
             
                  end
         
     | 
| 
       294 
275 
     | 
    
         
             
                rescue => e
         
     | 
| 
       295 
     | 
    
         
            -
                  log_error("Failed to cleanup  
     | 
| 
       296 
     | 
    
         
            -
                  EventMachine.next_tick { callback.call } if callback
         
     | 
| 
      
 276 
     | 
    
         
            +
                  log_error("Failed to cleanup stale message IDs: #{e.message}")
         
     | 
| 
       297 
277 
     | 
    
         
             
                end
         
     | 
| 
       298 
278 
     | 
    
         | 
| 
       299 
279 
     | 
    
         
             
                def setup_message_routing
         
     | 
| 
       300 
     | 
    
         
            -
                  # Track locally published message IDs to avoid duplicate enqueue
         
     | 
| 
       301 
     | 
    
         
            -
                   
     | 
| 
      
 280 
     | 
    
         
            +
                  # Track locally published message IDs with timestamps to avoid duplicate enqueue
         
     | 
| 
      
 281 
     | 
    
         
            +
                  # Use Hash to store message_id => timestamp for expiry tracking
         
     | 
| 
      
 282 
     | 
    
         
            +
                  @local_message_ids = {}
         
     | 
| 
       302 
283 
     | 
    
         
             
                  @local_message_ids_mutex = Mutex.new if defined?(Mutex)
         
     | 
| 
       303 
284 
     | 
    
         | 
| 
       304 
285 
     | 
    
         
             
                  # Subscribe to message events from other servers
         
     | 
| 
         @@ -311,10 +292,12 @@ module Faye 
     | 
|
| 
       311 
292 
     | 
    
         
             
                    if message_id
         
     | 
| 
       312 
293 
     | 
    
         
             
                      if @local_message_ids_mutex
         
     | 
| 
       313 
294 
     | 
    
         
             
                        @local_message_ids_mutex.synchronize do
         
     | 
| 
       314 
     | 
    
         
            -
                           
     | 
| 
      
 295 
     | 
    
         
            +
                          # Check existence but don't delete yet (cleanup will handle expiry)
         
     | 
| 
      
 296 
     | 
    
         
            +
                          # This prevents issues with multi-channel publishes
         
     | 
| 
      
 297 
     | 
    
         
            +
                          is_local = @local_message_ids.key?(message_id)
         
     | 
| 
       315 
298 
     | 
    
         
             
                        end
         
     | 
| 
       316 
299 
     | 
    
         
             
                      else
         
     | 
| 
       317 
     | 
    
         
            -
                        is_local = @local_message_ids. 
     | 
| 
      
 300 
     | 
    
         
            +
                        is_local = @local_message_ids.key?(message_id)
         
     | 
| 
       318 
301 
     | 
    
         
             
                      end
         
     | 
| 
       319 
302 
     | 
    
         
             
                    end
         
     | 
| 
       320 
303 
     | 
    
         |