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.
@@ -0,0 +1,344 @@
1
+ # lib/smart_message/dead_letter_queue.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ require 'json'
6
+ require 'fileutils'
7
+
8
+ module SmartMessage
9
+ # File-based Dead Letter Queue implementation using JSON Lines format
10
+ # Provides FIFO queue operations with replay capabilities for failed messages
11
+ class DeadLetterQueue
12
+ attr_reader :file_path
13
+
14
+ # Default singleton instance
15
+ @@default_instance = nil
16
+
17
+ def self.default
18
+ @@default_instance ||= new
19
+ end
20
+
21
+ def self.configure_default(file_path)
22
+ @@default_instance = new(file_path)
23
+ end
24
+
25
+ def initialize(file_path = 'dead_letters.jsonl')
26
+ @file_path = File.expand_path(file_path)
27
+ @mutex = Mutex.new
28
+ ensure_directory_exists
29
+ end
30
+
31
+ # Core FIFO queue operations
32
+
33
+ # Add a failed message to the dead letter queue
34
+ # @param message_header [SmartMessage::Header] The message header
35
+ # @param message_payload [String] The serialized message payload
36
+ # @param error_info [Hash] Error details including :error, :retry_count, :transport, etc.
37
+ def enqueue(message_header, message_payload, error_info = {})
38
+ entry = {
39
+ timestamp: Time.now.iso8601,
40
+ header: message_header.to_hash,
41
+ payload: message_payload,
42
+ payload_format: error_info[:serializer] || 'json', # Track serialization format
43
+ error: error_info[:error] || 'Unknown error',
44
+ retry_count: error_info[:retry_count] || 0,
45
+ transport: error_info[:transport],
46
+ stack_trace: error_info[:stack_trace]
47
+ }
48
+
49
+ @mutex.synchronize do
50
+ File.open(@file_path, 'a') do |file|
51
+ file.puts entry.to_json
52
+ file.fsync # Ensure immediate write to disk
53
+ end
54
+ end
55
+
56
+ entry
57
+ end
58
+
59
+ # Remove and return the oldest message from the queue
60
+ # @return [Hash, nil] The oldest DLQ entry or nil if queue is empty
61
+ def dequeue
62
+ @mutex.synchronize do
63
+ return nil unless File.exist?(@file_path)
64
+
65
+ lines = File.readlines(@file_path)
66
+ return nil if lines.empty?
67
+
68
+ # Get first line (oldest)
69
+ oldest_line = lines.shift
70
+ oldest_entry = JSON.parse(oldest_line.strip, symbolize_names: true)
71
+
72
+ # Rewrite file without the first line
73
+ File.open(@file_path, 'w') do |file|
74
+ lines.each { |line| file.write(line) }
75
+ end
76
+
77
+ oldest_entry
78
+ end
79
+ rescue JSON::ParserError => e
80
+ puts "Warning: Corrupted DLQ entry skipped: #{e.message}" if $DEBUG
81
+ nil
82
+ end
83
+
84
+ # Look at the oldest message without removing it
85
+ # @return [Hash, nil] The oldest DLQ entry or nil if queue is empty
86
+ def peek
87
+ return nil unless File.exist?(@file_path)
88
+
89
+ File.open(@file_path, 'r') do |file|
90
+ first_line = file.readline
91
+ return nil if first_line.nil? || first_line.strip.empty?
92
+ JSON.parse(first_line.strip, symbolize_names: true)
93
+ end
94
+ rescue EOFError, JSON::ParserError
95
+ nil
96
+ end
97
+
98
+ # Get the number of messages in the queue
99
+ # @return [Integer] Number of messages in the DLQ
100
+ def size
101
+ return 0 unless File.exist?(@file_path)
102
+ File.readlines(@file_path).size
103
+ end
104
+
105
+ # Clear all messages from the queue
106
+ def clear
107
+ @mutex.synchronize do
108
+ File.delete(@file_path) if File.exist?(@file_path)
109
+ end
110
+ end
111
+
112
+ # Replay capabilities
113
+
114
+ # Replay all messages in the queue
115
+ # @param transport [SmartMessage::Transport::Base] Optional transport override
116
+ # @return [Hash] Results summary
117
+ def replay_all(transport = nil)
118
+ results = { success: 0, failed: 0, errors: [] }
119
+
120
+ while (entry = dequeue)
121
+ result = replay_entry(entry, transport)
122
+ if result[:success]
123
+ results[:success] += 1
124
+ else
125
+ results[:failed] += 1
126
+ results[:errors] << result[:error]
127
+ # Re-enqueue failed replay attempts
128
+ header = SmartMessage::Header.new(entry[:header])
129
+ enqueue(header, entry[:payload],
130
+ error: "Replay failed: #{result[:error]}",
131
+ retry_count: (entry[:retry_count] || 0) + 1)
132
+ end
133
+ end
134
+
135
+ results
136
+ end
137
+
138
+ # Replay the oldest message
139
+ # @param transport [SmartMessage::Transport::Base] Optional transport override
140
+ # @return [Hash] Result of replay attempt
141
+ def replay_one(transport = nil)
142
+ entry = dequeue
143
+ return { success: false, error: 'Queue is empty' } unless entry
144
+
145
+ replay_entry(entry, transport)
146
+ end
147
+
148
+ # Replay a batch of messages
149
+ # @param count [Integer] Number of messages to replay
150
+ # @param transport [SmartMessage::Transport::Base] Optional transport override
151
+ # @return [Hash] Results summary
152
+ def replay_batch(count = 10, transport = nil)
153
+ results = { success: 0, failed: 0, errors: [] }
154
+
155
+ count.times do
156
+ break if size == 0
157
+
158
+ result = replay_one(transport)
159
+ if result[:success]
160
+ results[:success] += 1
161
+ else
162
+ results[:failed] += 1
163
+ results[:errors] << result[:error]
164
+ end
165
+ end
166
+
167
+ results
168
+ end
169
+
170
+ # Administrative utilities
171
+
172
+ # Inspect messages in the queue without removing them
173
+ # @param limit [Integer] Maximum number of messages to show
174
+ # @return [Array<Hash>] Array of DLQ entries
175
+ def inspect_messages(limit: 10)
176
+ count = 0
177
+ read_entries_with_filter do |entry|
178
+ return [] if count >= limit
179
+ count += 1
180
+ entry
181
+ end
182
+ end
183
+
184
+ # Filter messages by message class
185
+ # @param message_class [String] The message class name to filter by
186
+ # @return [Array<Hash>] Filtered DLQ entries
187
+ def filter_by_class(message_class)
188
+ read_entries_with_filter do |entry|
189
+ entry if entry.dig(:header, :message_class) == message_class
190
+ end
191
+ end
192
+
193
+ # Filter messages by error pattern
194
+ # @param pattern [Regexp, String] Pattern to match against error messages
195
+ # @return [Array<Hash>] Filtered DLQ entries
196
+ def filter_by_error_pattern(pattern)
197
+ pattern = Regexp.new(pattern) if pattern.is_a?(String)
198
+
199
+ read_entries_with_filter do |entry|
200
+ entry if pattern.match?(entry[:error].to_s)
201
+ end
202
+ end
203
+
204
+ # Get statistics about the dead letter queue
205
+ # @return [Hash] Statistics summary
206
+ def statistics
207
+ stats = { total: 0, by_class: Hash.new(0), by_error: Hash.new(0) }
208
+
209
+ read_entries_with_filter do |entry|
210
+ stats[:total] += 1
211
+
212
+ message_class = entry.dig(:header, :message_class) || 'Unknown'
213
+ stats[:by_class][message_class] += 1
214
+
215
+ error = entry[:error] || 'Unknown error'
216
+ stats[:by_error][error] += 1
217
+
218
+ nil # Don't collect entries, just process for side effects
219
+ end
220
+
221
+ stats
222
+ end
223
+
224
+ # Export messages within a time range
225
+ # @param start_time [Time] Start of time range
226
+ # @param end_time [Time] End of time range
227
+ # @return [Array<Hash>] DLQ entries within the time range
228
+ def export_range(start_time, end_time)
229
+ read_entries_with_filter do |entry|
230
+ begin
231
+ timestamp = Time.parse(entry[:timestamp])
232
+ entry if timestamp >= start_time && timestamp <= end_time
233
+ rescue ArgumentError
234
+ # Skip entries with invalid timestamps
235
+ nil
236
+ end
237
+ end
238
+ end
239
+
240
+ private
241
+
242
+ # Replay a single DLQ entry by recreating the message instance
243
+ # @param entry [Hash] The DLQ entry to replay
244
+ # @param transport_override [SmartMessage::Transport::Base] Optional transport override
245
+ # @return [Hash] Result of replay attempt
246
+ def replay_entry(entry, transport_override = nil)
247
+ message_class_name = entry.dig(:header, :message_class)
248
+ return { success: false, error: 'Missing message class' } unless message_class_name
249
+
250
+ # Get the message class
251
+ message_class = message_class_name.constantize
252
+
253
+ # Deserialize the payload using the appropriate format
254
+ payload_data = deserialize_payload(entry[:payload], entry[:payload_format] || 'json')
255
+ return { success: false, error: 'Failed to deserialize payload' } unless payload_data
256
+
257
+ # Remove the header from payload data (it's stored separately in DLQ)
258
+ payload_data.delete(:_sm_header)
259
+
260
+ # Create new message instance with original data
261
+ message = message_class.new(**payload_data)
262
+
263
+ # Restore complete header information
264
+ restore_header_fields(message, entry[:header])
265
+
266
+ # Override transport if provided - this must be done before publishing
267
+ if transport_override
268
+ message.transport(transport_override)
269
+ end
270
+
271
+ # Attempt to publish the message
272
+ message.publish
273
+
274
+ { success: true, message: message }
275
+ rescue => e
276
+ { success: false, error: "#{e.class.name}: #{e.message}" }
277
+ end
278
+
279
+ # Deserialize payload based on format
280
+ # @param payload [String] The serialized payload
281
+ # @param format [String] The serialization format (json, etc.)
282
+ # @return [Hash, nil] Deserialized data or nil if failed
283
+ def deserialize_payload(payload, format)
284
+ case format.to_s.downcase
285
+ when 'json'
286
+ JSON.parse(payload, symbolize_names: true)
287
+ else
288
+ # For unknown formats, assume JSON as fallback but log warning
289
+ puts "Warning: Unknown payload format '#{format}', attempting JSON" if $DEBUG
290
+ JSON.parse(payload, symbolize_names: true)
291
+ end
292
+ rescue JSON::ParserError => e
293
+ puts "Error: Failed to deserialize payload as #{format}: #{e.message}" if $DEBUG
294
+ nil
295
+ end
296
+
297
+ # Restore all header fields from DLQ entry
298
+ # @param message [SmartMessage::Base] The message instance
299
+ # @param header_data [Hash] The stored header data
300
+ def restore_header_fields(message, header_data)
301
+ return unless header_data
302
+
303
+ # Restore all available header fields
304
+ message._sm_header.uuid = header_data[:uuid] if header_data[:uuid]
305
+ message._sm_header.message_class = header_data[:message_class] if header_data[:message_class]
306
+ message._sm_header.published_at = Time.parse(header_data[:published_at]) if header_data[:published_at]
307
+ message._sm_header.publisher_pid = header_data[:publisher_pid] if header_data[:publisher_pid]
308
+ message._sm_header.version = header_data[:version] if header_data[:version]
309
+ message._sm_header.from = header_data[:from] if header_data[:from]
310
+ message._sm_header.to = header_data[:to] if header_data[:to]
311
+ message._sm_header.reply_to = header_data[:reply_to] if header_data[:reply_to]
312
+ rescue => e
313
+ puts "Warning: Failed to restore some header fields: #{e.message}" if $DEBUG
314
+ end
315
+
316
+ # Generic file reading iterator with error handling
317
+ # @param block [Proc] Block to execute for each valid entry
318
+ # @return [Array] Results from the block
319
+ def read_entries_with_filter(&block)
320
+ return [] unless File.exist?(@file_path)
321
+
322
+ results = []
323
+ File.open(@file_path, 'r') do |file|
324
+ file.each_line do |line|
325
+ begin
326
+ entry = JSON.parse(line.strip, symbolize_names: true)
327
+ result = block.call(entry)
328
+ results << result if result
329
+ rescue JSON::ParserError
330
+ # Skip corrupted lines
331
+ end
332
+ end
333
+ end
334
+
335
+ results
336
+ end
337
+
338
+ # Ensure the directory for the DLQ file exists
339
+ def ensure_directory_exists
340
+ directory = File.dirname(@file_path)
341
+ FileUtils.mkdir_p(directory) unless Dir.exist?(directory)
342
+ end
343
+ end
344
+ end
@@ -3,26 +3,25 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require 'concurrent'
6
+ require_relative 'circuit_breaker'
6
7
 
7
8
  module SmartMessage
8
9
 
9
10
  # The disoatcher routes incoming messages to all of the methods that
10
11
  # have been subscribed to the message.
11
12
  class Dispatcher
13
+ include BreakerMachines::DSL
12
14
 
13
15
  # TODO: setup forwardable for some @router_pool methods
14
16
 
15
- def initialize
17
+ def initialize(circuit_breaker_options = {})
16
18
  @subscribers = Hash.new(Array.new)
17
19
  @router_pool = Concurrent::CachedThreadPool.new
20
+
21
+ # Configure circuit breakers
22
+ configure_circuit_breakers(circuit_breaker_options)
18
23
  at_exit do
19
- print "Shuttingdown down the dispatcher's @router_pool ..."
20
- @router_pool.shutdown
21
- while @router_pool.shuttingdown?
22
- print '.'
23
- sleep 1
24
- end
25
- puts " done."
24
+ shutdown_pool
26
25
  end
27
26
  end
28
27
 
@@ -139,7 +138,8 @@ module SmartMessage
139
138
 
140
139
  SS.add(message_klass, message_processor, 'routed' )
141
140
  @router_pool.post do
142
- begin
141
+ # Use circuit breaker to protect message processing
142
+ circuit_result = circuit(:message_processor).wrap do
143
143
  # Check if this is a proc handler or a regular method call
144
144
  if proc_handler?(message_processor)
145
145
  # Call the proc handler via SmartMessage::Base
@@ -153,17 +153,63 @@ module SmartMessage
153
153
  .method(class_method)
154
154
  .call(message_header, message_payload)
155
155
  end
156
- rescue Exception => e
157
- # TODO: Add proper exception logging
158
- # Exception details: #{e.message}
159
- # Processor: #{message_processor}
160
- puts "Error processing message: #{e.message}" if $DEBUG
156
+ end
157
+
158
+ # Handle circuit breaker fallback responses
159
+ if circuit_result.is_a?(Hash) && circuit_result[:circuit_breaker]
160
+ handle_circuit_breaker_fallback(circuit_result, message_header, message_payload, message_processor)
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ # Get circuit breaker statistics
167
+ # @return [Hash] Circuit breaker statistics
168
+ def circuit_breaker_stats
169
+ stats = {}
170
+
171
+ begin
172
+ if respond_to?(:circuit)
173
+ breaker = circuit(:message_processor)
174
+ if breaker
175
+ stats[:message_processor] = {
176
+ status: breaker.status,
177
+ closed: breaker.closed?,
178
+ open: breaker.open?,
179
+ half_open: breaker.half_open?,
180
+ last_error: breaker.last_error,
181
+ opened_at: breaker.opened_at,
182
+ stats: breaker.stats
183
+ }
161
184
  end
162
185
  end
186
+ rescue => e
187
+ stats[:error] = "Failed to get circuit breaker stats: #{e.message}"
163
188
  end
189
+
190
+ stats
164
191
  end
165
192
 
166
- private
193
+ # Reset circuit breakers
194
+ # @param circuit_name [Symbol] Optional specific circuit to reset
195
+ def reset_circuit_breakers!(circuit_name = nil)
196
+ if circuit_name
197
+ circuit(circuit_name)&.reset!
198
+ else
199
+ # Reset all known circuits
200
+ circuit(:message_processor)&.reset!
201
+ end
202
+ end
203
+
204
+ # Shutdown the router pool with timeout and fallback
205
+ def shutdown_pool
206
+ @router_pool.shutdown
207
+
208
+ # Wait for graceful shutdown, force kill if timeout
209
+ unless @router_pool.wait_for_termination(3)
210
+ @router_pool.kill
211
+ end
212
+ end
167
213
 
168
214
  # Check if a message matches the subscription filters
169
215
  # @param message_header [SmartMessage::Header] The message header
@@ -199,6 +245,81 @@ module SmartMessage
199
245
  SmartMessage::Base.proc_handler?(message_processor)
200
246
  end
201
247
 
248
+ # Configure circuit breakers for the dispatcher
249
+ # @param options [Hash] Circuit breaker configuration options
250
+ def configure_circuit_breakers(options = {})
251
+ # Ensure CircuitBreaker module is available
252
+ return unless defined?(SmartMessage::CircuitBreaker::DEFAULT_CONFIGS)
253
+
254
+ # Configure message processor circuit breaker
255
+ default_config = SmartMessage::CircuitBreaker::DEFAULT_CONFIGS[:message_processor]
256
+ return unless default_config
257
+
258
+ processor_config = default_config.merge(options[:message_processor] || {})
259
+
260
+ # Define the circuit using the class-level DSL
261
+ self.class.circuit :message_processor do
262
+ threshold failures: processor_config[:threshold][:failures],
263
+ within: processor_config[:threshold][:within].seconds
264
+ reset_after processor_config[:reset_after].seconds
265
+
266
+ # Configure storage backend
267
+ case processor_config[:storage]
268
+ when :redis
269
+ # Use Redis storage if configured and available
270
+ if defined?(SmartMessage::Transport::RedisTransport)
271
+ begin
272
+ redis_transport = SmartMessage::Transport::RedisTransport.new
273
+ storage BreakerMachines::Storage::Redis.new(redis: redis_transport.redis_pub)
274
+ rescue
275
+ # Fall back to memory storage if Redis not available
276
+ storage BreakerMachines::Storage::Memory.new
277
+ end
278
+ else
279
+ storage BreakerMachines::Storage::Memory.new
280
+ end
281
+ else
282
+ storage BreakerMachines::Storage::Memory.new
283
+ end
284
+
285
+ # Default fallback for message processing failures
286
+ fallback do |exception|
287
+ {
288
+ circuit_breaker: {
289
+ circuit: :message_processor,
290
+ state: 'open',
291
+ error: exception.message,
292
+ error_class: exception.class.name,
293
+ timestamp: Time.now.iso8601,
294
+ fallback_triggered: true
295
+ }
296
+ }
297
+ end
298
+ end
299
+
300
+ end
301
+
302
+ # Handle circuit breaker fallback responses
303
+ # @param circuit_result [Hash] The circuit breaker fallback result
304
+ # @param message_header [SmartMessage::Header] The message header
305
+ # @param message_payload [String] The message payload
306
+ # @param message_processor [String] The processor that failed
307
+ def handle_circuit_breaker_fallback(circuit_result, message_header, message_payload, message_processor)
308
+ # Log circuit breaker activation
309
+ if $DEBUG
310
+ puts "Circuit breaker activated for processor: #{message_processor}"
311
+ puts "Error: #{circuit_result[:circuit_breaker][:error]}"
312
+ puts "Message: #{message_header.message_class} from #{message_header.from}"
313
+ end
314
+
315
+ # TODO: Integrate with structured logging when implemented
316
+ # TODO: Send to dead letter queue when implemented
317
+ # TODO: Emit metrics/events for monitoring
318
+
319
+ # For now, record the failure in simple stats
320
+ SS.add(message_header.message_class, message_processor, 'circuit_breaker_fallback')
321
+ end
322
+
202
323
 
203
324
  #######################################################
204
325
  ## Class methods
@@ -2,22 +2,95 @@
2
2
  # encoding: utf-8
3
3
  # frozen_string_literal: true
4
4
 
5
+ require_relative '../circuit_breaker'
6
+
5
7
  module SmartMessage::Serializer
6
8
  # the standard super class
7
9
  class Base
10
+ include BreakerMachines::DSL
11
+
8
12
  # provide basic configuration
9
13
  def initialize
10
- # TODO: write this
14
+ configure_serializer_circuit_breakers
11
15
  end
12
16
 
13
17
  def encode(message_instance)
14
- # TODO: Add proper logging here
15
- raise ::SmartMessage::Errors::NotImplemented
18
+ circuit(:serializer).wrap do
19
+ do_encode(message_instance)
20
+ end
21
+ rescue => e
22
+ # Handle circuit breaker fallback
23
+ if e.is_a?(Hash) && e[:circuit_breaker]
24
+ handle_serializer_fallback(e, :encode, message_instance)
25
+ else
26
+ raise
27
+ end
16
28
  end
17
29
 
18
30
  def decode(payload)
19
- # TODO: Add proper logging here
31
+ circuit(:serializer).wrap do
32
+ do_decode(payload)
33
+ end
34
+ rescue => e
35
+ # Handle circuit breaker fallback
36
+ if e.is_a?(Hash) && e[:circuit_breaker]
37
+ handle_serializer_fallback(e, :decode, payload)
38
+ else
39
+ raise
40
+ end
41
+ end
42
+
43
+ # Template methods for actual serialization (implement in subclasses)
44
+ def do_encode(message_instance)
45
+ raise ::SmartMessage::Errors::NotImplemented
46
+ end
47
+
48
+ def do_decode(payload)
20
49
  raise ::SmartMessage::Errors::NotImplemented
21
50
  end
51
+
52
+ private
53
+
54
+ # Configure circuit breaker for serializer operations
55
+ def configure_serializer_circuit_breakers
56
+ serializer_config = SmartMessage::CircuitBreaker::DEFAULT_CONFIGS[:serializer]
57
+
58
+ self.class.circuit :serializer do
59
+ threshold failures: serializer_config[:threshold][:failures],
60
+ within: serializer_config[:threshold][:within].seconds
61
+ reset_after serializer_config[:reset_after].seconds
62
+
63
+ storage BreakerMachines::Storage::Memory.new
64
+
65
+ # Fallback for serializer failures
66
+ fallback do |exception|
67
+ {
68
+ circuit_breaker: {
69
+ circuit: :serializer,
70
+ serializer_type: self.class.name,
71
+ state: 'open',
72
+ error: exception.message,
73
+ error_class: exception.class.name,
74
+ timestamp: Time.now.iso8601,
75
+ fallback_triggered: true
76
+ }
77
+ }
78
+ end
79
+ end
80
+ end
81
+
82
+ # Handle serializer circuit breaker fallback
83
+ def handle_serializer_fallback(fallback_result, operation, data)
84
+ if $DEBUG
85
+ puts "Serializer circuit breaker activated: #{self.class.name}"
86
+ puts "Operation: #{operation}"
87
+ puts "Error: #{fallback_result[:circuit_breaker][:error]}"
88
+ end
89
+
90
+ # TODO: Integrate with structured logging when implemented
91
+
92
+ # Return the fallback result
93
+ fallback_result
94
+ end
22
95
  end # class Base
23
96
  end # module SmartMessage::Serializer
@@ -6,13 +6,13 @@ require 'json' # STDLIB
6
6
 
7
7
  module SmartMessage::Serializer
8
8
  class JSON < Base
9
- def encode(message_instance)
9
+ def do_encode(message_instance)
10
10
  # TODO: is this the right place to insert an automated-invisible
11
11
  # message header?
12
12
  message_instance.to_json
13
13
  end
14
14
 
15
- def decode(payload)
15
+ def do_decode(payload)
16
16
  # TODO: so how do I know to which message class this payload
17
17
  # belongs? The class needs to be in some kind of message
18
18
  # header.