faye-redis-ng 1.0.6 → 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 +133 -1
- data/lib/faye/redis/client_registry.rb +49 -0
- data/lib/faye/redis/pubsub_coordinator.rb +20 -7
- data/lib/faye/redis/subscription_manager.rb +224 -7
- data/lib/faye/redis/version.rb +1 -1
- data/lib/faye/redis.rb +116 -67
- 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,137 @@ 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
|
+
|
|
93
|
+
## [1.0.7] - 2025-10-30
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
- **Critical: Publish Race Condition**: Fixed race condition in `publish` method where callback could be called multiple times
|
|
97
|
+
- Added `callback_called` flag to prevent duplicate callback invocations
|
|
98
|
+
- Properly track completion of all async operations before calling final callback
|
|
99
|
+
- Ensures `success` status is correctly aggregated from all operations
|
|
100
|
+
- **Impact**: Eliminates unreliable message delivery status in high-concurrency scenarios
|
|
101
|
+
|
|
102
|
+
- **Critical: Thread Safety Issue**: Fixed thread safety issue in PubSubCoordinator message handling
|
|
103
|
+
- Changed `EventMachine.next_tick` to `EventMachine.schedule` for cross-thread safety
|
|
104
|
+
- Added reactor running check before scheduling
|
|
105
|
+
- Added error handling for subscriber callbacks
|
|
106
|
+
- **Impact**: Prevents undefined behavior when messages arrive from Redis pub/sub thread
|
|
107
|
+
|
|
108
|
+
- **Message Deduplication**: Fixed duplicate message enqueue issue
|
|
109
|
+
- Local published messages were being enqueued twice (local + pub/sub echo)
|
|
110
|
+
- Added message ID tracking to filter out locally published messages from pub/sub
|
|
111
|
+
- Messages now include unique IDs for deduplication
|
|
112
|
+
- **Impact**: Eliminates duplicate messages in single-server deployments
|
|
113
|
+
|
|
114
|
+
- **Batch Enqueue Logic**: Fixed `enqueue_messages_batch` to handle nil callbacks correctly
|
|
115
|
+
- Separated empty client list check from callback check
|
|
116
|
+
- Allows batch enqueue without callback (used by setup_message_routing)
|
|
117
|
+
- **Impact**: Fixes NoMethodError when enqueue is called without callback
|
|
118
|
+
|
|
119
|
+
### Added
|
|
120
|
+
- **Concurrency Test Suite**: Added comprehensive concurrency tests (spec/faye/redis_concurrency_spec.rb)
|
|
121
|
+
- Tests for callback guarantee (single invocation)
|
|
122
|
+
- Tests for concurrent publish operations
|
|
123
|
+
- Tests for multi-channel publishing
|
|
124
|
+
- Tests for error handling
|
|
125
|
+
- Stress test with 50 rapid publishes
|
|
126
|
+
- Thread safety tests
|
|
127
|
+
|
|
128
|
+
### Technical Details
|
|
129
|
+
**Publish Race Condition Fix**:
|
|
130
|
+
- Before: Multiple async callbacks could decrement counter and call callback multiple times
|
|
131
|
+
- After: Track completion with callback_called flag, ensure atomic callback invocation
|
|
132
|
+
|
|
133
|
+
**Thread Safety Fix**:
|
|
134
|
+
- Before: `EventMachine.next_tick` called from Redis subscriber thread (unsafe)
|
|
135
|
+
- After: `EventMachine.schedule` safely queues work from any thread to EM reactor
|
|
136
|
+
|
|
137
|
+
**Message Deduplication**:
|
|
138
|
+
- Before: Message published locally → enqueued → published to Redis → received back → enqueued again
|
|
139
|
+
- After: Track local message IDs, filter out self-published messages from pub/sub
|
|
140
|
+
|
|
10
141
|
## [1.0.6] - 2025-10-30
|
|
11
142
|
|
|
12
143
|
### Added
|
|
@@ -151,7 +282,8 @@ For 100 subscribers receiving one message:
|
|
|
151
282
|
### Security
|
|
152
283
|
- Client and message IDs now use `SecureRandom.uuid` instead of predictable time-based generation
|
|
153
284
|
|
|
154
|
-
[Unreleased]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.
|
|
285
|
+
[Unreleased]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.7...HEAD
|
|
286
|
+
[1.0.7]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.6...v1.0.7
|
|
155
287
|
[1.0.6]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.5...v1.0.6
|
|
156
288
|
[1.0.5]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.4...v1.0.5
|
|
157
289
|
[1.0.4]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.3...v1.0.4
|
|
@@ -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,11 +169,21 @@ module Faye
|
|
|
166
169
|
begin
|
|
167
170
|
message = JSON.parse(message_json)
|
|
168
171
|
|
|
169
|
-
# Notify
|
|
170
|
-
EventMachine.
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
# Notify the message handler
|
|
173
|
+
# Use EventMachine.schedule to safely call from non-EM thread
|
|
174
|
+
# (handle_message is called from subscriber_thread, not EM reactor thread)
|
|
175
|
+
if EventMachine.reactor_running?
|
|
176
|
+
EventMachine.schedule do
|
|
177
|
+
if @message_handler
|
|
178
|
+
begin
|
|
179
|
+
@message_handler.call(channel, message)
|
|
180
|
+
rescue => e
|
|
181
|
+
log_error("Message handler callback error for #{channel}: #{e.message}")
|
|
182
|
+
end
|
|
183
|
+
end
|
|
173
184
|
end
|
|
185
|
+
else
|
|
186
|
+
log_error("Cannot handle message: EventMachine reactor not running")
|
|
174
187
|
end
|
|
175
188
|
rescue JSON::ParserError => e
|
|
176
189
|
log_error("Failed to parse message from #{channel}: #{e.message}")
|
|
@@ -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,
|
|
@@ -105,34 +107,69 @@ module Faye
|
|
|
105
107
|
channels = [channels] unless channels.is_a?(Array)
|
|
106
108
|
|
|
107
109
|
begin
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
# Ensure message has an ID for deduplication
|
|
111
|
+
message = message.dup unless message.frozen?
|
|
112
|
+
message['id'] ||= generate_message_id
|
|
113
|
+
|
|
114
|
+
# Track this message as locally published with timestamp
|
|
115
|
+
if @local_message_ids
|
|
116
|
+
timestamp = Time.now.to_i
|
|
117
|
+
if @local_message_ids_mutex
|
|
118
|
+
@local_message_ids_mutex.synchronize { @local_message_ids[message['id']] = timestamp }
|
|
119
|
+
else
|
|
120
|
+
@local_message_ids[message['id']] = timestamp
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
total_channels = channels.size
|
|
125
|
+
completed_channels = 0
|
|
126
|
+
callback_called = false
|
|
127
|
+
all_success = true
|
|
110
128
|
|
|
111
129
|
channels.each do |channel|
|
|
112
130
|
# Get subscribers and process in parallel
|
|
113
131
|
@subscription_manager.get_subscribers(channel) do |client_ids|
|
|
114
|
-
#
|
|
132
|
+
# Track operations for this channel
|
|
133
|
+
pending_ops = 2 # pubsub + enqueue
|
|
134
|
+
channel_success = true
|
|
135
|
+
ops_completed = 0
|
|
136
|
+
|
|
137
|
+
complete_channel = lambda do
|
|
138
|
+
ops_completed += 1
|
|
139
|
+
if ops_completed == pending_ops
|
|
140
|
+
# This channel is complete
|
|
141
|
+
all_success &&= channel_success
|
|
142
|
+
completed_channels += 1
|
|
143
|
+
|
|
144
|
+
# Call final callback when all channels are done
|
|
145
|
+
if completed_channels == total_channels && !callback_called && callback
|
|
146
|
+
callback_called = true
|
|
147
|
+
EventMachine.next_tick { callback.call(all_success) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Publish to pub/sub
|
|
115
153
|
@pubsub_coordinator.publish(channel, message) do |published|
|
|
116
|
-
|
|
154
|
+
channel_success &&= published
|
|
155
|
+
complete_channel.call
|
|
117
156
|
end
|
|
118
157
|
|
|
119
|
-
# Enqueue for all subscribed clients
|
|
158
|
+
# Enqueue for all subscribed clients
|
|
120
159
|
if client_ids.any?
|
|
121
160
|
enqueue_messages_batch(client_ids, message) do |enqueued|
|
|
122
|
-
|
|
161
|
+
channel_success &&= enqueued
|
|
162
|
+
complete_channel.call
|
|
123
163
|
end
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
remaining_operations -= 1
|
|
128
|
-
if remaining_operations == 0 && callback
|
|
129
|
-
EventMachine.next_tick { callback.call(success) }
|
|
164
|
+
else
|
|
165
|
+
# No clients, but still need to complete
|
|
166
|
+
complete_channel.call
|
|
130
167
|
end
|
|
131
168
|
end
|
|
132
169
|
end
|
|
133
170
|
rescue => e
|
|
134
171
|
log_error("Failed to publish message to channels #{channels}: #{e.message}")
|
|
135
|
-
EventMachine.next_tick { callback.call(false) } if callback
|
|
172
|
+
EventMachine.next_tick { callback.call(false) } if callback && !callback_called
|
|
136
173
|
end
|
|
137
174
|
end
|
|
138
175
|
|
|
@@ -152,13 +189,20 @@ module Faye
|
|
|
152
189
|
|
|
153
190
|
# Clean up expired clients and their associated data
|
|
154
191
|
def cleanup_expired(&callback)
|
|
192
|
+
# Clean up stale local message IDs first
|
|
193
|
+
cleanup_stale_message_ids
|
|
194
|
+
|
|
155
195
|
@client_registry.cleanup_expired do |expired_count|
|
|
156
196
|
@logger.info("Cleaned up #{expired_count} expired clients") if expired_count > 0
|
|
157
197
|
|
|
158
|
-
# Always clean up orphaned subscription
|
|
198
|
+
# Always clean up orphaned subscription data (even if no expired clients)
|
|
159
199
|
# This handles cases where subscriptions were orphaned due to crashes
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
162
206
|
end
|
|
163
207
|
end
|
|
164
208
|
end
|
|
@@ -169,9 +213,20 @@ module Faye
|
|
|
169
213
|
SecureRandom.uuid
|
|
170
214
|
end
|
|
171
215
|
|
|
216
|
+
def generate_message_id
|
|
217
|
+
SecureRandom.uuid
|
|
218
|
+
end
|
|
219
|
+
|
|
172
220
|
# Batch enqueue messages to multiple clients using a single Redis pipeline
|
|
173
221
|
def enqueue_messages_batch(client_ids, message, &callback)
|
|
174
|
-
|
|
222
|
+
# Handle empty client list
|
|
223
|
+
if client_ids.empty?
|
|
224
|
+
EventMachine.next_tick { callback.call(true) } if callback
|
|
225
|
+
return
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# No callback provided, but still need to enqueue
|
|
229
|
+
# (setup_message_routing calls this without callback)
|
|
175
230
|
|
|
176
231
|
message_json = message.to_json
|
|
177
232
|
message_ttl = @options[:message_ttl] || 3600
|
|
@@ -195,67 +250,61 @@ module Faye
|
|
|
195
250
|
end
|
|
196
251
|
end
|
|
197
252
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
active_set = active_clients.to_set
|
|
202
|
-
namespace = @options[:namespace] || 'faye'
|
|
203
|
-
|
|
204
|
-
# Scan for subscription keys and clean up orphaned ones
|
|
205
|
-
@connection.with_redis do |redis|
|
|
206
|
-
cursor = "0"
|
|
207
|
-
orphaned_keys = []
|
|
253
|
+
# Clean up stale local message IDs (older than 5 minutes)
|
|
254
|
+
def cleanup_stale_message_ids
|
|
255
|
+
return unless @local_message_ids
|
|
208
256
|
|
|
209
|
-
|
|
210
|
-
|
|
257
|
+
cutoff = Time.now.to_i - 300 # 5 minutes
|
|
258
|
+
stale_count = 0
|
|
211
259
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
break if cursor == "0"
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
# Clean up orphaned subscription data
|
|
222
|
-
if orphaned_keys.any?
|
|
223
|
-
@logger.info("Cleaning up #{orphaned_keys.size} orphaned subscription sets")
|
|
224
|
-
|
|
225
|
-
orphaned_keys.each do |client_id|
|
|
226
|
-
# Get channels for this orphaned client
|
|
227
|
-
channels = redis.smembers("#{namespace}:subscriptions:#{client_id}")
|
|
228
|
-
|
|
229
|
-
# Remove in batch
|
|
230
|
-
redis.pipelined do |pipeline|
|
|
231
|
-
# Delete client's subscription list
|
|
232
|
-
pipeline.del("#{namespace}:subscriptions:#{client_id}")
|
|
233
|
-
|
|
234
|
-
# Delete each subscription metadata and remove from channel subscribers
|
|
235
|
-
channels.each do |channel|
|
|
236
|
-
pipeline.del("#{namespace}:subscription:#{client_id}:#{channel}")
|
|
237
|
-
pipeline.srem("#{namespace}:channels:#{channel}", client_id)
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
# Delete message queue if exists
|
|
241
|
-
pipeline.del("#{namespace}:messages:#{client_id}")
|
|
242
|
-
end
|
|
243
|
-
end
|
|
244
|
-
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
|
|
245
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
|
|
246
271
|
|
|
247
|
-
|
|
272
|
+
if stale_count > 0
|
|
273
|
+
@logger.info("Cleaned up #{stale_count} stale local message IDs")
|
|
248
274
|
end
|
|
249
275
|
rescue => e
|
|
250
|
-
log_error("Failed to cleanup
|
|
251
|
-
EventMachine.next_tick { callback.call } if callback
|
|
276
|
+
log_error("Failed to cleanup stale message IDs: #{e.message}")
|
|
252
277
|
end
|
|
253
278
|
|
|
254
279
|
def setup_message_routing
|
|
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 = {}
|
|
283
|
+
@local_message_ids_mutex = Mutex.new if defined?(Mutex)
|
|
284
|
+
|
|
255
285
|
# Subscribe to message events from other servers
|
|
256
286
|
@pubsub_coordinator.on_message do |channel, message|
|
|
287
|
+
# Skip if this is a message we just published locally
|
|
288
|
+
# (Redis pub/sub echoes back messages to the publisher)
|
|
289
|
+
message_id = message['id']
|
|
290
|
+
is_local = false
|
|
291
|
+
|
|
292
|
+
if message_id
|
|
293
|
+
if @local_message_ids_mutex
|
|
294
|
+
@local_message_ids_mutex.synchronize do
|
|
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)
|
|
298
|
+
end
|
|
299
|
+
else
|
|
300
|
+
is_local = @local_message_ids.key?(message_id)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
next if is_local
|
|
305
|
+
|
|
306
|
+
# Enqueue for remote servers' messages only
|
|
257
307
|
@subscription_manager.get_subscribers(channel) do |client_ids|
|
|
258
|
-
# Use batch enqueue for better performance
|
|
259
308
|
enqueue_messages_batch(client_ids, message) if client_ids.any?
|
|
260
309
|
end
|
|
261
310
|
end
|