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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/Gemfile.lock +1 -1
- data/README.md +101 -3
- data/docs/architecture.md +139 -69
- data/docs/message_deduplication.md +488 -0
- data/examples/10_message_deduplication.rb +209 -0
- data/lib/smart_message/base.rb +2 -0
- data/lib/smart_message/ddq/base.rb +71 -0
- data/lib/smart_message/ddq/memory.rb +109 -0
- data/lib/smart_message/ddq/redis.rb +168 -0
- data/lib/smart_message/ddq.rb +31 -0
- data/lib/smart_message/deduplication.rb +174 -0
- data/lib/smart_message/dispatcher.rb +175 -18
- data/lib/smart_message/subscription.rb +10 -7
- data/lib/smart_message/version.rb +1 -1
- metadata +8 -1
@@ -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[:
|
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
|
-
|
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
|
166
|
+
# drop all processors from a subscribed message
|
126
167
|
def drop_all(message_class)
|
127
|
-
|
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
|
-
#
|
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
|
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
|
-
#
|
249
|
-
|
250
|
-
|
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
|
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",
|
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(
|
85
|
+
# @example Entity-specific filtering (receives only messages from payment service)
|
86
|
+
# MyMessage.subscribe("OrderService.process", from: ['payment'])
|
87
87
|
#
|
88
|
-
# @example
|
89
|
-
# MyMessage.subscribe(to: '
|
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,
|
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.
|
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
|