faye-redis-ng 1.0.7 → 1.0.8
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 +83 -0
 - data/lib/faye/redis/client_registry.rb +49 -0
 - data/lib/faye/redis/pubsub_coordinator.rb +10 -7
 - data/lib/faye/redis/subscription_manager.rb +224 -7
 - 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: 78cd29dcd487d16281545bd13560fc6519ff944f3c1d3d794df353a3a0cc7c6b
         
     | 
| 
      
 4 
     | 
    
         
            +
              data.tar.gz: 8928d068c16b5a47761a15e82e7e8d4f1f846569da719cc6f1d4adb93fac7357
         
     | 
| 
       5 
5 
     | 
    
         
             
            SHA512:
         
     | 
| 
       6 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       7 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 6 
     | 
    
         
            +
              metadata.gz: '099efa93f2aa2ad2556c1fa77d369ecb4ff52a42653186317dd423858071eb71387cb9718c56b1f148961387327658d4c52190a19ce3951546a0af2ae23ea965'
         
     | 
| 
      
 7 
     | 
    
         
            +
              data.tar.gz: cdc9a3987580324cd5760894b7c24a21f13b519dc6e5cc4117de3b060ecc02f5d209e30843f9d54a3466c082615f159d32e47a846a6a0394b21a6926ffa26e18
         
     | 
    
        data/CHANGELOG.md
    CHANGED
    
    | 
         @@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 
     | 
|
| 
       7 
7 
     | 
    
         | 
| 
       8 
8 
     | 
    
         
             
            ## [Unreleased]
         
     | 
| 
       9 
9 
     | 
    
         | 
| 
      
 10 
     | 
    
         
            +
            ## [1.0.8] - 2025-10-30
         
     | 
| 
      
 11 
     | 
    
         
            +
             
     | 
| 
      
 12 
     | 
    
         
            +
            ### Fixed - Memory Leaks (P0 - High Risk)
         
     | 
| 
      
 13 
     | 
    
         
            +
            - **@local_message_ids Memory Leak**: Fixed unbounded growth of message ID tracking
         
     | 
| 
      
 14 
     | 
    
         
            +
              - Changed from Set to Hash with timestamps for expiry tracking
         
     | 
| 
      
 15 
     | 
    
         
            +
              - Added `cleanup_stale_message_ids` to remove IDs older than 5 minutes
         
     | 
| 
      
 16 
     | 
    
         
            +
              - Integrated into automatic GC cycle
         
     | 
| 
      
 17 
     | 
    
         
            +
              - **Impact**: Prevents 90 MB/month memory leak in high-traffic scenarios
         
     | 
| 
      
 18 
     | 
    
         
            +
             
     | 
| 
      
 19 
     | 
    
         
            +
            - **Subscription Keys Without TTL**: Added TTL to all subscription-related Redis keys
         
     | 
| 
      
 20 
     | 
    
         
            +
              - Added `subscription_ttl` configuration option (default: 24 hours)
         
     | 
| 
      
 21 
     | 
    
         
            +
              - Set EXPIRE on: client subscriptions, channel subscribers, subscription metadata, patterns
         
     | 
| 
      
 22 
     | 
    
         
            +
              - Provides safety net if GC is disabled or crashes
         
     | 
| 
      
 23 
     | 
    
         
            +
              - **Impact**: Prevents unlimited Redis memory growth from orphaned subscriptions
         
     | 
| 
      
 24 
     | 
    
         
            +
             
     | 
| 
      
 25 
     | 
    
         
            +
            - **Multi-channel Message Deduplication**: Fixed duplicate message enqueue for multi-channel publishes
         
     | 
| 
      
 26 
     | 
    
         
            +
              - Changed message ID tracking from delete-on-check to check-only
         
     | 
| 
      
 27 
     | 
    
         
            +
              - Allows same message_id to be checked multiple times for different channels
         
     | 
| 
      
 28 
     | 
    
         
            +
              - Cleanup now handles expiry instead of immediate deletion
         
     | 
| 
      
 29 
     | 
    
         
            +
              - **Impact**: Eliminates duplicate messages when publishing to multiple channels
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
            ### Fixed - Performance Issues (P1 - Medium Risk)
         
     | 
| 
      
 32 
     | 
    
         
            +
            - **N+1 Query in Pattern Subscribers**: Optimized wildcard pattern subscriber lookup
         
     | 
| 
      
 33 
     | 
    
         
            +
              - Added Redis pipelining to fetch all matching pattern subscribers in one round-trip
         
     | 
| 
      
 34 
     | 
    
         
            +
              - Reduced from 101 calls to 2 calls for 100 patterns
         
     | 
| 
      
 35 
     | 
    
         
            +
              - Filter patterns in-memory before fetching subscribers
         
     | 
| 
      
 36 
     | 
    
         
            +
              - **Impact**: 50x performance improvement for wildcard subscriptions
         
     | 
| 
      
 37 
     | 
    
         
            +
             
     | 
| 
      
 38 
     | 
    
         
            +
            - **clients:index Accumulation**: Added periodic index rebuild to prevent stale data
         
     | 
| 
      
 39 
     | 
    
         
            +
              - Tracks cleanup counter and rebuilds index every 10 GC cycles
         
     | 
| 
      
 40 
     | 
    
         
            +
              - SCAN actual client keys and rebuild atomically
         
     | 
| 
      
 41 
     | 
    
         
            +
              - Removes all stale IDs that weren't properly cleaned
         
     | 
| 
      
 42 
     | 
    
         
            +
              - **Impact**: Prevents 36 MB memory growth for 1M clients
         
     | 
| 
      
 43 
     | 
    
         
            +
             
     | 
| 
      
 44 
     | 
    
         
            +
            - **@subscribers Array Duplication**: Converted to single handler pattern
         
     | 
| 
      
 45 
     | 
    
         
            +
              - Changed from array of handlers to single @message_handler
         
     | 
| 
      
 46 
     | 
    
         
            +
              - Prevents duplicate message processing if on_message called multiple times
         
     | 
| 
      
 47 
     | 
    
         
            +
              - Added warning if handler replaced
         
     | 
| 
      
 48 
     | 
    
         
            +
              - **Impact**: Eliminates potential duplicate message processing
         
     | 
| 
      
 49 
     | 
    
         
            +
             
     | 
| 
      
 50 
     | 
    
         
            +
            - **Comprehensive Cleanup Logic**: Enhanced cleanup to handle all orphaned data
         
     | 
| 
      
 51 
     | 
    
         
            +
              - Added cleanup for empty channel Sets
         
     | 
| 
      
 52 
     | 
    
         
            +
              - Added cleanup for orphaned subscription metadata
         
     | 
| 
      
 53 
     | 
    
         
            +
              - Added cleanup for unused wildcard patterns
         
     | 
| 
      
 54 
     | 
    
         
            +
              - Integrated message queue cleanup
         
     | 
| 
      
 55 
     | 
    
         
            +
              - **Impact**: Complete memory leak prevention
         
     | 
| 
      
 56 
     | 
    
         
            +
             
     | 
| 
      
 57 
     | 
    
         
            +
            - **Batched Cleanup Processing**: Implemented batched cleanup to prevent connection pool blocking
         
     | 
| 
      
 58 
     | 
    
         
            +
              - Added `cleanup_batch_size` configuration option (default: 50)
         
     | 
| 
      
 59 
     | 
    
         
            +
              - Process cleanup in batches with EventMachine.next_tick between batches
         
     | 
| 
      
 60 
     | 
    
         
            +
              - Split cleanup into 4 async phases: scan → cleanup → empty channels → patterns
         
     | 
| 
      
 61 
     | 
    
         
            +
              - **Impact**: Prevents cleanup operations from blocking other Redis operations
         
     | 
| 
      
 62 
     | 
    
         
            +
             
     | 
| 
      
 63 
     | 
    
         
            +
            ### Added
         
     | 
| 
      
 64 
     | 
    
         
            +
            - New configuration option: `subscription_ttl` (default: 86400 seconds / 24 hours)
         
     | 
| 
      
 65 
     | 
    
         
            +
            - New configuration option: `cleanup_batch_size` (default: 50 items per batch)
         
     | 
| 
      
 66 
     | 
    
         
            +
            - New method: `SubscriptionManager#cleanup_orphaned_data` for comprehensive cleanup
         
     | 
| 
      
 67 
     | 
    
         
            +
            - New private methods for batched cleanup: `scan_orphaned_subscriptions`, `cleanup_orphaned_subscriptions_batched`, `cleanup_empty_channels_async`, `cleanup_unused_patterns_async`
         
     | 
| 
      
 68 
     | 
    
         
            +
            - New method: `ClientRegistry#rebuild_clients_index` for periodic index maintenance
         
     | 
| 
      
 69 
     | 
    
         
            +
             
     | 
| 
      
 70 
     | 
    
         
            +
            ### Changed
         
     | 
| 
      
 71 
     | 
    
         
            +
            - `PubSubCoordinator`: Converted from array-based @subscribers to single @message_handler
         
     | 
| 
      
 72 
     | 
    
         
            +
            - `cleanup_expired`: Now calls comprehensive orphaned data cleanup
         
     | 
| 
      
 73 
     | 
    
         
            +
            - Message ID deduplication: Changed from delete-on-check to check-only with time-based cleanup
         
     | 
| 
      
 74 
     | 
    
         
            +
            - Test specs updated to work with single handler pattern
         
     | 
| 
      
 75 
     | 
    
         
            +
             
     | 
| 
      
 76 
     | 
    
         
            +
            ### Technical Details
         
     | 
| 
      
 77 
     | 
    
         
            +
            **Memory Leak Prevention**:
         
     | 
| 
      
 78 
     | 
    
         
            +
            - All subscription keys now have TTL as safety net
         
     | 
| 
      
 79 
     | 
    
         
            +
            - Message IDs expire after 5 minutes instead of growing indefinitely
         
     | 
| 
      
 80 
     | 
    
         
            +
            - Periodic index rebuild removes stale client IDs
         
     | 
| 
      
 81 
     | 
    
         
            +
            - Comprehensive cleanup removes all types of orphaned data
         
     | 
| 
      
 82 
     | 
    
         
            +
             
     | 
| 
      
 83 
     | 
    
         
            +
            **Performance Improvements**:
         
     | 
| 
      
 84 
     | 
    
         
            +
            - Wildcard pattern lookups: 100 sequential calls → 1 pipelined call
         
     | 
| 
      
 85 
     | 
    
         
            +
            - Cleanup operations: Batched processing prevents blocking
         
     | 
| 
      
 86 
     | 
    
         
            +
            - Index maintenance: Periodic rebuild keeps index size optimal
         
     | 
| 
      
 87 
     | 
    
         
            +
             
     | 
| 
      
 88 
     | 
    
         
            +
            **Test Coverage**:
         
     | 
| 
      
 89 
     | 
    
         
            +
            - All 177 tests passing
         
     | 
| 
      
 90 
     | 
    
         
            +
            - Line Coverage: 86.4%
         
     | 
| 
      
 91 
     | 
    
         
            +
            - Branch Coverage: 55.04%
         
     | 
| 
      
 92 
     | 
    
         
            +
             
     | 
| 
       10 
93 
     | 
    
         
             
            ## [1.0.7] - 2025-10-30
         
     | 
| 
       11 
94 
     | 
    
         | 
| 
       12 
95 
     | 
    
         
             
            ### 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,7 @@ module Faye 
     | 
|
| 
       84 
87 
     | 
    
         
             
                      @redis_subscriber = nil
         
     | 
| 
       85 
88 
     | 
    
         
             
                    end
         
     | 
| 
       86 
89 
     | 
    
         
             
                    @subscribed_channels.clear
         
     | 
| 
       87 
     | 
    
         
            -
                    @ 
     | 
| 
      
 90 
     | 
    
         
            +
                    @message_handler = nil
         
     | 
| 
       88 
91 
     | 
    
         
             
                  end
         
     | 
| 
       89 
92 
     | 
    
         | 
| 
       90 
93 
     | 
    
         
             
                  private
         
     | 
| 
         @@ -166,16 +169,16 @@ module Faye 
     | 
|
| 
       166 
169 
     | 
    
         
             
                    begin
         
     | 
| 
       167 
170 
     | 
    
         
             
                      message = JSON.parse(message_json)
         
     | 
| 
       168 
171 
     | 
    
         | 
| 
       169 
     | 
    
         
            -
                      # Notify  
     | 
| 
      
 172 
     | 
    
         
            +
                      # Notify the message handler
         
     | 
| 
       170 
173 
     | 
    
         
             
                      # Use EventMachine.schedule to safely call from non-EM thread
         
     | 
| 
       171 
174 
     | 
    
         
             
                      # (handle_message is called from subscriber_thread, not EM reactor thread)
         
     | 
| 
       172 
175 
     | 
    
         
             
                      if EventMachine.reactor_running?
         
     | 
| 
       173 
176 
     | 
    
         
             
                        EventMachine.schedule do
         
     | 
| 
       174 
     | 
    
         
            -
                          @ 
     | 
| 
      
 177 
     | 
    
         
            +
                          if @message_handler
         
     | 
| 
       175 
178 
     | 
    
         
             
                            begin
         
     | 
| 
       176 
     | 
    
         
            -
                               
     | 
| 
      
 179 
     | 
    
         
            +
                              @message_handler.call(channel, message)
         
     | 
| 
       177 
180 
     | 
    
         
             
                            rescue => e
         
     | 
| 
       178 
     | 
    
         
            -
                              log_error(" 
     | 
| 
      
 181 
     | 
    
         
            +
                              log_error("Message handler callback error for #{channel}: #{e.message}")
         
     | 
| 
       179 
182 
     | 
    
         
             
                            end
         
     | 
| 
       180 
183 
     | 
    
         
             
                          end
         
     | 
| 
       181 
184 
     | 
    
         
             
                        end
         
     | 
| 
         @@ -11,14 +11,19 @@ module Faye 
     | 
|
| 
       11 
11 
     | 
    
         
             
                  # Subscribe a client to a channel
         
     | 
| 
       12 
12 
     | 
    
         
             
                  def subscribe(client_id, channel, &callback)
         
     | 
| 
       13 
13 
     | 
    
         
             
                    timestamp = Time.now.to_i
         
     | 
| 
      
 14 
     | 
    
         
            +
                    subscription_ttl = @options[:subscription_ttl] || 86400  # 24 hours default
         
     | 
| 
       14 
15 
     | 
    
         | 
| 
       15 
16 
     | 
    
         
             
                    @connection.with_redis do |redis|
         
     | 
| 
       16 
17 
     | 
    
         
             
                      redis.multi do |multi|
         
     | 
| 
       17 
18 
     | 
    
         
             
                        # Add channel to client's subscriptions
         
     | 
| 
       18 
19 
     | 
    
         
             
                        multi.sadd?(client_subscriptions_key(client_id), channel)
         
     | 
| 
      
 20 
     | 
    
         
            +
                        # Set/refresh TTL for client subscriptions list
         
     | 
| 
      
 21 
     | 
    
         
            +
                        multi.expire(client_subscriptions_key(client_id), subscription_ttl)
         
     | 
| 
       19 
22 
     | 
    
         | 
| 
       20 
23 
     | 
    
         
             
                        # Add client to channel's subscribers
         
     | 
| 
       21 
24 
     | 
    
         
             
                        multi.sadd?(channel_subscribers_key(channel), client_id)
         
     | 
| 
      
 25 
     | 
    
         
            +
                        # Set/refresh TTL for channel subscribers list
         
     | 
| 
      
 26 
     | 
    
         
            +
                        multi.expire(channel_subscribers_key(channel), subscription_ttl)
         
     | 
| 
       22 
27 
     | 
    
         | 
| 
       23 
28 
     | 
    
         
             
                        # Store subscription metadata
         
     | 
| 
       24 
29 
     | 
    
         
             
                        multi.hset(
         
     | 
| 
         @@ -27,10 +32,14 @@ module Faye 
     | 
|
| 
       27 
32 
     | 
    
         
             
                          'channel', channel,
         
     | 
| 
       28 
33 
     | 
    
         
             
                          'client_id', client_id
         
     | 
| 
       29 
34 
     | 
    
         
             
                        )
         
     | 
| 
      
 35 
     | 
    
         
            +
                        # Set TTL for subscription metadata
         
     | 
| 
      
 36 
     | 
    
         
            +
                        multi.expire(subscription_key(client_id, channel), subscription_ttl)
         
     | 
| 
       30 
37 
     | 
    
         | 
| 
       31 
38 
     | 
    
         
             
                        # Handle wildcard patterns
         
     | 
| 
       32 
39 
     | 
    
         
             
                        if channel.include?('*')
         
     | 
| 
       33 
40 
     | 
    
         
             
                          multi.sadd?(patterns_key, channel)
         
     | 
| 
      
 41 
     | 
    
         
            +
                          # Set/refresh TTL for patterns set
         
     | 
| 
      
 42 
     | 
    
         
            +
                          multi.expire(patterns_key, subscription_ttl)
         
     | 
| 
       34 
43 
     | 
    
         
             
                        end
         
     | 
| 
       35 
44 
     | 
    
         
             
                      end
         
     | 
| 
       36 
45 
     | 
    
         
             
                    end
         
     | 
| 
         @@ -129,17 +138,21 @@ module Faye 
     | 
|
| 
       129 
138 
     | 
    
         
             
                      redis.smembers(patterns_key)
         
     | 
| 
       130 
139 
     | 
    
         
             
                    end
         
     | 
| 
       131 
140 
     | 
    
         | 
| 
       132 
     | 
    
         
            -
                     
     | 
| 
       133 
     | 
    
         
            -
                    patterns. 
     | 
| 
       134 
     | 
    
         
            -
             
     | 
| 
       135 
     | 
    
         
            -
             
     | 
| 
       136 
     | 
    
         
            -
             
     | 
| 
      
 141 
     | 
    
         
            +
                    # Filter to only matching patterns first
         
     | 
| 
      
 142 
     | 
    
         
            +
                    matching_patterns = patterns.select { |pattern| channel_matches_pattern?(channel, pattern) }
         
     | 
| 
      
 143 
     | 
    
         
            +
                    return [] if matching_patterns.empty?
         
     | 
| 
      
 144 
     | 
    
         
            +
             
     | 
| 
      
 145 
     | 
    
         
            +
                    # Use pipelining to fetch all matching pattern subscribers in one network round-trip
         
     | 
| 
      
 146 
     | 
    
         
            +
                    results = @connection.with_redis do |redis|
         
     | 
| 
      
 147 
     | 
    
         
            +
                      redis.pipelined do |pipeline|
         
     | 
| 
      
 148 
     | 
    
         
            +
                        matching_patterns.each do |pattern|
         
     | 
| 
      
 149 
     | 
    
         
            +
                          pipeline.smembers(channel_subscribers_key(pattern))
         
     | 
| 
       137 
150 
     | 
    
         
             
                        end
         
     | 
| 
       138 
     | 
    
         
            -
                        matching_clients.concat(clients)
         
     | 
| 
       139 
151 
     | 
    
         
             
                      end
         
     | 
| 
       140 
152 
     | 
    
         
             
                    end
         
     | 
| 
       141 
153 
     | 
    
         | 
| 
       142 
     | 
    
         
            -
                     
     | 
| 
      
 154 
     | 
    
         
            +
                    # Flatten and deduplicate results
         
     | 
| 
      
 155 
     | 
    
         
            +
                    results.flatten.uniq
         
     | 
| 
       143 
156 
     | 
    
         
             
                  rescue => e
         
     | 
| 
       144 
157 
     | 
    
         
             
                    log_error("Failed to get pattern subscribers for channel #{channel}: #{e.message}")
         
     | 
| 
       145 
158 
     | 
    
         
             
                    []
         
     | 
| 
         @@ -163,8 +176,212 @@ module Faye 
     | 
|
| 
       163 
176 
     | 
    
         
             
                    unsubscribe_all(client_id)
         
     | 
| 
       164 
177 
     | 
    
         
             
                  end
         
     | 
| 
       165 
178 
     | 
    
         | 
| 
      
 179 
     | 
    
         
            +
                  # Comprehensive cleanup of orphaned subscription data
         
     | 
| 
      
 180 
     | 
    
         
            +
                  # This should be called periodically during garbage collection
         
     | 
| 
      
 181 
     | 
    
         
            +
                  # Processes in batches to avoid blocking the connection pool
         
     | 
| 
      
 182 
     | 
    
         
            +
                  def cleanup_orphaned_data(active_client_ids, &callback)
         
     | 
| 
      
 183 
     | 
    
         
            +
                    active_set = active_client_ids.to_set
         
     | 
| 
      
 184 
     | 
    
         
            +
                    namespace = @options[:namespace] || 'faye'
         
     | 
| 
      
 185 
     | 
    
         
            +
                    batch_size = @options[:cleanup_batch_size] || 50
         
     | 
| 
      
 186 
     | 
    
         
            +
             
     | 
| 
      
 187 
     | 
    
         
            +
                    # Phase 1: Scan for orphaned subscriptions
         
     | 
| 
      
 188 
     | 
    
         
            +
                    scan_orphaned_subscriptions(active_set, namespace) do |orphaned_subscriptions|
         
     | 
| 
      
 189 
     | 
    
         
            +
                      # Phase 2: Clean up orphaned subscriptions in batches
         
     | 
| 
      
 190 
     | 
    
         
            +
                      cleanup_orphaned_subscriptions_batched(orphaned_subscriptions, namespace, batch_size) do
         
     | 
| 
      
 191 
     | 
    
         
            +
                        # Phase 3: Clean up empty channels (yields between operations)
         
     | 
| 
      
 192 
     | 
    
         
            +
                        cleanup_empty_channels_async(namespace) do
         
     | 
| 
      
 193 
     | 
    
         
            +
                          # Phase 4: Clean up unused patterns
         
     | 
| 
      
 194 
     | 
    
         
            +
                          cleanup_unused_patterns_async do
         
     | 
| 
      
 195 
     | 
    
         
            +
                            callback.call if callback
         
     | 
| 
      
 196 
     | 
    
         
            +
                          end
         
     | 
| 
      
 197 
     | 
    
         
            +
                        end
         
     | 
| 
      
 198 
     | 
    
         
            +
                      end
         
     | 
| 
      
 199 
     | 
    
         
            +
                    end
         
     | 
| 
      
 200 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 201 
     | 
    
         
            +
                    log_error("Failed to cleanup orphaned data: #{e.message}")
         
     | 
| 
      
 202 
     | 
    
         
            +
                    EventMachine.next_tick { callback.call } if callback
         
     | 
| 
      
 203 
     | 
    
         
            +
                  end
         
     | 
| 
      
 204 
     | 
    
         
            +
             
     | 
| 
       166 
205 
     | 
    
         
             
                  private
         
     | 
| 
       167 
206 
     | 
    
         | 
| 
      
 207 
     | 
    
         
            +
                  # Scan for orphaned subscription keys
         
     | 
| 
      
 208 
     | 
    
         
            +
                  def scan_orphaned_subscriptions(active_set, namespace, &callback)
         
     | 
| 
      
 209 
     | 
    
         
            +
                    @connection.with_redis do |redis|
         
     | 
| 
      
 210 
     | 
    
         
            +
                      cursor = "0"
         
     | 
| 
      
 211 
     | 
    
         
            +
                      orphaned_subscriptions = []
         
     | 
| 
      
 212 
     | 
    
         
            +
             
     | 
| 
      
 213 
     | 
    
         
            +
                      loop do
         
     | 
| 
      
 214 
     | 
    
         
            +
                        cursor, keys = redis.scan(cursor, match: "#{namespace}:subscriptions:*", count: 100)
         
     | 
| 
      
 215 
     | 
    
         
            +
             
     | 
| 
      
 216 
     | 
    
         
            +
                        keys.each do |key|
         
     | 
| 
      
 217 
     | 
    
         
            +
                          client_id = key.split(':').last
         
     | 
| 
      
 218 
     | 
    
         
            +
                          orphaned_subscriptions << client_id unless active_set.include?(client_id)
         
     | 
| 
      
 219 
     | 
    
         
            +
                        end
         
     | 
| 
      
 220 
     | 
    
         
            +
             
     | 
| 
      
 221 
     | 
    
         
            +
                        break if cursor == "0"
         
     | 
| 
      
 222 
     | 
    
         
            +
                      end
         
     | 
| 
      
 223 
     | 
    
         
            +
             
     | 
| 
      
 224 
     | 
    
         
            +
                      EventMachine.next_tick { callback.call(orphaned_subscriptions) }
         
     | 
| 
      
 225 
     | 
    
         
            +
                    end
         
     | 
| 
      
 226 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 227 
     | 
    
         
            +
                    log_error("Failed to scan orphaned subscriptions: #{e.message}")
         
     | 
| 
      
 228 
     | 
    
         
            +
                    EventMachine.next_tick { callback.call([]) }
         
     | 
| 
      
 229 
     | 
    
         
            +
                  end
         
     | 
| 
      
 230 
     | 
    
         
            +
             
     | 
| 
      
 231 
     | 
    
         
            +
                  # Clean up orphaned subscriptions in batches to avoid blocking
         
     | 
| 
      
 232 
     | 
    
         
            +
                  def cleanup_orphaned_subscriptions_batched(orphaned_subscriptions, namespace, batch_size, &callback)
         
     | 
| 
      
 233 
     | 
    
         
            +
                    return EventMachine.next_tick { callback.call } if orphaned_subscriptions.empty?
         
     | 
| 
      
 234 
     | 
    
         
            +
             
     | 
| 
      
 235 
     | 
    
         
            +
                    total = orphaned_subscriptions.size
         
     | 
| 
      
 236 
     | 
    
         
            +
                    batches = orphaned_subscriptions.each_slice(batch_size).to_a
         
     | 
| 
      
 237 
     | 
    
         
            +
                    processed = 0
         
     | 
| 
      
 238 
     | 
    
         
            +
             
     | 
| 
      
 239 
     | 
    
         
            +
                    process_batch = lambda do |batch_index|
         
     | 
| 
      
 240 
     | 
    
         
            +
                      if batch_index >= batches.size
         
     | 
| 
      
 241 
     | 
    
         
            +
                        puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{total} orphaned subscription sets" if @options[:log_level] != :silent
         
     | 
| 
      
 242 
     | 
    
         
            +
                        EventMachine.next_tick { callback.call }
         
     | 
| 
      
 243 
     | 
    
         
            +
                        return
         
     | 
| 
      
 244 
     | 
    
         
            +
                      end
         
     | 
| 
      
 245 
     | 
    
         
            +
             
     | 
| 
      
 246 
     | 
    
         
            +
                      batch = batches[batch_index]
         
     | 
| 
      
 247 
     | 
    
         
            +
             
     | 
| 
      
 248 
     | 
    
         
            +
                      @connection.with_redis do |redis|
         
     | 
| 
      
 249 
     | 
    
         
            +
                        batch.each do |client_id|
         
     | 
| 
      
 250 
     | 
    
         
            +
                          channels = redis.smembers(client_subscriptions_key(client_id))
         
     | 
| 
      
 251 
     | 
    
         
            +
             
     | 
| 
      
 252 
     | 
    
         
            +
                          redis.pipelined do |pipeline|
         
     | 
| 
      
 253 
     | 
    
         
            +
                            pipeline.del(client_subscriptions_key(client_id))
         
     | 
| 
      
 254 
     | 
    
         
            +
             
     | 
| 
      
 255 
     | 
    
         
            +
                            channels.each do |channel|
         
     | 
| 
      
 256 
     | 
    
         
            +
                              pipeline.del(subscription_key(client_id, channel))
         
     | 
| 
      
 257 
     | 
    
         
            +
                              pipeline.srem(channel_subscribers_key(channel), client_id)
         
     | 
| 
      
 258 
     | 
    
         
            +
                            end
         
     | 
| 
      
 259 
     | 
    
         
            +
             
     | 
| 
      
 260 
     | 
    
         
            +
                            pipeline.del("#{namespace}:messages:#{client_id}")
         
     | 
| 
      
 261 
     | 
    
         
            +
                          end
         
     | 
| 
      
 262 
     | 
    
         
            +
                        end
         
     | 
| 
      
 263 
     | 
    
         
            +
                      end
         
     | 
| 
      
 264 
     | 
    
         
            +
             
     | 
| 
      
 265 
     | 
    
         
            +
                      processed += batch.size
         
     | 
| 
      
 266 
     | 
    
         
            +
             
     | 
| 
      
 267 
     | 
    
         
            +
                      # Yield control to EventMachine between batches
         
     | 
| 
      
 268 
     | 
    
         
            +
                      EventMachine.next_tick { process_batch.call(batch_index + 1) }
         
     | 
| 
      
 269 
     | 
    
         
            +
                    end
         
     | 
| 
      
 270 
     | 
    
         
            +
             
     | 
| 
      
 271 
     | 
    
         
            +
                    process_batch.call(0)
         
     | 
| 
      
 272 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 273 
     | 
    
         
            +
                    log_error("Failed to cleanup orphaned subscriptions batch: #{e.message}")
         
     | 
| 
      
 274 
     | 
    
         
            +
                    EventMachine.next_tick { callback.call }
         
     | 
| 
      
 275 
     | 
    
         
            +
                  end
         
     | 
| 
      
 276 
     | 
    
         
            +
             
     | 
| 
      
 277 
     | 
    
         
            +
                  # Async version of cleanup_empty_channels that yields between operations
         
     | 
| 
      
 278 
     | 
    
         
            +
                  def cleanup_empty_channels_async(namespace, &callback)
         
     | 
| 
      
 279 
     | 
    
         
            +
                    @connection.with_redis do |redis|
         
     | 
| 
      
 280 
     | 
    
         
            +
                      cursor = "0"
         
     | 
| 
      
 281 
     | 
    
         
            +
                      empty_channels = []
         
     | 
| 
      
 282 
     | 
    
         
            +
             
     | 
| 
      
 283 
     | 
    
         
            +
                      loop do
         
     | 
| 
      
 284 
     | 
    
         
            +
                        cursor, keys = redis.scan(cursor, match: "#{namespace}:channels:*", count: 100)
         
     | 
| 
      
 285 
     | 
    
         
            +
             
     | 
| 
      
 286 
     | 
    
         
            +
                        keys.each do |key|
         
     | 
| 
      
 287 
     | 
    
         
            +
                          count = redis.scard(key)
         
     | 
| 
      
 288 
     | 
    
         
            +
                          empty_channels << key if count == 0
         
     | 
| 
      
 289 
     | 
    
         
            +
                        end
         
     | 
| 
      
 290 
     | 
    
         
            +
             
     | 
| 
      
 291 
     | 
    
         
            +
                        break if cursor == "0"
         
     | 
| 
      
 292 
     | 
    
         
            +
                      end
         
     | 
| 
      
 293 
     | 
    
         
            +
             
     | 
| 
      
 294 
     | 
    
         
            +
                      if empty_channels.any?
         
     | 
| 
      
 295 
     | 
    
         
            +
                        redis.pipelined do |pipeline|
         
     | 
| 
      
 296 
     | 
    
         
            +
                          empty_channels.each { |key| pipeline.del(key) }
         
     | 
| 
      
 297 
     | 
    
         
            +
                        end
         
     | 
| 
      
 298 
     | 
    
         
            +
                        puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{empty_channels.size} empty channel Sets" if @options[:log_level] != :silent
         
     | 
| 
      
 299 
     | 
    
         
            +
                      end
         
     | 
| 
      
 300 
     | 
    
         
            +
             
     | 
| 
      
 301 
     | 
    
         
            +
                      EventMachine.next_tick { callback.call }
         
     | 
| 
      
 302 
     | 
    
         
            +
                    end
         
     | 
| 
      
 303 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 304 
     | 
    
         
            +
                    log_error("Failed to cleanup empty channels: #{e.message}")
         
     | 
| 
      
 305 
     | 
    
         
            +
                    EventMachine.next_tick { callback.call }
         
     | 
| 
      
 306 
     | 
    
         
            +
                  end
         
     | 
| 
      
 307 
     | 
    
         
            +
             
     | 
| 
      
 308 
     | 
    
         
            +
                  # Async version of cleanup_unused_patterns that yields after completion
         
     | 
| 
      
 309 
     | 
    
         
            +
                  def cleanup_unused_patterns_async(&callback)
         
     | 
| 
      
 310 
     | 
    
         
            +
                    @connection.with_redis do |redis|
         
     | 
| 
      
 311 
     | 
    
         
            +
                      patterns = redis.smembers(patterns_key)
         
     | 
| 
      
 312 
     | 
    
         
            +
                      unused_patterns = []
         
     | 
| 
      
 313 
     | 
    
         
            +
             
     | 
| 
      
 314 
     | 
    
         
            +
                      patterns.each do |pattern|
         
     | 
| 
      
 315 
     | 
    
         
            +
                        count = redis.scard(channel_subscribers_key(pattern))
         
     | 
| 
      
 316 
     | 
    
         
            +
                        unused_patterns << pattern if count == 0
         
     | 
| 
      
 317 
     | 
    
         
            +
                      end
         
     | 
| 
      
 318 
     | 
    
         
            +
             
     | 
| 
      
 319 
     | 
    
         
            +
                      if unused_patterns.any?
         
     | 
| 
      
 320 
     | 
    
         
            +
                        redis.pipelined do |pipeline|
         
     | 
| 
      
 321 
     | 
    
         
            +
                          unused_patterns.each do |pattern|
         
     | 
| 
      
 322 
     | 
    
         
            +
                            pipeline.srem(patterns_key, pattern)
         
     | 
| 
      
 323 
     | 
    
         
            +
                            pipeline.del(channel_subscribers_key(pattern))
         
     | 
| 
      
 324 
     | 
    
         
            +
                          end
         
     | 
| 
      
 325 
     | 
    
         
            +
                        end
         
     | 
| 
      
 326 
     | 
    
         
            +
                        puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{unused_patterns.size} unused patterns" if @options[:log_level] != :silent
         
     | 
| 
      
 327 
     | 
    
         
            +
                      end
         
     | 
| 
      
 328 
     | 
    
         
            +
             
     | 
| 
      
 329 
     | 
    
         
            +
                      EventMachine.next_tick { callback.call }
         
     | 
| 
      
 330 
     | 
    
         
            +
                    end
         
     | 
| 
      
 331 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 332 
     | 
    
         
            +
                    log_error("Failed to cleanup unused patterns: #{e.message}")
         
     | 
| 
      
 333 
     | 
    
         
            +
                    EventMachine.next_tick { callback.call }
         
     | 
| 
      
 334 
     | 
    
         
            +
                  end
         
     | 
| 
      
 335 
     | 
    
         
            +
             
     | 
| 
      
 336 
     | 
    
         
            +
                  # Clean up channel Sets that have no subscribers
         
     | 
| 
      
 337 
     | 
    
         
            +
                  def cleanup_empty_channels(redis, namespace)
         
     | 
| 
      
 338 
     | 
    
         
            +
                    cursor = "0"
         
     | 
| 
      
 339 
     | 
    
         
            +
                    empty_channels = []
         
     | 
| 
      
 340 
     | 
    
         
            +
             
     | 
| 
      
 341 
     | 
    
         
            +
                    loop do
         
     | 
| 
      
 342 
     | 
    
         
            +
                      cursor, keys = redis.scan(cursor, match: "#{namespace}:channels:*", count: 100)
         
     | 
| 
      
 343 
     | 
    
         
            +
             
     | 
| 
      
 344 
     | 
    
         
            +
                      keys.each do |key|
         
     | 
| 
      
 345 
     | 
    
         
            +
                        count = redis.scard(key)
         
     | 
| 
      
 346 
     | 
    
         
            +
                        empty_channels << key if count == 0
         
     | 
| 
      
 347 
     | 
    
         
            +
                      end
         
     | 
| 
      
 348 
     | 
    
         
            +
             
     | 
| 
      
 349 
     | 
    
         
            +
                      break if cursor == "0"
         
     | 
| 
      
 350 
     | 
    
         
            +
                    end
         
     | 
| 
      
 351 
     | 
    
         
            +
             
     | 
| 
      
 352 
     | 
    
         
            +
                    if empty_channels.any?
         
     | 
| 
      
 353 
     | 
    
         
            +
                      redis.pipelined do |pipeline|
         
     | 
| 
      
 354 
     | 
    
         
            +
                        empty_channels.each { |key| pipeline.del(key) }
         
     | 
| 
      
 355 
     | 
    
         
            +
                      end
         
     | 
| 
      
 356 
     | 
    
         
            +
                      puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{empty_channels.size} empty channel Sets" if @options[:log_level] != :silent
         
     | 
| 
      
 357 
     | 
    
         
            +
                    end
         
     | 
| 
      
 358 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 359 
     | 
    
         
            +
                    log_error("Failed to cleanup empty channels: #{e.message}")
         
     | 
| 
      
 360 
     | 
    
         
            +
                  end
         
     | 
| 
      
 361 
     | 
    
         
            +
             
     | 
| 
      
 362 
     | 
    
         
            +
                  # Clean up patterns that have no subscribers
         
     | 
| 
      
 363 
     | 
    
         
            +
                  def cleanup_unused_patterns(redis)
         
     | 
| 
      
 364 
     | 
    
         
            +
                    patterns = redis.smembers(patterns_key)
         
     | 
| 
      
 365 
     | 
    
         
            +
                    unused_patterns = []
         
     | 
| 
      
 366 
     | 
    
         
            +
             
     | 
| 
      
 367 
     | 
    
         
            +
                    patterns.each do |pattern|
         
     | 
| 
      
 368 
     | 
    
         
            +
                      count = redis.scard(channel_subscribers_key(pattern))
         
     | 
| 
      
 369 
     | 
    
         
            +
                      unused_patterns << pattern if count == 0
         
     | 
| 
      
 370 
     | 
    
         
            +
                    end
         
     | 
| 
      
 371 
     | 
    
         
            +
             
     | 
| 
      
 372 
     | 
    
         
            +
                    if unused_patterns.any?
         
     | 
| 
      
 373 
     | 
    
         
            +
                      redis.pipelined do |pipeline|
         
     | 
| 
      
 374 
     | 
    
         
            +
                        unused_patterns.each do |pattern|
         
     | 
| 
      
 375 
     | 
    
         
            +
                          pipeline.srem(patterns_key, pattern)
         
     | 
| 
      
 376 
     | 
    
         
            +
                          pipeline.del(channel_subscribers_key(pattern))
         
     | 
| 
      
 377 
     | 
    
         
            +
                        end
         
     | 
| 
      
 378 
     | 
    
         
            +
                      end
         
     | 
| 
      
 379 
     | 
    
         
            +
                      puts "[Faye::Redis::SubscriptionManager] INFO: Cleaned up #{unused_patterns.size} unused patterns" if @options[:log_level] != :silent
         
     | 
| 
      
 380 
     | 
    
         
            +
                    end
         
     | 
| 
      
 381 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 382 
     | 
    
         
            +
                    log_error("Failed to cleanup unused patterns: #{e.message}")
         
     | 
| 
      
 383 
     | 
    
         
            +
                  end
         
     | 
| 
      
 384 
     | 
    
         
            +
             
     | 
| 
       168 
385 
     | 
    
         
             
                  def cleanup_pattern_if_unused(pattern)
         
     | 
| 
       169 
386 
     | 
    
         
             
                    subscribers = @connection.with_redis do |redis|
         
     | 
| 
       170 
387 
     | 
    
         
             
                      redis.smembers(channel_subscribers_key(pattern))
         
     | 
    
        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 to process per batch during cleanup (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 
     | 
    
         |