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
@@ -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
|
-
|
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
|
-
|
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
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
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
|
-
|
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
|
-
|
14
|
+
configure_serializer_circuit_breakers
|
11
15
|
end
|
12
16
|
|
13
17
|
def encode(message_instance)
|
14
|
-
|
15
|
-
|
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
|
-
|
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
|
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
|
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.
|