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.
@@ -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 Using a proc
388
- # handler = proc { |header, payload| puts "Processing..." }
389
- # MyMessage.subscribe(handler)
390
- def subscribe(process_method = nil, &block)
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