smart_message 0.0.6 ā 0.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 +102 -0
- data/Gemfile.lock +9 -1
- data/examples/01_point_to_point_orders.rb +4 -2
- data/examples/02_publish_subscribe_events.rb +2 -1
- data/examples/03_many_to_many_chat.rb +10 -5
- data/examples/04_redis_smart_home_iot.rb +32 -16
- data/examples/05_proc_handlers.rb +3 -2
- data/examples/06_custom_logger_example.rb +13 -7
- data/examples/07_error_handling_scenarios.rb +26 -15
- data/examples/08_entity_addressing_basic.rb +14 -7
- data/examples/08_entity_addressing_with_filtering.rb +24 -12
- data/examples/tmux_chat/bot_agent.rb +2 -1
- data/examples/tmux_chat/shared_chat_system.rb +4 -2
- data/lib/smart_message/base.rb +6 -3
- data/lib/smart_message/circuit_breaker.rb +243 -0
- data/lib/smart_message/dead_letter_queue.rb +344 -0
- data/lib/smart_message/dispatcher.rb +136 -15
- data/lib/smart_message/serializer/base.rb +77 -4
- data/lib/smart_message/serializer/json.rb +2 -2
- data/lib/smart_message/transport/base.rb +138 -2
- data/lib/smart_message/transport/memory_transport.rb +1 -1
- data/lib/smart_message/transport/redis_transport.rb +43 -13
- data/lib/smart_message/transport/stdout_transport.rb +1 -1
- data/lib/smart_message/version.rb +1 -1
- data/lib/smart_message.rb +2 -0
- data/smart_message.gemspec +1 -0
- metadata +17 -1
@@ -87,7 +87,8 @@ puts "\nš¤ Publishing test messages..."
|
|
87
87
|
# Broadcast message - should only be received by broadcast handler
|
88
88
|
broadcast_msg = ServiceMessage.new(
|
89
89
|
message_type: 'system_announcement',
|
90
|
-
data: 'System maintenance at 2 AM'
|
90
|
+
data: 'System maintenance at 2 AM',
|
91
|
+
from: 'sender-service'
|
91
92
|
)
|
92
93
|
broadcast_msg.to(nil) # Explicitly set as broadcast
|
93
94
|
puts "\n1. Publishing broadcast message (no 'to' field)..."
|
@@ -97,7 +98,8 @@ sleep(0.2) # Allow time for handlers to process
|
|
97
98
|
# Directed message to 'my-service' - should only be received by directed handler
|
98
99
|
directed_msg = ServiceMessage.new(
|
99
100
|
message_type: 'service_update',
|
100
|
-
data: 'Update your configuration'
|
101
|
+
data: 'Update your configuration',
|
102
|
+
from: 'sender-service'
|
101
103
|
)
|
102
104
|
directed_msg.to('my-service')
|
103
105
|
puts "\n2. Publishing message to 'my-service'..."
|
@@ -107,7 +109,8 @@ sleep(0.2) # Allow time for handlers to process
|
|
107
109
|
# Directed message to different service - should NOT be received
|
108
110
|
other_msg = ServiceMessage.new(
|
109
111
|
message_type: 'other_update',
|
110
|
-
data: 'This is for another service'
|
112
|
+
data: 'This is for another service',
|
113
|
+
from: 'sender-service'
|
111
114
|
)
|
112
115
|
other_msg.to('other-service')
|
113
116
|
puts "\n3. Publishing message to 'other-service' (should not be received)..."
|
@@ -117,7 +120,8 @@ sleep(0.2) # Allow time to confirm no handlers process this
|
|
117
120
|
# Message from admin - should only be received by admin handler
|
118
121
|
admin_msg = ServiceMessage.new(
|
119
122
|
message_type: 'admin_command',
|
120
|
-
data: 'Restart all services'
|
123
|
+
data: 'Restart all services',
|
124
|
+
from: 'admin-service'
|
121
125
|
)
|
122
126
|
admin_msg.from('admin-service')
|
123
127
|
admin_msg.to('my-service')
|
@@ -191,7 +195,8 @@ puts "\nš¤ Publishing alert messages..."
|
|
191
195
|
broadcast_alert = AlertMessage.new(
|
192
196
|
severity: 'warning',
|
193
197
|
alert_text: 'CPU usage high across cluster',
|
194
|
-
source_system: 'cluster-monitor'
|
198
|
+
source_system: 'cluster-monitor',
|
199
|
+
from: 'monitoring-system-1'
|
195
200
|
)
|
196
201
|
broadcast_alert.from('monitoring-system-1')
|
197
202
|
broadcast_alert.to(nil) # Broadcast
|
@@ -203,7 +208,8 @@ sleep(0.2) # Allow time for handlers to process
|
|
203
208
|
directed_alert = AlertMessage.new(
|
204
209
|
severity: 'critical',
|
205
210
|
alert_text: 'Database connection lost',
|
206
|
-
source_system: 'db-monitor'
|
211
|
+
source_system: 'db-monitor',
|
212
|
+
from: 'monitoring-system-2'
|
207
213
|
)
|
208
214
|
directed_alert.from('monitoring-system-2')
|
209
215
|
directed_alert.to('alert-service')
|
@@ -215,7 +221,8 @@ sleep(0.2) # Allow time for handlers to process
|
|
215
221
|
other_alert = AlertMessage.new(
|
216
222
|
severity: 'info',
|
217
223
|
alert_text: 'Backup completed successfully',
|
218
|
-
source_system: 'backup-system'
|
224
|
+
source_system: 'backup-system',
|
225
|
+
from: 'monitoring-system-1'
|
219
226
|
)
|
220
227
|
other_alert.from('monitoring-system-1')
|
221
228
|
other_alert.to('backup-service')
|
@@ -285,7 +292,8 @@ normal_order = OrderMessage.new(
|
|
285
292
|
order_id: "ORD-001",
|
286
293
|
priority: 'normal',
|
287
294
|
items: ["Widget A", "Widget B"],
|
288
|
-
total_amount: 99.99
|
295
|
+
total_amount: 99.99,
|
296
|
+
from: 'order-service'
|
289
297
|
)
|
290
298
|
puts "\n1. Publishing normal order to fulfillment..."
|
291
299
|
normal_order.publish
|
@@ -296,7 +304,8 @@ high_priority_order = OrderMessage.new(
|
|
296
304
|
order_id: "ORD-002",
|
297
305
|
priority: 'high',
|
298
306
|
items: ["Premium Widget", "Express Gadget"],
|
299
|
-
total_amount: 999.99
|
307
|
+
total_amount: 999.99,
|
308
|
+
from: 'order-service'
|
300
309
|
)
|
301
310
|
puts "\n2. Publishing high-priority order..."
|
302
311
|
high_priority_order.publish
|
@@ -307,7 +316,8 @@ misrouted_order = OrderMessage.new(
|
|
307
316
|
order_id: "ORD-003",
|
308
317
|
priority: 'normal',
|
309
318
|
items: ["Test Item"],
|
310
|
-
total_amount: 50.00
|
319
|
+
total_amount: 50.00,
|
320
|
+
from: 'order-service'
|
311
321
|
)
|
312
322
|
misrouted_order.to('wrong-service')
|
313
323
|
puts "\n3. Publishing order to 'wrong-service' (should not be received)..."
|
@@ -371,7 +381,8 @@ puts "\nš¤ Publishing service requests..."
|
|
371
381
|
api_request = ServiceRequest.new(
|
372
382
|
request_id: SecureRandom.uuid,
|
373
383
|
request_type: 'user_lookup',
|
374
|
-
data: { user_id: 'USER-123' }
|
384
|
+
data: { user_id: 'USER-123' },
|
385
|
+
from: 'web-frontend'
|
375
386
|
)
|
376
387
|
api_request.from('web-frontend')
|
377
388
|
api_request.to('api-service')
|
@@ -384,7 +395,8 @@ sleep(0.2) # Allow time for handlers to process
|
|
384
395
|
data_request = ServiceRequest.new(
|
385
396
|
request_id: SecureRandom.uuid,
|
386
397
|
request_type: 'query',
|
387
|
-
data: { table: 'orders', limit: 100 }
|
398
|
+
data: { table: 'orders', limit: 100 },
|
399
|
+
from: 'analytics-service'
|
388
400
|
)
|
389
401
|
data_request.from('analytics-service')
|
390
402
|
data_request.to('data-service')
|
@@ -110,7 +110,8 @@ class BotChatAgent < BaseAgent
|
|
110
110
|
user_name: chat_data['sender_name'],
|
111
111
|
command: command,
|
112
112
|
parameters: parameters,
|
113
|
-
timestamp: Time.now.iso8601
|
113
|
+
timestamp: Time.now.iso8601,
|
114
|
+
from: chat_data['sender_id']
|
114
115
|
)
|
115
116
|
|
116
117
|
bot_command.publish
|
@@ -244,7 +244,8 @@ class BaseAgent
|
|
244
244
|
message_type: message_type,
|
245
245
|
timestamp: Time.now.iso8601,
|
246
246
|
mentions: extract_mentions(content),
|
247
|
-
metadata: { agent_type: @agent_type }
|
247
|
+
metadata: { agent_type: @agent_type },
|
248
|
+
from: @agent_id
|
248
249
|
)
|
249
250
|
|
250
251
|
log_display("š¬ [#{room_id}] #{@name}: #{content}")
|
@@ -291,7 +292,8 @@ class BaseAgent
|
|
291
292
|
notification_type: notification_type,
|
292
293
|
content: content,
|
293
294
|
timestamp: Time.now.iso8601,
|
294
|
-
metadata: { triggered_by: @agent_id }
|
295
|
+
metadata: { triggered_by: @agent_id },
|
296
|
+
from: @agent_id
|
295
297
|
)
|
296
298
|
|
297
299
|
notification.publish
|
data/lib/smart_message/base.rb
CHANGED
@@ -73,10 +73,13 @@ module SmartMessage
|
|
73
73
|
@serializer = nil
|
74
74
|
@logger = nil
|
75
75
|
|
76
|
+
# Extract addressing information from props before creating header
|
77
|
+
addressing_props = props.extract!(:from, :to, :reply_to)
|
78
|
+
|
76
79
|
# instance-level over ride of class addressing
|
77
|
-
@from =
|
78
|
-
@to =
|
79
|
-
@reply_to =
|
80
|
+
@from = addressing_props[:from]
|
81
|
+
@to = addressing_props[:to]
|
82
|
+
@reply_to = addressing_props[:reply_to]
|
80
83
|
|
81
84
|
# Create header with version validation specific to this message class
|
82
85
|
header = SmartMessage::Header.new(
|
@@ -0,0 +1,243 @@
|
|
1
|
+
# lib/smart_message/circuit_breaker.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
require 'breaker_machines'
|
6
|
+
|
7
|
+
module SmartMessage
|
8
|
+
# Circuit breaker configuration and management for SmartMessage
|
9
|
+
# Provides production-grade reliability patterns using BreakerMachines gem
|
10
|
+
module CircuitBreaker
|
11
|
+
extend self
|
12
|
+
|
13
|
+
# Default circuit breaker configurations
|
14
|
+
DEFAULT_CONFIGS = {
|
15
|
+
message_processor: {
|
16
|
+
threshold: { failures: 3, within: 60 }, # 3 failures within 1 minute
|
17
|
+
reset_after: 30, # Reset after 30 seconds
|
18
|
+
storage: :memory # Use memory storage by default
|
19
|
+
},
|
20
|
+
transport_publish: {
|
21
|
+
threshold: { failures: 5, within: 30 }, # 5 failures within 30 seconds
|
22
|
+
reset_after: 15, # Reset after 15 seconds
|
23
|
+
storage: :memory
|
24
|
+
},
|
25
|
+
transport_subscribe: {
|
26
|
+
threshold: { failures: 3, within: 60 }, # 3 failures within 1 minute
|
27
|
+
reset_after: 45, # Reset after 45 seconds
|
28
|
+
storage: :memory
|
29
|
+
},
|
30
|
+
serializer: {
|
31
|
+
threshold: { failures: 5, within: 30 }, # 5 failures within 30 seconds
|
32
|
+
reset_after: 10, # Reset after 10 seconds
|
33
|
+
storage: :memory
|
34
|
+
},
|
35
|
+
dispatcher_shutdown: {
|
36
|
+
threshold: { failures: 2, within: 10 }, # 2 failures within 10 seconds
|
37
|
+
reset_after: 5, # Reset after 5 seconds
|
38
|
+
storage: :memory
|
39
|
+
}
|
40
|
+
}.freeze
|
41
|
+
|
42
|
+
# Configure circuit breakers for a class
|
43
|
+
# @param target_class [Class] The class to add circuit breakers to
|
44
|
+
# @param options [Hash] Configuration options
|
45
|
+
def configure_for(target_class, options = {})
|
46
|
+
target_class.include BreakerMachines::DSL
|
47
|
+
|
48
|
+
# Configure each circuit breaker type
|
49
|
+
DEFAULT_CONFIGS.each do |circuit_name, config|
|
50
|
+
final_config = config.merge(options[circuit_name] || {})
|
51
|
+
|
52
|
+
target_class.circuit circuit_name do
|
53
|
+
threshold failures: final_config[:threshold][:failures],
|
54
|
+
within: final_config[:threshold][:within].seconds
|
55
|
+
reset_after final_config[:reset_after].seconds
|
56
|
+
|
57
|
+
# Configure storage backend
|
58
|
+
case final_config[:storage]
|
59
|
+
when :redis
|
60
|
+
# Use Redis storage if configured
|
61
|
+
storage BreakerMachines::Storage::Redis.new(
|
62
|
+
redis: SmartMessage::Transport::RedisTransport.new.redis_pub
|
63
|
+
)
|
64
|
+
else
|
65
|
+
# Default to memory storage
|
66
|
+
storage BreakerMachines::Storage::Memory.new
|
67
|
+
end
|
68
|
+
|
69
|
+
# Default fallback that logs the failure
|
70
|
+
fallback do |exception|
|
71
|
+
{
|
72
|
+
circuit_breaker: {
|
73
|
+
circuit: circuit_name,
|
74
|
+
state: 'open',
|
75
|
+
error: exception.message,
|
76
|
+
timestamp: Time.now.iso8601,
|
77
|
+
fallback_triggered: true
|
78
|
+
}
|
79
|
+
}
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Create a specialized circuit breaker for entity-specific processing
|
86
|
+
# @param target_class [Class] The class to add the circuit to
|
87
|
+
# @param entity_id [String] The entity identifier
|
88
|
+
# @param options [Hash] Configuration options
|
89
|
+
def configure_entity_circuit(target_class, entity_id, options = {})
|
90
|
+
circuit_name = "entity_#{entity_id}".to_sym
|
91
|
+
config = DEFAULT_CONFIGS[:message_processor].merge(options)
|
92
|
+
|
93
|
+
target_class.circuit circuit_name do
|
94
|
+
threshold failures: config[:threshold][:failures],
|
95
|
+
within: config[:threshold][:within].seconds
|
96
|
+
reset_after config[:reset_after].seconds
|
97
|
+
|
98
|
+
# Configure storage
|
99
|
+
case config[:storage]
|
100
|
+
when :redis
|
101
|
+
storage BreakerMachines::Storage::Redis.new(
|
102
|
+
redis: SmartMessage::Transport::RedisTransport.new.redis_pub
|
103
|
+
)
|
104
|
+
else
|
105
|
+
storage BreakerMachines::Storage::Memory.new
|
106
|
+
end
|
107
|
+
|
108
|
+
# Entity-specific fallback
|
109
|
+
fallback do |exception|
|
110
|
+
{
|
111
|
+
circuit_breaker: {
|
112
|
+
circuit: circuit_name,
|
113
|
+
entity_id: entity_id,
|
114
|
+
state: 'open',
|
115
|
+
error: exception.message,
|
116
|
+
timestamp: Time.now.iso8601,
|
117
|
+
fallback_triggered: true
|
118
|
+
}
|
119
|
+
}
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
circuit_name
|
124
|
+
end
|
125
|
+
|
126
|
+
# Get circuit breaker statistics
|
127
|
+
# @param circuit_instance [Object] Instance with circuit breakers
|
128
|
+
# @param circuit_name [Symbol] Name of the circuit
|
129
|
+
def stats(circuit_instance, circuit_name)
|
130
|
+
breaker = circuit_instance.circuit(circuit_name)
|
131
|
+
return nil unless breaker
|
132
|
+
|
133
|
+
{
|
134
|
+
name: circuit_name,
|
135
|
+
state: breaker.state,
|
136
|
+
failure_count: breaker.failure_count,
|
137
|
+
last_failure_time: breaker.last_failure_time,
|
138
|
+
next_attempt_time: breaker.next_attempt_time
|
139
|
+
}
|
140
|
+
end
|
141
|
+
|
142
|
+
# Check if circuit breaker is available (closed or half-open)
|
143
|
+
# @param circuit_instance [Object] Instance with circuit breakers
|
144
|
+
# @param circuit_name [Symbol] Name of the circuit
|
145
|
+
def available?(circuit_instance, circuit_name)
|
146
|
+
breaker = circuit_instance.circuit(circuit_name)
|
147
|
+
return true unless breaker # No circuit breaker means always available
|
148
|
+
|
149
|
+
breaker.state != :open
|
150
|
+
end
|
151
|
+
|
152
|
+
# Manually reset a circuit breaker
|
153
|
+
# @param circuit_instance [Object] Instance with circuit breakers
|
154
|
+
# @param circuit_name [Symbol] Name of the circuit
|
155
|
+
def reset!(circuit_instance, circuit_name)
|
156
|
+
breaker = circuit_instance.circuit(circuit_name)
|
157
|
+
breaker&.reset!
|
158
|
+
end
|
159
|
+
|
160
|
+
# Configure fallback handlers for different scenarios
|
161
|
+
module Fallbacks
|
162
|
+
# Dead letter queue fallback - stores failed messages to file-based DLQ
|
163
|
+
def self.dead_letter_queue(dlq_instance = nil)
|
164
|
+
proc do |exception, *args|
|
165
|
+
# Extract message details from args if available
|
166
|
+
message_header = args[0] if args[0].is_a?(SmartMessage::Header)
|
167
|
+
message_payload = args[1] if args.length > 1
|
168
|
+
|
169
|
+
# Use provided DLQ instance or default
|
170
|
+
dlq = dlq_instance || SmartMessage::DeadLetterQueue.default
|
171
|
+
|
172
|
+
# Store failed message in dead letter queue
|
173
|
+
sent_to_dlq = false
|
174
|
+
if message_header && message_payload
|
175
|
+
begin
|
176
|
+
dlq.enqueue(message_header, message_payload,
|
177
|
+
error: exception.message,
|
178
|
+
retry_count: 0,
|
179
|
+
serializer: 'json', # Default to JSON, could be enhanced to detect actual serializer
|
180
|
+
stack_trace: exception.backtrace&.join("\n")
|
181
|
+
)
|
182
|
+
sent_to_dlq = true
|
183
|
+
rescue => dlq_error
|
184
|
+
# DLQ storage failed - log but don't raise
|
185
|
+
puts "Warning: Failed to store message in DLQ: #{dlq_error.message}" if $DEBUG
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
{
|
190
|
+
circuit_breaker: {
|
191
|
+
circuit: :transport_publish, # Default circuit name, overridden by specific configurations
|
192
|
+
state: 'open',
|
193
|
+
error: exception.message,
|
194
|
+
sent_to_dlq: sent_to_dlq,
|
195
|
+
timestamp: Time.now.iso8601
|
196
|
+
}
|
197
|
+
}
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# Retry with exponential backoff fallback
|
202
|
+
def self.retry_with_backoff(max_retries: 3, base_delay: 1)
|
203
|
+
proc do |exception, *args|
|
204
|
+
retry_count = Thread.current[:circuit_retry_count] ||= 0
|
205
|
+
|
206
|
+
if retry_count < max_retries
|
207
|
+
Thread.current[:circuit_retry_count] += 1
|
208
|
+
delay = base_delay * (2 ** retry_count)
|
209
|
+
sleep(delay)
|
210
|
+
|
211
|
+
# Re-raise to trigger retry
|
212
|
+
raise exception
|
213
|
+
else
|
214
|
+
Thread.current[:circuit_retry_count] = nil
|
215
|
+
|
216
|
+
{
|
217
|
+
circuit_breaker: {
|
218
|
+
state: 'open',
|
219
|
+
error: exception.message,
|
220
|
+
max_retries_exceeded: true,
|
221
|
+
timestamp: Time.now.iso8601
|
222
|
+
}
|
223
|
+
}
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# Graceful degradation fallback
|
229
|
+
def self.graceful_degradation(degraded_response)
|
230
|
+
proc do |exception|
|
231
|
+
{
|
232
|
+
circuit_breaker: {
|
233
|
+
state: 'open',
|
234
|
+
error: exception.message,
|
235
|
+
degraded_response: degraded_response,
|
236
|
+
timestamp: Time.now.iso8601
|
237
|
+
}
|
238
|
+
}
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|