faye-redis-ng 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f87f5800dede3c5720fc5ab77944c3322f735cd808c1b02edd218fc6d97bca8b
4
- data.tar.gz: 13d579f33d6620ab7ecdc3567b45dd7c4a58264ed0115b25ddc39454662b2e0c
3
+ metadata.gz: 4a2f33a6f83547306e5a0e52a70e2e8e06a236703c5a56bffc7cf85a829d0a54
4
+ data.tar.gz: 0e62be0064f4307be4a87d94ddce9e424d7ee4dbad9a85fdf41c03c7ed5db854
5
5
  SHA512:
6
- metadata.gz: fbd69bad2590d4e55b141941952c95e316c3d8dd2a1e37f4ebbe3129ffe00b48957e8ba86c02732ef67aad97200c47b27dc6c663c5c41640cbaa0dd6787e8514
7
- data.tar.gz: 8c08572629040c40e130f03c0881e864f02a1dbfb89b5db9c0509c255d4cf986a9f38bf5267c4834216dcae0ca26ef6c115742b42c4f61095c9e4cb14264824a
6
+ metadata.gz: f67fd292dd0bf0b9fb90a3af34fc7b8ae15627569090c303a06b58f89702124319a8ad96f75603cc38570c5881aa0f4ecaf0e0df39a43ad96ddb9483c3bac51e
7
+ data.tar.gz: 697a5b1bbd62ebd8f87da936ffc0aa3dc4cad84c9a010f4537dfd5e21c1ea1847ce0af757085bb3a4c7dc6f3a2952cf4c53e01d4a6f33e2fa4e714faffec61be
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.7] - 2025-10-30
11
+
12
+ ### Fixed
13
+ - **Critical: Publish Race Condition**: Fixed race condition in `publish` method where callback could be called multiple times
14
+ - Added `callback_called` flag to prevent duplicate callback invocations
15
+ - Properly track completion of all async operations before calling final callback
16
+ - Ensures `success` status is correctly aggregated from all operations
17
+ - **Impact**: Eliminates unreliable message delivery status in high-concurrency scenarios
18
+
19
+ - **Critical: Thread Safety Issue**: Fixed thread safety issue in PubSubCoordinator message handling
20
+ - Changed `EventMachine.next_tick` to `EventMachine.schedule` for cross-thread safety
21
+ - Added reactor running check before scheduling
22
+ - Added error handling for subscriber callbacks
23
+ - **Impact**: Prevents undefined behavior when messages arrive from Redis pub/sub thread
24
+
25
+ - **Message Deduplication**: Fixed duplicate message enqueue issue
26
+ - Local published messages were being enqueued twice (local + pub/sub echo)
27
+ - Added message ID tracking to filter out locally published messages from pub/sub
28
+ - Messages now include unique IDs for deduplication
29
+ - **Impact**: Eliminates duplicate messages in single-server deployments
30
+
31
+ - **Batch Enqueue Logic**: Fixed `enqueue_messages_batch` to handle nil callbacks correctly
32
+ - Separated empty client list check from callback check
33
+ - Allows batch enqueue without callback (used by setup_message_routing)
34
+ - **Impact**: Fixes NoMethodError when enqueue is called without callback
35
+
36
+ ### Added
37
+ - **Concurrency Test Suite**: Added comprehensive concurrency tests (spec/faye/redis_concurrency_spec.rb)
38
+ - Tests for callback guarantee (single invocation)
39
+ - Tests for concurrent publish operations
40
+ - Tests for multi-channel publishing
41
+ - Tests for error handling
42
+ - Stress test with 50 rapid publishes
43
+ - Thread safety tests
44
+
45
+ ### Technical Details
46
+ **Publish Race Condition Fix**:
47
+ - Before: Multiple async callbacks could decrement counter and call callback multiple times
48
+ - After: Track completion with callback_called flag, ensure atomic callback invocation
49
+
50
+ **Thread Safety Fix**:
51
+ - Before: `EventMachine.next_tick` called from Redis subscriber thread (unsafe)
52
+ - After: `EventMachine.schedule` safely queues work from any thread to EM reactor
53
+
54
+ **Message Deduplication**:
55
+ - Before: Message published locally → enqueued → published to Redis → received back → enqueued again
56
+ - After: Track local message IDs, filter out self-published messages from pub/sub
57
+
10
58
  ## [1.0.6] - 2025-10-30
11
59
 
12
60
  ### Added
@@ -151,7 +199,8 @@ For 100 subscribers receiving one message:
151
199
  ### Security
152
200
  - Client and message IDs now use `SecureRandom.uuid` instead of predictable time-based generation
153
201
 
154
- [Unreleased]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.6...HEAD
202
+ [Unreleased]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.7...HEAD
203
+ [1.0.7]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.6...v1.0.7
155
204
  [1.0.6]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.5...v1.0.6
156
205
  [1.0.5]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.4...v1.0.5
157
206
  [1.0.4]: https://github.com/7a6163/faye-redis-ng/compare/v1.0.3...v1.0.4
@@ -166,11 +166,21 @@ module Faye
166
166
  begin
167
167
  message = JSON.parse(message_json)
168
168
 
169
- # Notify all subscribers (use dup to avoid concurrent modification)
170
- EventMachine.next_tick do
171
- @subscribers.dup.each do |subscriber|
172
- subscriber.call(channel, message)
169
+ # Notify all subscribers
170
+ # Use EventMachine.schedule to safely call from non-EM thread
171
+ # (handle_message is called from subscriber_thread, not EM reactor thread)
172
+ if EventMachine.reactor_running?
173
+ EventMachine.schedule do
174
+ @subscribers.dup.each do |subscriber|
175
+ begin
176
+ subscriber.call(channel, message)
177
+ rescue => e
178
+ log_error("Subscriber callback error for #{channel}: #{e.message}")
179
+ end
180
+ end
173
181
  end
182
+ else
183
+ log_error("Cannot handle message: EventMachine reactor not running")
174
184
  end
175
185
  rescue JSON::ParserError => e
176
186
  log_error("Failed to parse message from #{channel}: #{e.message}")
@@ -1,5 +1,5 @@
1
1
  module Faye
2
2
  class Redis
3
- VERSION = '1.0.6'
3
+ VERSION = '1.0.7'
4
4
  end
5
5
  end
data/lib/faye/redis.rb CHANGED
@@ -105,34 +105,68 @@ module Faye
105
105
  channels = [channels] unless channels.is_a?(Array)
106
106
 
107
107
  begin
108
- remaining_operations = channels.size
109
- success = true
108
+ # Ensure message has an ID for deduplication
109
+ message = message.dup unless message.frozen?
110
+ message['id'] ||= generate_message_id
111
+
112
+ # Track this message as locally published
113
+ if @local_message_ids
114
+ if @local_message_ids_mutex
115
+ @local_message_ids_mutex.synchronize { @local_message_ids.add(message['id']) }
116
+ else
117
+ @local_message_ids.add(message['id'])
118
+ end
119
+ end
120
+
121
+ total_channels = channels.size
122
+ completed_channels = 0
123
+ callback_called = false
124
+ all_success = true
110
125
 
111
126
  channels.each do |channel|
112
127
  # Get subscribers and process in parallel
113
128
  @subscription_manager.get_subscribers(channel) do |client_ids|
114
- # Immediately publish to pub/sub (don't wait for enqueue)
129
+ # Track operations for this channel
130
+ pending_ops = 2 # pubsub + enqueue
131
+ channel_success = true
132
+ ops_completed = 0
133
+
134
+ complete_channel = lambda do
135
+ ops_completed += 1
136
+ if ops_completed == pending_ops
137
+ # This channel is complete
138
+ all_success &&= channel_success
139
+ completed_channels += 1
140
+
141
+ # Call final callback when all channels are done
142
+ if completed_channels == total_channels && !callback_called && callback
143
+ callback_called = true
144
+ EventMachine.next_tick { callback.call(all_success) }
145
+ end
146
+ end
147
+ end
148
+
149
+ # Publish to pub/sub
115
150
  @pubsub_coordinator.publish(channel, message) do |published|
116
- success &&= published
151
+ channel_success &&= published
152
+ complete_channel.call
117
153
  end
118
154
 
119
- # Enqueue for all subscribed clients in parallel (batch operation)
155
+ # Enqueue for all subscribed clients
120
156
  if client_ids.any?
121
157
  enqueue_messages_batch(client_ids, message) do |enqueued|
122
- success &&= enqueued
158
+ channel_success &&= enqueued
159
+ complete_channel.call
123
160
  end
124
- end
125
-
126
- # Track completion
127
- remaining_operations -= 1
128
- if remaining_operations == 0 && callback
129
- EventMachine.next_tick { callback.call(success) }
161
+ else
162
+ # No clients, but still need to complete
163
+ complete_channel.call
130
164
  end
131
165
  end
132
166
  end
133
167
  rescue => e
134
168
  log_error("Failed to publish message to channels #{channels}: #{e.message}")
135
- EventMachine.next_tick { callback.call(false) } if callback
169
+ EventMachine.next_tick { callback.call(false) } if callback && !callback_called
136
170
  end
137
171
  end
138
172
 
@@ -169,9 +203,20 @@ module Faye
169
203
  SecureRandom.uuid
170
204
  end
171
205
 
206
+ def generate_message_id
207
+ SecureRandom.uuid
208
+ end
209
+
172
210
  # Batch enqueue messages to multiple clients using a single Redis pipeline
173
211
  def enqueue_messages_batch(client_ids, message, &callback)
174
- return EventMachine.next_tick { callback.call(true) } if client_ids.empty? || !callback
212
+ # Handle empty client list
213
+ if client_ids.empty?
214
+ EventMachine.next_tick { callback.call(true) } if callback
215
+ return
216
+ end
217
+
218
+ # No callback provided, but still need to enqueue
219
+ # (setup_message_routing calls this without callback)
175
220
 
176
221
  message_json = message.to_json
177
222
  message_ttl = @options[:message_ttl] || 3600
@@ -252,10 +297,31 @@ module Faye
252
297
  end
253
298
 
254
299
  def setup_message_routing
300
+ # Track locally published message IDs to avoid duplicate enqueue
301
+ @local_message_ids = Set.new
302
+ @local_message_ids_mutex = Mutex.new if defined?(Mutex)
303
+
255
304
  # Subscribe to message events from other servers
256
305
  @pubsub_coordinator.on_message do |channel, message|
306
+ # Skip if this is a message we just published locally
307
+ # (Redis pub/sub echoes back messages to the publisher)
308
+ message_id = message['id']
309
+ is_local = false
310
+
311
+ if message_id
312
+ if @local_message_ids_mutex
313
+ @local_message_ids_mutex.synchronize do
314
+ is_local = @local_message_ids.delete(message_id)
315
+ end
316
+ else
317
+ is_local = @local_message_ids.delete(message_id)
318
+ end
319
+ end
320
+
321
+ next if is_local
322
+
323
+ # Enqueue for remote servers' messages only
257
324
  @subscription_manager.get_subscribers(channel) do |client_ids|
258
- # Use batch enqueue for better performance
259
325
  enqueue_messages_batch(client_ids, message) if client_ids.any?
260
326
  end
261
327
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: faye-redis-ng
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.6
4
+ version: 1.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac