smart_message 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ module SmartMessage
16
16
 
17
17
  def initialize(circuit_breaker_options = {})
18
18
  @subscribers = Hash.new { |h, k| h[k] = [] }
19
+ @subscriber_ddqs = {} # Hash to store DDQs per subscriber: "MessageClass:subscriber_id" => DDQ
19
20
  @router_pool = Concurrent::CachedThreadPool.new
20
21
 
21
22
  # Configure circuit breakers
@@ -97,39 +98,89 @@ module SmartMessage
97
98
 
98
99
  def add(message_class, process_method_as_string, filter_options = {})
99
100
  klass = String(message_class)
101
+
102
+ # Subscriber ID is derived from the process method (handler-only scoping)
103
+ subscriber_id = process_method_as_string
104
+
105
+ # Initialize DDQ for this handler if message class supports deduplication
106
+ begin
107
+ message_class_obj = klass.constantize
108
+ if message_class_obj.respond_to?(:ddq_enabled?) && message_class_obj.ddq_enabled?
109
+ initialize_ddq_for_subscriber(klass, subscriber_id, message_class_obj)
110
+ end
111
+ rescue NameError
112
+ # Message class doesn't exist as a constant (e.g., in tests with fake class names)
113
+ # Skip DDQ initialization - this is fine since the class wouldn't support deduplication anyway
114
+ end
100
115
 
101
- # Create subscription entry with filter options
116
+ # Create subscription entry with filter options and derived subscriber ID
102
117
  subscription = {
118
+ subscriber_id: subscriber_id,
103
119
  process_method: process_method_as_string,
104
120
  filters: filter_options
105
121
  }
106
122
 
107
123
  # Check if this exact subscription already exists
108
124
  existing_subscription = @subscribers[klass].find do |sub|
109
- sub[:process_method] == process_method_as_string && sub[:filters] == filter_options
125
+ sub[:subscriber_id] == subscriber_id &&
126
+ sub[:process_method] == process_method_as_string &&
127
+ sub[:filters] == filter_options
110
128
  end
111
129
 
112
130
  unless existing_subscription
113
131
  @subscribers[klass] << subscription
132
+ logger.debug { "[SmartMessage::Dispatcher] Added subscription: #{klass} for #{subscriber_id}" }
114
133
  end
115
134
  end
116
135
 
117
136
 
118
137
  # drop a processer from a subscribed message
119
- def drop(message_class, process_method_as_string)
138
+ def drop(message_class, process_method_as_string, filter_options = {})
120
139
  klass = String(message_class)
121
- @subscribers[klass].reject! { |sub| sub[:process_method] == process_method_as_string }
140
+
141
+ # Subscriber ID is derived from the process method
142
+ subscriber_id = process_method_as_string
143
+
144
+ # Drop the specific method (now uniquely identified by handler)
145
+ @subscribers[klass].reject! do |sub|
146
+ sub[:subscriber_id] == subscriber_id && sub[:process_method] == process_method_as_string
147
+ end
148
+
149
+ # Clean up DDQ if no more subscriptions for this handler
150
+ cleanup_ddq_if_empty(klass, subscriber_id)
122
151
  end
123
152
 
153
+ # drop all processors for a specific handler (subscriber)
154
+ def drop_subscriber(message_class, handler_method)
155
+ klass = String(message_class)
156
+ unless handler_method.is_a?(String) && !handler_method.empty?
157
+ raise ArgumentError, "handler_method must be a non-empty String, got: #{handler_method.inspect}"
158
+ end
159
+
160
+ # Handler method is the subscriber ID
161
+ subscriber_id = handler_method
162
+ @subscribers[klass].reject! { |sub| sub[:subscriber_id] == subscriber_id }
163
+ cleanup_ddq_if_empty(klass, subscriber_id)
164
+ end
124
165
 
125
- # drop all processer from a subscribed message
166
+ # drop all processors from a subscribed message
126
167
  def drop_all(message_class)
127
- @subscribers.delete String(message_class)
168
+ klass = String(message_class)
169
+
170
+ # Clean up all DDQs for this message class
171
+ if @subscribers[klass]
172
+ @subscribers[klass].each do |sub|
173
+ cleanup_ddq_if_empty(klass, sub[:subscriber_id], force: true)
174
+ end
175
+ end
176
+
177
+ @subscribers.delete klass
128
178
  end
129
179
 
130
-
131
180
  # complete reset all subscriptions
132
181
  def drop_all!
182
+ # Clean up all DDQs
183
+ @subscriber_ddqs&.clear
133
184
  @subscribers = Hash.new { |h, k| h[k] = [] }
134
185
  end
135
186
 
@@ -139,18 +190,37 @@ module SmartMessage
139
190
  def route(decoded_message)
140
191
  message_header = decoded_message._sm_header
141
192
  message_klass = message_header.message_class
193
+
194
+ # Try to get the message class object for deduplication checks
195
+ message_class_obj = nil
196
+ begin
197
+ message_class_obj = message_klass.constantize
198
+ rescue NameError
199
+ # Message class doesn't exist as a constant - skip deduplication checks
200
+ end
201
+
142
202
  logger.debug { "[SmartMessage::Dispatcher] Routing message #{message_klass} to #{@subscribers[message_klass]&.size || 0} subscribers" }
143
203
  logger.debug { "[SmartMessage::Dispatcher] Available subscribers: #{@subscribers.keys}" }
144
204
  return nil if @subscribers[message_klass].nil? || @subscribers[message_klass].empty?
145
205
 
146
206
  @subscribers[message_klass].each do |subscription|
147
207
  # Extract subscription details
208
+ subscriber_id = subscription[:subscriber_id]
148
209
  message_processor = subscription[:process_method]
149
210
  filters = subscription[:filters]
150
211
 
151
212
  # Check if message matches filters
152
213
  next unless message_matches_filters?(message_header, filters)
153
214
 
215
+ # Subscriber-specific deduplication check (only if class exists and supports it)
216
+ if message_class_obj&.respond_to?(:ddq_enabled?) && message_class_obj.ddq_enabled?
217
+ ddq = get_ddq_for_subscriber(message_klass, subscriber_id)
218
+ if ddq&.contains?(decoded_message.uuid)
219
+ logger.warn { "[SmartMessage::Dispatcher] Skipping duplicate for #{subscriber_id}: #{decoded_message.uuid} | Header: #{decoded_message._sm_header.to_h}" }
220
+ next # Skip this subscriber, continue to next
221
+ end
222
+ end
223
+
154
224
  SS.add(message_klass, message_processor, 'routed' )
155
225
  @router_pool.post do
156
226
  # Use circuit breaker to protect message processing
@@ -173,6 +243,15 @@ module SmartMessage
173
243
  # Handle circuit breaker fallback responses
174
244
  if circuit_result.is_a?(Hash) && circuit_result[:circuit_breaker]
175
245
  handle_circuit_breaker_fallback(circuit_result, decoded_message, message_processor)
246
+ else
247
+ # Mark message as processed in subscriber's DDQ after successful processing
248
+ if message_class_obj&.respond_to?(:ddq_enabled?) && message_class_obj.ddq_enabled?
249
+ ddq = get_ddq_for_subscriber(message_klass, subscriber_id)
250
+ if ddq && decoded_message.uuid
251
+ ddq.add(decoded_message.uuid)
252
+ logger.debug { "[SmartMessage::Dispatcher] Marked UUID as processed for #{subscriber_id}: #{decoded_message.uuid}" }
253
+ end
254
+ end
176
255
  end
177
256
  end
178
257
  end
@@ -231,26 +310,25 @@ module SmartMessage
231
310
  # @param filters [Hash] The filter criteria
232
311
  # @return [Boolean] True if the message matches all filters
233
312
  def message_matches_filters?(message_header, filters)
234
- # If no filters specified, accept all messages (backward compatibility)
235
- return true if filters.nil? || filters.empty? || filters.values.all?(&:nil?)
236
-
237
- # Check from filter
313
+ # Check from filter first (if specified)
238
314
  if filters[:from]
239
315
  from_match = filter_value_matches?(message_header.from, filters[:from])
240
316
  return false unless from_match
241
317
  end
242
318
 
243
319
  # Check to/broadcast filters (OR logic between them)
244
- if filters[:broadcast] || filters[:to]
320
+ # Only consider explicit filtering if the values are not nil
321
+ if filters[:to] || filters[:broadcast]
322
+ # Explicit filtering specified
245
323
  broadcast_match = filters[:broadcast] && message_header.to.nil?
246
324
  to_match = filters[:to] && filter_value_matches?(message_header.to, filters[:to])
247
-
248
- # If either broadcast or to filter is specified, at least one must match
249
- combined_match = (broadcast_match || to_match)
250
- return false unless combined_match
325
+
326
+ # At least one must match
327
+ return (broadcast_match || to_match)
328
+ else
329
+ # No to/broadcast filtering - accept all messages (backward compatibility)
330
+ return true
251
331
  end
252
-
253
- true
254
332
  end
255
333
 
256
334
  # Check if a value matches any of the filter criteria
@@ -361,6 +439,85 @@ module SmartMessage
361
439
  SS.add(message_header.message_class, message_processor, 'circuit_breaker_fallback')
362
440
  end
363
441
 
442
+ # Initialize DDQ for a subscriber if message class supports deduplication
443
+ # @param message_class [String] The message class name
444
+ # @param subscriber_id [String] The subscriber identifier
445
+ # @param message_class_obj [Class] The message class object
446
+ def initialize_ddq_for_subscriber(message_class, subscriber_id, message_class_obj)
447
+ ddq_key = "#{message_class}:#{subscriber_id}"
448
+ return if @subscriber_ddqs[ddq_key] # Already initialized
449
+
450
+ # Get DDQ configuration from message class
451
+ ddq_config = message_class_obj.ddq_config
452
+
453
+ begin
454
+ # Create DDQ instance using message class configuration
455
+ ddq = SmartMessage::DDQ.create(
456
+ ddq_config[:storage],
457
+ ddq_config[:size],
458
+ ddq_config[:options].merge(key_prefix: ddq_key)
459
+ )
460
+
461
+ @subscriber_ddqs[ddq_key] = ddq
462
+
463
+ logger.debug do
464
+ "[SmartMessage::Dispatcher] Initialized DDQ for #{subscriber_id}: " \
465
+ "storage=#{ddq_config[:storage]}, size=#{ddq_config[:size]}"
466
+ end
467
+ rescue => e
468
+ logger.error do
469
+ "[SmartMessage::Dispatcher] Failed to initialize DDQ for #{subscriber_id}: #{e.message}"
470
+ end
471
+ end
472
+ end
473
+
474
+ # Get DDQ instance for a specific subscriber
475
+ # @param message_class [String] The message class name
476
+ # @param subscriber_id [String] The subscriber identifier
477
+ # @return [SmartMessage::DDQ::Base, nil] The DDQ instance or nil
478
+ def get_ddq_for_subscriber(message_class, subscriber_id)
479
+ ddq_key = "#{message_class}:#{subscriber_id}"
480
+ @subscriber_ddqs[ddq_key]
481
+ end
482
+
483
+ # Clean up DDQ if no more subscriptions exist for a subscriber
484
+ # @param message_class [String] The message class name
485
+ # @param subscriber_id [String] The subscriber identifier
486
+ # @param force [Boolean] Force cleanup even if subscriptions exist
487
+ def cleanup_ddq_if_empty(message_class, subscriber_id, force: false)
488
+ ddq_key = "#{message_class}:#{subscriber_id}"
489
+
490
+ # Check if any subscriptions still exist for this subscriber
491
+ has_subscriptions = @subscribers[message_class]&.any? do |sub|
492
+ sub[:subscriber_id] == subscriber_id
493
+ end
494
+
495
+ if force || !has_subscriptions
496
+ ddq = @subscriber_ddqs.delete(ddq_key)
497
+ if ddq
498
+ logger.debug do
499
+ "[SmartMessage::Dispatcher] Cleaned up DDQ for #{subscriber_id}"
500
+ end
501
+ end
502
+ end
503
+ end
504
+
505
+ # Get DDQ statistics for all subscribers
506
+ # @return [Hash] DDQ statistics keyed by subscriber
507
+ def ddq_stats
508
+ stats = {}
509
+
510
+ @subscriber_ddqs.each do |ddq_key, ddq|
511
+ begin
512
+ stats[ddq_key] = ddq.stats
513
+ rescue => e
514
+ stats[ddq_key] = { error: e.message }
515
+ end
516
+ end
517
+
518
+ stats
519
+ end
520
+
364
521
 
365
522
  #######################################################
366
523
  ## Class methods
@@ -70,11 +70,11 @@ module SmartMessage
70
70
  # @param block [Proc] Alternative way to pass a processing block
71
71
  # @return [String] The identifier used for this subscription
72
72
  #
73
- # @example Using default handler (all messages)
73
+ # @example Using default handler
74
74
  # MyMessage.subscribe
75
75
  #
76
76
  # @example Using custom method name with filtering
77
- # MyMessage.subscribe("MyService.handle_message", to: 'my-service')
77
+ # MyMessage.subscribe("MyService.handle_message", from: ['order-service'])
78
78
  #
79
79
  # @example Using a block with broadcast filtering
80
80
  # MyMessage.subscribe(broadcast: true) do |header, payload|
@@ -82,11 +82,11 @@ module SmartMessage
82
82
  # puts "Received broadcast: #{data}"
83
83
  # end
84
84
  #
85
- # @example Entity-specific filtering
86
- # MyMessage.subscribe(to: 'order-service', from: ['payment', 'user'])
85
+ # @example Entity-specific filtering (receives only messages from payment service)
86
+ # MyMessage.subscribe("OrderService.process", from: ['payment'])
87
87
  #
88
- # @example Broadcast + directed messages
89
- # MyMessage.subscribe(to: 'my-service', broadcast: true)
88
+ # @example Explicit to filter
89
+ # MyMessage.subscribe("AdminService.handle", to: 'admin', broadcast: false)
90
90
  def subscribe(process_method = nil, broadcast: nil, to: nil, from: nil, &block)
91
91
  message_class = whoami
92
92
 
@@ -105,11 +105,14 @@ module SmartMessage
105
105
  end
106
106
  # If process_method is a String, use it as-is
107
107
 
108
+ # Subscriber identity is derived from the process method (handler)
109
+ # This ensures each handler gets its own DDQ scope per message class
110
+
108
111
  # Normalize string filters to arrays
109
112
  to_filter = normalize_filter_value(to)
110
113
  from_filter = normalize_filter_value(from)
111
114
 
112
- # Create filter options
115
+ # Create filter options (no explicit subscriber identity needed)
113
116
  filter_options = {
114
117
  broadcast: broadcast,
115
118
  to: to_filter,
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  module SmartMessage
6
- VERSION = '0.0.9'
6
+ VERSION = '0.0.10'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_message
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.9
4
+ version: 0.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -264,6 +264,7 @@ files:
264
264
  - docs/getting-started.md
265
265
  - docs/ideas_to_think_about.md
266
266
  - docs/logging.md
267
+ - docs/message_deduplication.md
267
268
  - docs/message_filtering.md
268
269
  - docs/message_processing.md
269
270
  - docs/proc_handlers_summary.md
@@ -284,6 +285,7 @@ files:
284
285
  - examples/09_dead_letter_queue_demo.rb
285
286
  - examples/09_regex_filtering_microservices.rb
286
287
  - examples/10_header_block_configuration.rb
288
+ - examples/10_message_deduplication.rb
287
289
  - examples/11_global_configuration_example.rb
288
290
  - examples/README.md
289
291
  - examples/dead_letters.jsonl
@@ -316,7 +318,12 @@ files:
316
318
  - lib/smart_message/base.rb
317
319
  - lib/smart_message/circuit_breaker.rb
318
320
  - lib/smart_message/configuration.rb
321
+ - lib/smart_message/ddq.rb
322
+ - lib/smart_message/ddq/base.rb
323
+ - lib/smart_message/ddq/memory.rb
324
+ - lib/smart_message/ddq/redis.rb
319
325
  - lib/smart_message/dead_letter_queue.rb
326
+ - lib/smart_message/deduplication.rb
320
327
  - lib/smart_message/dispatcher.rb
321
328
  - lib/smart_message/dispatcher/.keep
322
329
  - lib/smart_message/errors.rb