smart_message 0.0.5 → 0.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 +4 -4
- data/CHANGELOG.md +122 -0
- data/Gemfile.lock +9 -1
- data/README.md +58 -4
- data/docs/README.md +1 -0
- data/docs/addressing.md +364 -0
- data/docs/getting-started.md +38 -0
- 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 +373 -0
- data/examples/08_entity_addressing_with_filtering.rb +430 -0
- data/examples/README.md +68 -0
- 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 +204 -12
- data/lib/smart_message/circuit_breaker.rb +227 -0
- data/lib/smart_message/dispatcher.rb +189 -20
- data/lib/smart_message/header.rb +14 -0
- 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 +141 -4
- data/lib/smart_message/transport/memory_transport.rb +1 -1
- data/lib/smart_message/transport/redis_transport.rb +45 -15
- data/lib/smart_message/transport/stdout_transport.rb +1 -1
- data/lib/smart_message/version.rb +1 -1
- data/lib/smart_message.rb +1 -0
- data/smart_message.gemspec +1 -0
- metadata +19 -1
data/lib/smart_message/base.rb
CHANGED
@@ -18,6 +18,9 @@ module SmartMessage
|
|
18
18
|
@@serializer = nil
|
19
19
|
@@logger = nil
|
20
20
|
|
21
|
+
# Class-level addressing configuration - use a registry for per-class isolation
|
22
|
+
@@addressing_registry = {}
|
23
|
+
|
21
24
|
# Registry for proc-based message handlers
|
22
25
|
@@proc_handlers = {}
|
23
26
|
|
@@ -69,6 +72,14 @@ module SmartMessage
|
|
69
72
|
@transport = nil
|
70
73
|
@serializer = nil
|
71
74
|
@logger = nil
|
75
|
+
|
76
|
+
# Extract addressing information from props before creating header
|
77
|
+
addressing_props = props.extract!(:from, :to, :reply_to)
|
78
|
+
|
79
|
+
# instance-level over ride of class addressing
|
80
|
+
@from = addressing_props[:from]
|
81
|
+
@to = addressing_props[:to]
|
82
|
+
@reply_to = addressing_props[:reply_to]
|
72
83
|
|
73
84
|
# Create header with version validation specific to this message class
|
74
85
|
header = SmartMessage::Header.new(
|
@@ -76,7 +87,10 @@ module SmartMessage
|
|
76
87
|
message_class: self.class.to_s,
|
77
88
|
published_at: Time.now,
|
78
89
|
publisher_pid: Process.pid,
|
79
|
-
version: self.class.version
|
90
|
+
version: self.class.version,
|
91
|
+
from: from,
|
92
|
+
to: to,
|
93
|
+
reply_to: reply_to
|
80
94
|
)
|
81
95
|
|
82
96
|
# Set up version validation to match the expected class version
|
@@ -234,6 +248,61 @@ module SmartMessage
|
|
234
248
|
def reset_serializer; @serializer = nil; end
|
235
249
|
|
236
250
|
|
251
|
+
#########################################################
|
252
|
+
## instance-level addressing configuration
|
253
|
+
|
254
|
+
def from(entity_id = nil)
|
255
|
+
if entity_id.nil?
|
256
|
+
@from || self.class.from
|
257
|
+
else
|
258
|
+
@from = entity_id
|
259
|
+
# Update the header with the new value
|
260
|
+
_sm_header.from = entity_id if _sm_header
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
def from_configured?; !from.nil?; end
|
265
|
+
def from_missing?; from.nil?; end
|
266
|
+
def reset_from;
|
267
|
+
@from = nil
|
268
|
+
_sm_header.from = nil if _sm_header
|
269
|
+
end
|
270
|
+
|
271
|
+
def to(entity_id = nil)
|
272
|
+
if entity_id.nil?
|
273
|
+
@to || self.class.to
|
274
|
+
else
|
275
|
+
@to = entity_id
|
276
|
+
# Update the header with the new value
|
277
|
+
_sm_header.to = entity_id if _sm_header
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
def to_configured?; !to.nil?; end
|
282
|
+
def to_missing?; to.nil?; end
|
283
|
+
def reset_to;
|
284
|
+
@to = nil
|
285
|
+
_sm_header.to = nil if _sm_header
|
286
|
+
end
|
287
|
+
|
288
|
+
def reply_to(entity_id = nil)
|
289
|
+
if entity_id.nil?
|
290
|
+
@reply_to || self.class.reply_to
|
291
|
+
else
|
292
|
+
@reply_to = entity_id
|
293
|
+
# Update the header with the new value
|
294
|
+
_sm_header.reply_to = entity_id if _sm_header
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def reply_to_configured?; !reply_to.nil?; end
|
299
|
+
def reply_to_missing?; reply_to.nil?; end
|
300
|
+
def reset_reply_to;
|
301
|
+
@reply_to = nil
|
302
|
+
_sm_header.reply_to = nil if _sm_header
|
303
|
+
end
|
304
|
+
|
305
|
+
|
237
306
|
#########################################################
|
238
307
|
## instance-level utility methods
|
239
308
|
|
@@ -358,6 +427,113 @@ module SmartMessage
|
|
358
427
|
def reset_serializer; @@serializer = nil; end
|
359
428
|
|
360
429
|
|
430
|
+
#########################################################
|
431
|
+
## class-level addressing configuration
|
432
|
+
|
433
|
+
# Helper method to normalize filter values (string -> array, nil -> nil)
|
434
|
+
private def normalize_filter_value(value)
|
435
|
+
case value
|
436
|
+
when nil
|
437
|
+
nil
|
438
|
+
when String
|
439
|
+
[value]
|
440
|
+
when Array
|
441
|
+
value
|
442
|
+
else
|
443
|
+
raise ArgumentError, "Filter value must be a String, Array, or nil, got: #{value.class}"
|
444
|
+
end
|
445
|
+
end
|
446
|
+
|
447
|
+
# Helper method to find addressing values in the inheritance chain
|
448
|
+
private def find_addressing_value(field)
|
449
|
+
# Start with current class
|
450
|
+
current_class = self
|
451
|
+
|
452
|
+
while current_class && current_class.respond_to?(:name)
|
453
|
+
class_name = current_class.name || current_class.to_s
|
454
|
+
|
455
|
+
# Check registry for this class
|
456
|
+
result = @@addressing_registry.dig(class_name, field)
|
457
|
+
return result if result
|
458
|
+
|
459
|
+
# If we have a proper name but no result, also check the to_s version
|
460
|
+
if current_class.name
|
461
|
+
alternative_key = current_class.to_s
|
462
|
+
result = @@addressing_registry.dig(alternative_key, field)
|
463
|
+
return result if result
|
464
|
+
end
|
465
|
+
|
466
|
+
# Move up the inheritance chain
|
467
|
+
current_class = current_class.superclass
|
468
|
+
|
469
|
+
# Stop if we reach SmartMessage::Base or above
|
470
|
+
break if current_class == SmartMessage::Base || current_class.nil?
|
471
|
+
end
|
472
|
+
|
473
|
+
nil
|
474
|
+
end
|
475
|
+
|
476
|
+
def from(entity_id = nil)
|
477
|
+
class_name = self.name || self.to_s
|
478
|
+
if entity_id.nil?
|
479
|
+
# Try to find the value, checking inheritance chain
|
480
|
+
result = find_addressing_value(:from)
|
481
|
+
result
|
482
|
+
else
|
483
|
+
@@addressing_registry[class_name] ||= {}
|
484
|
+
@@addressing_registry[class_name][:from] = entity_id
|
485
|
+
end
|
486
|
+
end
|
487
|
+
|
488
|
+
def from_configured?; !from.nil?; end
|
489
|
+
def from_missing?; from.nil?; end
|
490
|
+
def reset_from;
|
491
|
+
class_name = self.name || self.to_s
|
492
|
+
@@addressing_registry[class_name] ||= {}
|
493
|
+
@@addressing_registry[class_name][:from] = nil
|
494
|
+
end
|
495
|
+
|
496
|
+
def to(entity_id = nil)
|
497
|
+
class_name = self.name || self.to_s
|
498
|
+
if entity_id.nil?
|
499
|
+
# Try to find the value, checking inheritance chain
|
500
|
+
result = find_addressing_value(:to)
|
501
|
+
result
|
502
|
+
else
|
503
|
+
@@addressing_registry[class_name] ||= {}
|
504
|
+
@@addressing_registry[class_name][:to] = entity_id
|
505
|
+
end
|
506
|
+
end
|
507
|
+
|
508
|
+
def to_configured?; !to.nil?; end
|
509
|
+
def to_missing?; to.nil?; end
|
510
|
+
def reset_to;
|
511
|
+
class_name = self.name || self.to_s
|
512
|
+
@@addressing_registry[class_name] ||= {}
|
513
|
+
@@addressing_registry[class_name][:to] = nil
|
514
|
+
end
|
515
|
+
|
516
|
+
def reply_to(entity_id = nil)
|
517
|
+
class_name = self.name || self.to_s
|
518
|
+
if entity_id.nil?
|
519
|
+
# Try to find the value, checking inheritance chain
|
520
|
+
result = find_addressing_value(:reply_to)
|
521
|
+
result
|
522
|
+
else
|
523
|
+
@@addressing_registry[class_name] ||= {}
|
524
|
+
@@addressing_registry[class_name][:reply_to] = entity_id
|
525
|
+
end
|
526
|
+
end
|
527
|
+
|
528
|
+
def reply_to_configured?; !reply_to.nil?; end
|
529
|
+
def reply_to_missing?; reply_to.nil?; end
|
530
|
+
def reset_reply_to;
|
531
|
+
class_name = self.name || self.to_s
|
532
|
+
@@addressing_registry[class_name] ||= {}
|
533
|
+
@@addressing_registry[class_name][:reply_to] = nil
|
534
|
+
end
|
535
|
+
|
536
|
+
|
361
537
|
#########################################################
|
362
538
|
## class-level subscription management via the transport
|
363
539
|
|
@@ -369,25 +545,30 @@ module SmartMessage
|
|
369
545
|
# - String: Method name like "MyService.handle_message"
|
370
546
|
# - Proc: A proc/lambda that accepts (message_header, message_payload)
|
371
547
|
# - nil: Uses default "MessageClass.process" method
|
548
|
+
# @param broadcast [Boolean, nil] Filter for broadcast messages (to: nil)
|
549
|
+
# @param to [String, Array, nil] Filter for messages directed to specific entities
|
550
|
+
# @param from [String, Array, nil] Filter for messages from specific entities
|
372
551
|
# @param block [Proc] Alternative way to pass a processing block
|
373
552
|
# @return [String] The identifier used for this subscription
|
374
553
|
#
|
375
|
-
# @example Using default handler
|
554
|
+
# @example Using default handler (all messages)
|
376
555
|
# MyMessage.subscribe
|
377
556
|
#
|
378
|
-
# @example Using custom method name
|
379
|
-
# MyMessage.subscribe("MyService.handle_message")
|
557
|
+
# @example Using custom method name with filtering
|
558
|
+
# MyMessage.subscribe("MyService.handle_message", to: 'my-service')
|
380
559
|
#
|
381
|
-
# @example Using a block
|
382
|
-
# MyMessage.subscribe do |header, payload|
|
560
|
+
# @example Using a block with broadcast filtering
|
561
|
+
# MyMessage.subscribe(broadcast: true) do |header, payload|
|
383
562
|
# data = JSON.parse(payload)
|
384
|
-
# puts "Received: #{data}"
|
563
|
+
# puts "Received broadcast: #{data}"
|
385
564
|
# end
|
386
565
|
#
|
387
|
-
# @example
|
388
|
-
#
|
389
|
-
#
|
390
|
-
|
566
|
+
# @example Entity-specific filtering
|
567
|
+
# MyMessage.subscribe(to: 'order-service', from: ['payment', 'user'])
|
568
|
+
#
|
569
|
+
# @example Broadcast + directed messages
|
570
|
+
# MyMessage.subscribe(to: 'my-service', broadcast: true)
|
571
|
+
def subscribe(process_method = nil, broadcast: nil, to: nil, from: nil, &block)
|
391
572
|
message_class = whoami
|
392
573
|
|
393
574
|
# Handle different parameter types
|
@@ -405,10 +586,21 @@ module SmartMessage
|
|
405
586
|
end
|
406
587
|
# If process_method is a String, use it as-is
|
407
588
|
|
589
|
+
# Normalize string filters to arrays
|
590
|
+
to_filter = normalize_filter_value(to)
|
591
|
+
from_filter = normalize_filter_value(from)
|
592
|
+
|
593
|
+
# Create filter options
|
594
|
+
filter_options = {
|
595
|
+
broadcast: broadcast,
|
596
|
+
to: to_filter,
|
597
|
+
from: from_filter
|
598
|
+
}
|
599
|
+
|
408
600
|
# TODO: Add proper logging here
|
409
601
|
|
410
602
|
raise Errors::TransportNotConfigured if transport_missing?
|
411
|
-
transport.subscribe(message_class, process_method)
|
603
|
+
transport.subscribe(message_class, process_method, filter_options)
|
412
604
|
|
413
605
|
process_method
|
414
606
|
end
|
@@ -0,0 +1,227 @@
|
|
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
|
163
|
+
def self.dead_letter_queue(dlq_transport = 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
|
+
# Log to dead letter queue if transport provided
|
170
|
+
if dlq_transport && message_header && message_payload
|
171
|
+
dlq_transport.publish(message_header, message_payload)
|
172
|
+
end
|
173
|
+
|
174
|
+
{
|
175
|
+
circuit_breaker: {
|
176
|
+
state: 'open',
|
177
|
+
error: exception.message,
|
178
|
+
sent_to_dlq: !!(dlq_transport && message_header && message_payload),
|
179
|
+
timestamp: Time.now.iso8601
|
180
|
+
}
|
181
|
+
}
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# Retry with exponential backoff fallback
|
186
|
+
def self.retry_with_backoff(max_retries: 3, base_delay: 1)
|
187
|
+
proc do |exception, *args|
|
188
|
+
retry_count = Thread.current[:circuit_retry_count] ||= 0
|
189
|
+
|
190
|
+
if retry_count < max_retries
|
191
|
+
Thread.current[:circuit_retry_count] += 1
|
192
|
+
delay = base_delay * (2 ** retry_count)
|
193
|
+
sleep(delay)
|
194
|
+
|
195
|
+
# Re-raise to trigger retry
|
196
|
+
raise exception
|
197
|
+
else
|
198
|
+
Thread.current[:circuit_retry_count] = nil
|
199
|
+
|
200
|
+
{
|
201
|
+
circuit_breaker: {
|
202
|
+
state: 'open',
|
203
|
+
error: exception.message,
|
204
|
+
max_retries_exceeded: true,
|
205
|
+
timestamp: Time.now.iso8601
|
206
|
+
}
|
207
|
+
}
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
# Graceful degradation fallback
|
213
|
+
def self.graceful_degradation(degraded_response)
|
214
|
+
proc do |exception|
|
215
|
+
{
|
216
|
+
circuit_breaker: {
|
217
|
+
state: 'open',
|
218
|
+
error: exception.message,
|
219
|
+
degraded_response: degraded_response,
|
220
|
+
timestamp: Time.now.iso8601
|
221
|
+
}
|
222
|
+
}
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|