smart_message 0.0.8 → 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/.gitignore +1 -0
- data/.irbrc +24 -0
- data/CHANGELOG.md +119 -0
- data/Gemfile.lock +6 -1
- data/README.md +389 -17
- data/docs/README.md +3 -1
- data/docs/addressing.md +119 -13
- data/docs/architecture.md +184 -46
- data/docs/dead_letter_queue.md +673 -0
- data/docs/dispatcher.md +87 -0
- data/docs/examples.md +59 -1
- data/docs/getting-started.md +8 -1
- data/docs/logging.md +382 -326
- data/docs/message_deduplication.md +488 -0
- data/docs/message_filtering.md +451 -0
- data/examples/01_point_to_point_orders.rb +54 -53
- data/examples/02_publish_subscribe_events.rb +14 -10
- data/examples/03_many_to_many_chat.rb +16 -8
- data/examples/04_redis_smart_home_iot.rb +20 -10
- data/examples/05_proc_handlers.rb +12 -11
- data/examples/06_custom_logger_example.rb +95 -100
- data/examples/07_error_handling_scenarios.rb +4 -2
- data/examples/08_entity_addressing_basic.rb +18 -6
- data/examples/08_entity_addressing_with_filtering.rb +27 -9
- data/examples/09_dead_letter_queue_demo.rb +559 -0
- data/examples/09_regex_filtering_microservices.rb +407 -0
- data/examples/10_header_block_configuration.rb +263 -0
- data/examples/10_message_deduplication.rb +209 -0
- data/examples/11_global_configuration_example.rb +219 -0
- data/examples/README.md +102 -0
- data/examples/dead_letters.jsonl +12 -0
- data/examples/performance_metrics/benchmark_results_ractor_20250818_205603.json +135 -0
- data/examples/performance_metrics/benchmark_results_ractor_20250818_205831.json +135 -0
- data/examples/performance_metrics/benchmark_results_test_20250818_204942.json +130 -0
- data/examples/performance_metrics/benchmark_results_threadpool_20250818_204942.json +130 -0
- data/examples/performance_metrics/benchmark_results_threadpool_20250818_204959.json +130 -0
- data/examples/performance_metrics/benchmark_results_threadpool_20250818_205044.json +130 -0
- data/examples/performance_metrics/benchmark_results_threadpool_20250818_205109.json +130 -0
- data/examples/performance_metrics/benchmark_results_threadpool_20250818_205252.json +130 -0
- data/examples/performance_metrics/benchmark_results_unknown_20250819_172852.json +130 -0
- data/examples/performance_metrics/compare_benchmarks.rb +519 -0
- data/examples/performance_metrics/dead_letters.jsonl +3100 -0
- data/examples/performance_metrics/performance_benchmark.rb +344 -0
- data/examples/show_logger.rb +367 -0
- data/examples/show_me.rb +145 -0
- data/examples/temp.txt +94 -0
- data/examples/tmux_chat/bot_agent.rb +4 -2
- data/examples/tmux_chat/human_agent.rb +4 -2
- data/examples/tmux_chat/room_monitor.rb +4 -2
- data/examples/tmux_chat/shared_chat_system.rb +6 -3
- data/lib/smart_message/addressing.rb +259 -0
- data/lib/smart_message/base.rb +123 -599
- data/lib/smart_message/circuit_breaker.rb +2 -1
- data/lib/smart_message/configuration.rb +199 -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/dead_letter_queue.rb +27 -10
- data/lib/smart_message/deduplication.rb +174 -0
- data/lib/smart_message/dispatcher.rb +259 -61
- data/lib/smart_message/header.rb +5 -0
- data/lib/smart_message/logger/base.rb +21 -1
- data/lib/smart_message/logger/default.rb +88 -138
- data/lib/smart_message/logger/lumberjack.rb +324 -0
- data/lib/smart_message/logger/null.rb +81 -0
- data/lib/smart_message/logger.rb +17 -9
- data/lib/smart_message/messaging.rb +100 -0
- data/lib/smart_message/plugins.rb +132 -0
- data/lib/smart_message/serializer/base.rb +25 -8
- data/lib/smart_message/serializer/json.rb +5 -4
- data/lib/smart_message/subscription.rb +196 -0
- data/lib/smart_message/transport/base.rb +72 -41
- data/lib/smart_message/transport/memory_transport.rb +7 -5
- data/lib/smart_message/transport/redis_transport.rb +15 -45
- data/lib/smart_message/transport/stdout_transport.rb +18 -8
- data/lib/smart_message/transport.rb +1 -34
- data/lib/smart_message/utilities.rb +142 -0
- data/lib/smart_message/version.rb +1 -1
- data/lib/smart_message/versioning.rb +85 -0
- data/lib/smart_message/wrapper.rb.bak +132 -0
- data/lib/smart_message.rb +74 -28
- data/smart_message.gemspec +3 -0
- metadata +83 -3
- data/lib/smart_message/serializer.rb +0 -10
- data/lib/smart_message/wrapper.rb +0 -43
@@ -0,0 +1,174 @@
|
|
1
|
+
# lib/smart_message/deduplication.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
require_relative 'ddq'
|
6
|
+
|
7
|
+
module SmartMessage
|
8
|
+
# Deduplication functionality for message classes
|
9
|
+
#
|
10
|
+
# Provides class-level configuration and instance-level deduplication
|
11
|
+
# checking using a Deduplication Queue (DDQ).
|
12
|
+
module Deduplication
|
13
|
+
def self.included(base)
|
14
|
+
base.extend(ClassMethods)
|
15
|
+
base.instance_variable_set(:@ddq_size, DDQ::DEFAULT_SIZE)
|
16
|
+
base.instance_variable_set(:@ddq_storage, DDQ::DEFAULT_STORAGE)
|
17
|
+
base.instance_variable_set(:@ddq_options, {})
|
18
|
+
base.instance_variable_set(:@ddq_enabled, false)
|
19
|
+
base.instance_variable_set(:@ddq_instance, nil)
|
20
|
+
end
|
21
|
+
|
22
|
+
module ClassMethods
|
23
|
+
# Configure DDQ size for this message class
|
24
|
+
# @param size [Integer] Maximum number of UUIDs to track
|
25
|
+
def ddq_size(size)
|
26
|
+
unless size.is_a?(Integer) && size > 0
|
27
|
+
raise ArgumentError, "DDQ size must be a positive integer, got: #{size.inspect}"
|
28
|
+
end
|
29
|
+
@ddq_size = size
|
30
|
+
reset_ddq_instance! if ddq_enabled?
|
31
|
+
end
|
32
|
+
|
33
|
+
# Configure DDQ storage type for this message class
|
34
|
+
# @param storage [Symbol] Storage type (:memory or :redis)
|
35
|
+
# @param options [Hash] Additional options for the storage backend
|
36
|
+
def ddq_storage(storage, **options)
|
37
|
+
unless [:memory, :redis].include?(storage.to_sym)
|
38
|
+
raise ArgumentError, "DDQ storage must be :memory or :redis, got: #{storage.inspect}"
|
39
|
+
end
|
40
|
+
@ddq_storage = storage.to_sym
|
41
|
+
@ddq_options = options
|
42
|
+
reset_ddq_instance! if ddq_enabled?
|
43
|
+
end
|
44
|
+
|
45
|
+
# Enable deduplication for this message class
|
46
|
+
def enable_deduplication!
|
47
|
+
@ddq_enabled = true
|
48
|
+
get_ddq_instance # Initialize the DDQ
|
49
|
+
end
|
50
|
+
|
51
|
+
# Disable deduplication for this message class
|
52
|
+
def disable_deduplication!
|
53
|
+
@ddq_enabled = false
|
54
|
+
@ddq_instance = nil
|
55
|
+
end
|
56
|
+
|
57
|
+
# Check if deduplication is enabled
|
58
|
+
# @return [Boolean] true if DDQ is enabled
|
59
|
+
def ddq_enabled?
|
60
|
+
!!@ddq_enabled
|
61
|
+
end
|
62
|
+
|
63
|
+
# Get the current DDQ configuration
|
64
|
+
# @return [Hash] Current DDQ configuration
|
65
|
+
def ddq_config
|
66
|
+
{
|
67
|
+
enabled: ddq_enabled?,
|
68
|
+
size: @ddq_size,
|
69
|
+
storage: @ddq_storage,
|
70
|
+
options: @ddq_options
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
# Get DDQ statistics
|
75
|
+
# @return [Hash] DDQ statistics
|
76
|
+
def ddq_stats
|
77
|
+
return { enabled: false } unless ddq_enabled?
|
78
|
+
|
79
|
+
ddq = get_ddq_instance
|
80
|
+
if ddq
|
81
|
+
ddq.stats.merge(enabled: true)
|
82
|
+
else
|
83
|
+
{ enabled: true, error: "DDQ instance not available" }
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Clear the DDQ
|
88
|
+
def clear_ddq!
|
89
|
+
return unless ddq_enabled?
|
90
|
+
|
91
|
+
ddq = get_ddq_instance
|
92
|
+
ddq&.clear
|
93
|
+
end
|
94
|
+
|
95
|
+
# Check if a UUID is a duplicate (for external use)
|
96
|
+
# @param uuid [String] The UUID to check
|
97
|
+
# @return [Boolean] true if UUID is a duplicate
|
98
|
+
def duplicate_uuid?(uuid)
|
99
|
+
return false unless ddq_enabled?
|
100
|
+
|
101
|
+
ddq = get_ddq_instance
|
102
|
+
ddq ? ddq.contains?(uuid) : false
|
103
|
+
end
|
104
|
+
|
105
|
+
# Get the DDQ instance (exposed for testing)
|
106
|
+
def get_ddq_instance
|
107
|
+
return nil unless ddq_enabled?
|
108
|
+
|
109
|
+
# Return cached instance if available and configuration hasn't changed
|
110
|
+
if @ddq_instance
|
111
|
+
return @ddq_instance
|
112
|
+
end
|
113
|
+
|
114
|
+
# Create new DDQ instance
|
115
|
+
size = @ddq_size
|
116
|
+
storage = @ddq_storage
|
117
|
+
options = @ddq_options
|
118
|
+
|
119
|
+
ddq = DDQ.create(storage, size, options)
|
120
|
+
@ddq_instance = ddq
|
121
|
+
|
122
|
+
SmartMessage::Logger.default.debug do
|
123
|
+
"[SmartMessage::Deduplication] Created DDQ for #{self.name}: " \
|
124
|
+
"storage=#{storage}, size=#{size}, options=#{options}"
|
125
|
+
end
|
126
|
+
|
127
|
+
ddq
|
128
|
+
rescue => e
|
129
|
+
SmartMessage::Logger.default.error do
|
130
|
+
"[SmartMessage::Deduplication] Failed to create DDQ for #{self.name}: #{e.message}"
|
131
|
+
end
|
132
|
+
nil
|
133
|
+
end
|
134
|
+
|
135
|
+
private
|
136
|
+
|
137
|
+
def reset_ddq_instance!
|
138
|
+
@ddq_instance = nil
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# Instance methods for deduplication checking
|
143
|
+
|
144
|
+
# Check if this message is a duplicate based on its UUID
|
145
|
+
# @return [Boolean] true if this message UUID has been seen before
|
146
|
+
def duplicate?
|
147
|
+
return false unless self.class.ddq_enabled?
|
148
|
+
return false unless uuid
|
149
|
+
|
150
|
+
self.class.duplicate_uuid?(uuid)
|
151
|
+
end
|
152
|
+
|
153
|
+
# Mark this message as processed (add UUID to DDQ)
|
154
|
+
# @return [void]
|
155
|
+
def mark_as_processed!
|
156
|
+
return unless self.class.ddq_enabled?
|
157
|
+
return unless uuid
|
158
|
+
|
159
|
+
ddq = self.class.send(:get_ddq_instance)
|
160
|
+
if ddq
|
161
|
+
ddq.add(uuid)
|
162
|
+
SmartMessage::Logger.default.debug do
|
163
|
+
"[SmartMessage::Deduplication] Marked UUID as processed: #{uuid}"
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Get the message UUID
|
169
|
+
# @return [String, nil] The message UUID
|
170
|
+
def uuid
|
171
|
+
_sm_header&.uuid
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
@@ -15,15 +15,29 @@ module SmartMessage
|
|
15
15
|
# TODO: setup forwardable for some @router_pool methods
|
16
16
|
|
17
17
|
def initialize(circuit_breaker_options = {})
|
18
|
-
@subscribers = Hash.new
|
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
|
22
23
|
configure_circuit_breakers(circuit_breaker_options)
|
23
24
|
at_exit do
|
24
25
|
shutdown_pool
|
25
26
|
end
|
27
|
+
|
28
|
+
logger.debug { "[SmartMessage::Dispatcher] Initialized with circuit breaker options: #{circuit_breaker_options}" }
|
29
|
+
rescue => e
|
30
|
+
logger.error { "[SmartMessage] Error in dispatcher initialization: #{e.class.name} - #{e.message}" }
|
31
|
+
raise
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def logger
|
37
|
+
@logger ||= SmartMessage::Logger.default
|
26
38
|
end
|
39
|
+
|
40
|
+
public
|
27
41
|
|
28
42
|
|
29
43
|
def what_can_i_do?
|
@@ -85,57 +99,128 @@ module SmartMessage
|
|
85
99
|
def add(message_class, process_method_as_string, filter_options = {})
|
86
100
|
klass = String(message_class)
|
87
101
|
|
88
|
-
#
|
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
|
115
|
+
|
116
|
+
# Create subscription entry with filter options and derived subscriber ID
|
89
117
|
subscription = {
|
118
|
+
subscriber_id: subscriber_id,
|
90
119
|
process_method: process_method_as_string,
|
91
120
|
filters: filter_options
|
92
121
|
}
|
93
|
-
|
122
|
+
|
94
123
|
# Check if this exact subscription already exists
|
95
124
|
existing_subscription = @subscribers[klass].find do |sub|
|
96
|
-
sub[:
|
125
|
+
sub[:subscriber_id] == subscriber_id &&
|
126
|
+
sub[:process_method] == process_method_as_string &&
|
127
|
+
sub[:filters] == filter_options
|
97
128
|
end
|
98
|
-
|
129
|
+
|
99
130
|
unless existing_subscription
|
100
|
-
@subscribers[klass]
|
131
|
+
@subscribers[klass] << subscription
|
132
|
+
logger.debug { "[SmartMessage::Dispatcher] Added subscription: #{klass} for #{subscriber_id}" }
|
101
133
|
end
|
102
134
|
end
|
103
135
|
|
104
136
|
|
105
137
|
# drop a processer from a subscribed message
|
106
|
-
def drop(message_class, process_method_as_string)
|
138
|
+
def drop(message_class, process_method_as_string, filter_options = {})
|
107
139
|
klass = String(message_class)
|
108
|
-
|
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)
|
109
151
|
end
|
110
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
|
111
165
|
|
112
|
-
# drop all
|
166
|
+
# drop all processors from a subscribed message
|
113
167
|
def drop_all(message_class)
|
114
|
-
|
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
|
115
178
|
end
|
116
179
|
|
117
|
-
|
118
180
|
# complete reset all subscriptions
|
119
181
|
def drop_all!
|
120
|
-
|
182
|
+
# Clean up all DDQs
|
183
|
+
@subscriber_ddqs&.clear
|
184
|
+
@subscribers = Hash.new { |h, k| h[k] = [] }
|
121
185
|
end
|
122
186
|
|
123
187
|
|
124
|
-
#
|
125
|
-
#
|
126
|
-
|
127
|
-
|
188
|
+
# Route a decoded message to appropriate message processors
|
189
|
+
# @param decoded_message [SmartMessage::Base] The decoded message instance
|
190
|
+
def route(decoded_message)
|
191
|
+
message_header = decoded_message._sm_header
|
128
192
|
message_klass = message_header.message_class
|
129
|
-
return nil if @subscribers[message_klass].empty?
|
130
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
|
+
|
202
|
+
logger.debug { "[SmartMessage::Dispatcher] Routing message #{message_klass} to #{@subscribers[message_klass]&.size || 0} subscribers" }
|
203
|
+
logger.debug { "[SmartMessage::Dispatcher] Available subscribers: #{@subscribers.keys}" }
|
204
|
+
return nil if @subscribers[message_klass].nil? || @subscribers[message_klass].empty?
|
205
|
+
|
131
206
|
@subscribers[message_klass].each do |subscription|
|
132
207
|
# Extract subscription details
|
208
|
+
subscriber_id = subscription[:subscriber_id]
|
133
209
|
message_processor = subscription[:process_method]
|
134
210
|
filters = subscription[:filters]
|
135
|
-
|
211
|
+
|
136
212
|
# Check if message matches filters
|
137
213
|
next unless message_matches_filters?(message_header, filters)
|
138
|
-
|
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
|
+
|
139
224
|
SS.add(message_klass, message_processor, 'routed' )
|
140
225
|
@router_pool.post do
|
141
226
|
# Use circuit breaker to protect message processing
|
@@ -143,21 +228,30 @@ module SmartMessage
|
|
143
228
|
# Check if this is a proc handler or a regular method call
|
144
229
|
if proc_handler?(message_processor)
|
145
230
|
# Call the proc handler via SmartMessage::Base
|
146
|
-
SmartMessage::Base.call_proc_handler(message_processor,
|
231
|
+
SmartMessage::Base.call_proc_handler(message_processor, decoded_message)
|
147
232
|
else
|
148
|
-
#
|
233
|
+
# Method call logic with decoded message
|
149
234
|
parts = message_processor.split('.')
|
150
235
|
target_klass = parts[0]
|
151
236
|
class_method = parts[1]
|
152
237
|
target_klass.constantize
|
153
238
|
.method(class_method)
|
154
|
-
.call(
|
239
|
+
.call(decoded_message)
|
155
240
|
end
|
156
241
|
end
|
157
|
-
|
242
|
+
|
158
243
|
# Handle circuit breaker fallback responses
|
159
244
|
if circuit_result.is_a?(Hash) && circuit_result[:circuit_breaker]
|
160
|
-
handle_circuit_breaker_fallback(circuit_result,
|
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
|
161
255
|
end
|
162
256
|
end
|
163
257
|
end
|
@@ -167,7 +261,7 @@ module SmartMessage
|
|
167
261
|
# @return [Hash] Circuit breaker statistics
|
168
262
|
def circuit_breaker_stats
|
169
263
|
stats = {}
|
170
|
-
|
264
|
+
|
171
265
|
begin
|
172
266
|
if respond_to?(:circuit)
|
173
267
|
breaker = circuit(:message_processor)
|
@@ -186,7 +280,7 @@ module SmartMessage
|
|
186
280
|
rescue => e
|
187
281
|
stats[:error] = "Failed to get circuit breaker stats: #{e.message}"
|
188
282
|
end
|
189
|
-
|
283
|
+
|
190
284
|
stats
|
191
285
|
end
|
192
286
|
|
@@ -204,7 +298,7 @@ module SmartMessage
|
|
204
298
|
# Shutdown the router pool with timeout and fallback
|
205
299
|
def shutdown_pool
|
206
300
|
@router_pool.shutdown
|
207
|
-
|
301
|
+
|
208
302
|
# Wait for graceful shutdown, force kill if timeout
|
209
303
|
unless @router_pool.wait_for_termination(3)
|
210
304
|
@router_pool.kill
|
@@ -216,26 +310,45 @@ module SmartMessage
|
|
216
310
|
# @param filters [Hash] The filter criteria
|
217
311
|
# @return [Boolean] True if the message matches all filters
|
218
312
|
def message_matches_filters?(message_header, filters)
|
219
|
-
#
|
220
|
-
return true if filters.nil? || filters.empty? || filters.values.all?(&:nil?)
|
221
|
-
|
222
|
-
# Check from filter
|
313
|
+
# Check from filter first (if specified)
|
223
314
|
if filters[:from]
|
224
|
-
from_match = filters[:from]
|
315
|
+
from_match = filter_value_matches?(message_header.from, filters[:from])
|
225
316
|
return false unless from_match
|
226
317
|
end
|
227
|
-
|
318
|
+
|
228
319
|
# Check to/broadcast filters (OR logic between them)
|
229
|
-
if
|
320
|
+
# Only consider explicit filtering if the values are not nil
|
321
|
+
if filters[:to] || filters[:broadcast]
|
322
|
+
# Explicit filtering specified
|
230
323
|
broadcast_match = filters[:broadcast] && message_header.to.nil?
|
231
|
-
to_match = filters[:to] && filters[:to]
|
324
|
+
to_match = filters[:to] && filter_value_matches?(message_header.to, filters[:to])
|
232
325
|
|
233
|
-
#
|
234
|
-
|
235
|
-
|
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
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
# Check if a value matches any of the filter criteria
|
335
|
+
# Supports both exact string matching and regex pattern matching
|
336
|
+
# @param value [String, nil] The value to match against
|
337
|
+
# @param filter_array [Array] Array of strings and/or regexps to match against
|
338
|
+
# @return [Boolean] True if the value matches any filter in the array
|
339
|
+
def filter_value_matches?(value, filter_array)
|
340
|
+
return false if value.nil? || filter_array.nil?
|
341
|
+
|
342
|
+
filter_array.any? do |filter|
|
343
|
+
case filter
|
344
|
+
when String
|
345
|
+
filter == value
|
346
|
+
when Regexp
|
347
|
+
filter.match?(value)
|
348
|
+
else
|
349
|
+
false
|
350
|
+
end
|
236
351
|
end
|
237
|
-
|
238
|
-
true
|
239
352
|
end
|
240
353
|
|
241
354
|
# Check if a message processor is a proc handler
|
@@ -250,19 +363,19 @@ module SmartMessage
|
|
250
363
|
def configure_circuit_breakers(options = {})
|
251
364
|
# Ensure CircuitBreaker module is available
|
252
365
|
return unless defined?(SmartMessage::CircuitBreaker::DEFAULT_CONFIGS)
|
253
|
-
|
366
|
+
|
254
367
|
# Configure message processor circuit breaker
|
255
368
|
default_config = SmartMessage::CircuitBreaker::DEFAULT_CONFIGS[:message_processor]
|
256
369
|
return unless default_config
|
257
|
-
|
370
|
+
|
258
371
|
processor_config = default_config.merge(options[:message_processor] || {})
|
259
|
-
|
372
|
+
|
260
373
|
# Define the circuit using the class-level DSL
|
261
374
|
self.class.circuit :message_processor do
|
262
|
-
threshold failures: processor_config[:threshold][:failures],
|
375
|
+
threshold failures: processor_config[:threshold][:failures],
|
263
376
|
within: processor_config[:threshold][:within].seconds
|
264
377
|
reset_after processor_config[:reset_after].seconds
|
265
|
-
|
378
|
+
|
266
379
|
# Configure storage backend
|
267
380
|
case processor_config[:storage]
|
268
381
|
when :redis
|
@@ -281,7 +394,7 @@ module SmartMessage
|
|
281
394
|
else
|
282
395
|
storage BreakerMachines::Storage::Memory.new
|
283
396
|
end
|
284
|
-
|
397
|
+
|
285
398
|
# Default fallback for message processing failures
|
286
399
|
fallback do |exception|
|
287
400
|
{
|
@@ -296,30 +409,115 @@ module SmartMessage
|
|
296
409
|
}
|
297
410
|
end
|
298
411
|
end
|
299
|
-
|
412
|
+
|
300
413
|
end
|
301
414
|
|
302
415
|
# Handle circuit breaker fallback responses
|
303
416
|
# @param circuit_result [Hash] The circuit breaker fallback result
|
304
|
-
# @param
|
305
|
-
# @param message_payload [String] The message payload
|
417
|
+
# @param decoded_message [SmartMessage::Base] The decoded message instance
|
306
418
|
# @param message_processor [String] The processor that failed
|
307
|
-
def handle_circuit_breaker_fallback(circuit_result,
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
419
|
+
def handle_circuit_breaker_fallback(circuit_result, decoded_message, message_processor)
|
420
|
+
message_header = decoded_message._sm_header
|
421
|
+
|
422
|
+
# Always log circuit breaker activation for debugging
|
423
|
+
error_msg = circuit_result[:circuit_breaker][:error]
|
424
|
+
logger.error { "[SmartMessage::Dispatcher] Circuit breaker activated for processor: #{message_processor}" }
|
425
|
+
logger.error { "[SmartMessage::Dispatcher] Error: #{error_msg}" }
|
426
|
+
logger.error { "[SmartMessage::Dispatcher] Message: #{message_header.message_class} from #{message_header.from}" }
|
427
|
+
|
428
|
+
# Send to dead letter queue
|
429
|
+
SmartMessage::DeadLetterQueue.default.enqueue(decoded_message,
|
430
|
+
error: circuit_result[:circuit_breaker][:error],
|
431
|
+
retry_count: 0,
|
432
|
+
transport: 'circuit_breaker'
|
433
|
+
)
|
434
|
+
|
315
435
|
# TODO: Integrate with structured logging when implemented
|
316
|
-
# TODO: Send to dead letter queue when implemented
|
317
436
|
# TODO: Emit metrics/events for monitoring
|
318
|
-
|
319
|
-
#
|
437
|
+
|
438
|
+
# Record the failure in simple stats
|
320
439
|
SS.add(message_header.message_class, message_processor, 'circuit_breaker_fallback')
|
321
440
|
end
|
322
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
|
+
|
323
521
|
|
324
522
|
#######################################################
|
325
523
|
## Class methods
|
data/lib/smart_message/header.rb
CHANGED
@@ -57,5 +57,10 @@ module SmartMessage
|
|
57
57
|
property :reply_to,
|
58
58
|
required: false,
|
59
59
|
description: "Optional unique identifier of the entity that should receive replies to this message. Defaults to 'from' entity if not specified"
|
60
|
+
|
61
|
+
# Serialization tracking for wrapper architecture
|
62
|
+
property :serializer,
|
63
|
+
required: false,
|
64
|
+
description: "Class name of the serializer used to encode the payload (e.g., 'SmartMessage::Serializer::Json'). Used by DLQ and cross-serializer gateway patterns"
|
60
65
|
end
|
61
66
|
end
|
@@ -4,5 +4,25 @@
|
|
4
4
|
|
5
5
|
module SmartMessage::Logger
|
6
6
|
class Base
|
7
|
+
# Standard logging methods that subclasses should implement
|
8
|
+
def debug(message = nil, &block)
|
9
|
+
raise NotImplementedError, "Subclass must implement #debug"
|
10
|
+
end
|
11
|
+
|
12
|
+
def info(message = nil, &block)
|
13
|
+
raise NotImplementedError, "Subclass must implement #info"
|
14
|
+
end
|
15
|
+
|
16
|
+
def warn(message = nil, &block)
|
17
|
+
raise NotImplementedError, "Subclass must implement #warn"
|
18
|
+
end
|
19
|
+
|
20
|
+
def error(message = nil, &block)
|
21
|
+
raise NotImplementedError, "Subclass must implement #error"
|
22
|
+
end
|
23
|
+
|
24
|
+
def fatal(message = nil, &block)
|
25
|
+
raise NotImplementedError, "Subclass must implement #fatal"
|
26
|
+
end
|
7
27
|
end
|
8
|
-
end # module SmartMessage::Logger
|
28
|
+
end # module SmartMessage::Logger
|