smart_message 0.0.7 → 0.0.9
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/.gitignore +1 -0
- data/.irbrc +24 -0
- data/CHANGELOG.md +143 -0
- data/Gemfile.lock +6 -1
- data/README.md +289 -15
- data/docs/README.md +3 -1
- data/docs/addressing.md +119 -13
- data/docs/architecture.md +68 -0
- data/docs/dead_letter_queue.md +673 -0
- data/docs/dispatcher.md +87 -0
- data/docs/examples.md +59 -1
- data/docs/getting-started.md +8 -1
- data/docs/logging.md +382 -326
- data/docs/message_filtering.md +451 -0
- data/examples/01_point_to_point_orders.rb +54 -53
- data/examples/02_publish_subscribe_events.rb +14 -10
- data/examples/03_many_to_many_chat.rb +16 -8
- data/examples/04_redis_smart_home_iot.rb +20 -10
- data/examples/05_proc_handlers.rb +12 -11
- data/examples/06_custom_logger_example.rb +95 -100
- data/examples/07_error_handling_scenarios.rb +4 -2
- data/examples/08_entity_addressing_basic.rb +18 -6
- data/examples/08_entity_addressing_with_filtering.rb +27 -9
- data/examples/09_dead_letter_queue_demo.rb +559 -0
- data/examples/09_regex_filtering_microservices.rb +407 -0
- data/examples/10_header_block_configuration.rb +263 -0
- data/examples/11_global_configuration_example.rb +219 -0
- data/examples/README.md +102 -0
- data/examples/dead_letters.jsonl +12 -0
- data/examples/performance_metrics/benchmark_results_ractor_20250818_205603.json +135 -0
- data/examples/performance_metrics/benchmark_results_ractor_20250818_205831.json +135 -0
- data/examples/performance_metrics/benchmark_results_test_20250818_204942.json +130 -0
- data/examples/performance_metrics/benchmark_results_threadpool_20250818_204942.json +130 -0
- data/examples/performance_metrics/benchmark_results_threadpool_20250818_204959.json +130 -0
- data/examples/performance_metrics/benchmark_results_threadpool_20250818_205044.json +130 -0
- data/examples/performance_metrics/benchmark_results_threadpool_20250818_205109.json +130 -0
- data/examples/performance_metrics/benchmark_results_threadpool_20250818_205252.json +130 -0
- data/examples/performance_metrics/benchmark_results_unknown_20250819_172852.json +130 -0
- data/examples/performance_metrics/compare_benchmarks.rb +519 -0
- data/examples/performance_metrics/dead_letters.jsonl +3100 -0
- data/examples/performance_metrics/performance_benchmark.rb +344 -0
- data/examples/show_logger.rb +367 -0
- data/examples/show_me.rb +145 -0
- data/examples/temp.txt +94 -0
- data/examples/tmux_chat/bot_agent.rb +4 -2
- data/examples/tmux_chat/human_agent.rb +4 -2
- data/examples/tmux_chat/room_monitor.rb +4 -2
- data/examples/tmux_chat/shared_chat_system.rb +6 -3
- data/lib/smart_message/addressing.rb +259 -0
- data/lib/smart_message/base.rb +121 -599
- data/lib/smart_message/circuit_breaker.rb +23 -6
- data/lib/smart_message/configuration.rb +199 -0
- data/lib/smart_message/dead_letter_queue.rb +361 -0
- data/lib/smart_message/dispatcher.rb +90 -49
- data/lib/smart_message/header.rb +5 -0
- data/lib/smart_message/logger/base.rb +21 -1
- data/lib/smart_message/logger/default.rb +88 -138
- data/lib/smart_message/logger/lumberjack.rb +324 -0
- data/lib/smart_message/logger/null.rb +81 -0
- data/lib/smart_message/logger.rb +17 -9
- data/lib/smart_message/messaging.rb +100 -0
- data/lib/smart_message/plugins.rb +132 -0
- data/lib/smart_message/serializer/base.rb +25 -8
- data/lib/smart_message/serializer/json.rb +5 -4
- data/lib/smart_message/subscription.rb +193 -0
- data/lib/smart_message/transport/base.rb +84 -53
- data/lib/smart_message/transport/memory_transport.rb +7 -5
- data/lib/smart_message/transport/redis_transport.rb +15 -45
- data/lib/smart_message/transport/stdout_transport.rb +18 -8
- data/lib/smart_message/transport.rb +1 -34
- data/lib/smart_message/utilities.rb +142 -0
- data/lib/smart_message/version.rb +1 -1
- data/lib/smart_message/versioning.rb +85 -0
- data/lib/smart_message/wrapper.rb.bak +132 -0
- data/lib/smart_message.rb +74 -27
- data/smart_message.gemspec +3 -0
- metadata +77 -3
- data/lib/smart_message/serializer.rb +0 -10
- data/lib/smart_message/wrapper.rb +0 -43
@@ -159,23 +159,40 @@ module SmartMessage
|
|
159
159
|
|
160
160
|
# Configure fallback handlers for different scenarios
|
161
161
|
module Fallbacks
|
162
|
-
# Dead letter queue fallback
|
163
|
-
def self.dead_letter_queue(
|
162
|
+
# Dead letter queue fallback - stores failed messages to file-based DLQ
|
163
|
+
def self.dead_letter_queue(dlq_instance = nil)
|
164
164
|
proc do |exception, *args|
|
165
165
|
# Extract message details from args if available
|
166
166
|
message_header = args[0] if args[0].is_a?(SmartMessage::Header)
|
167
167
|
message_payload = args[1] if args.length > 1
|
168
168
|
|
169
|
-
#
|
170
|
-
|
171
|
-
|
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
|
+
# Note: Logger might not be available in circuit breaker context
|
186
|
+
warn "Warning: Failed to store message in DLQ: #{dlq_error.message}"
|
187
|
+
end
|
172
188
|
end
|
173
189
|
|
174
190
|
{
|
175
191
|
circuit_breaker: {
|
192
|
+
circuit: :transport_publish, # Default circuit name, overridden by specific configurations
|
176
193
|
state: 'open',
|
177
194
|
error: exception.message,
|
178
|
-
sent_to_dlq:
|
195
|
+
sent_to_dlq: sent_to_dlq,
|
179
196
|
timestamp: Time.now.iso8601
|
180
197
|
}
|
181
198
|
}
|
@@ -0,0 +1,199 @@
|
|
1
|
+
# lib/smart_message/configuration.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
module SmartMessage
|
6
|
+
# Global configuration class for SmartMessage framework
|
7
|
+
#
|
8
|
+
# This class provides a centralized way for applications to configure
|
9
|
+
# default behavior for all SmartMessage classes. Applications can set
|
10
|
+
# global defaults for logger, transport, and serializer, which will be
|
11
|
+
# used by all message classes unless explicitly overridden.
|
12
|
+
#
|
13
|
+
# IMPORTANT: No configuration = NO LOGGING
|
14
|
+
# Applications must explicitly configure logging if they want it.
|
15
|
+
#
|
16
|
+
# Usage:
|
17
|
+
# # No configuration block = NO LOGGING (default behavior)
|
18
|
+
#
|
19
|
+
# # Use framework default logger (Lumberjack) with custom log file:
|
20
|
+
# SmartMessage.configure do |config|
|
21
|
+
# config.logger = "log/my_app.log" # String path = Lumberjack logger
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# # Use framework default logger with STDOUT/STDERR:
|
25
|
+
# SmartMessage.configure do |config|
|
26
|
+
# config.logger = STDOUT # Log to STDOUT
|
27
|
+
# config.logger = STDERR # Log to STDERR
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# # Use framework default logger with default file (log/smart_message.log):
|
31
|
+
# SmartMessage.configure do |config|
|
32
|
+
# config.logger = :default # Framework default
|
33
|
+
# end
|
34
|
+
#
|
35
|
+
# # Configure Lumberjack logger options:
|
36
|
+
# SmartMessage.configure do |config|
|
37
|
+
# config.logger = :default # Use framework default
|
38
|
+
# config.log_level = :debug # :debug, :info, :warn, :error, :fatal
|
39
|
+
# config.log_format = :json # :text or :json
|
40
|
+
# config.log_include_source = true # Include file:line source info
|
41
|
+
# config.log_structured_data = true # Include structured message data
|
42
|
+
# config.log_colorize = true # Enable colorized output (console only)
|
43
|
+
# config.log_options = { # Additional Lumberjack options
|
44
|
+
# roll_by_date: true, # Enable date-based log rolling
|
45
|
+
# date_pattern: '%Y-%m-%d', # Date pattern for rolling
|
46
|
+
# roll_by_size: true, # Enable size-based log rolling
|
47
|
+
# max_file_size: 50 * 1024 * 1024, # Max file size before rolling (50 MB)
|
48
|
+
# keep_files: 10 # Number of rolled files to keep
|
49
|
+
# }
|
50
|
+
# end
|
51
|
+
#
|
52
|
+
# # Use custom logger:
|
53
|
+
# SmartMessage.configure do |config|
|
54
|
+
# config.logger = MyApp::Logger.new # Custom logger object
|
55
|
+
# config.transport = MyApp::Transport.new
|
56
|
+
# config.serializer = MyApp::Serializer.new
|
57
|
+
# end
|
58
|
+
#
|
59
|
+
# # Explicitly disable logging:
|
60
|
+
# SmartMessage.configure do |config|
|
61
|
+
# config.logger = nil # Explicit no logging
|
62
|
+
# end
|
63
|
+
#
|
64
|
+
# # Individual message classes use these defaults automatically
|
65
|
+
# class OrderMessage < SmartMessage::Base
|
66
|
+
# property :order_id
|
67
|
+
# # No config block needed - uses global defaults
|
68
|
+
# end
|
69
|
+
#
|
70
|
+
# # Override global defaults when needed
|
71
|
+
# class SpecialMessage < SmartMessage::Base
|
72
|
+
# config do
|
73
|
+
# logger MyApp::SpecialLogger.new # Override just the logger
|
74
|
+
# # transport and serializer still use global defaults
|
75
|
+
# end
|
76
|
+
# end
|
77
|
+
class Configuration
|
78
|
+
attr_accessor :transport, :serializer, :log_level, :log_format, :log_include_source, :log_structured_data, :log_colorize, :log_options
|
79
|
+
attr_reader :logger
|
80
|
+
|
81
|
+
def initialize
|
82
|
+
@logger = nil
|
83
|
+
@transport = nil
|
84
|
+
@serializer = nil
|
85
|
+
@logger_explicitly_set_to_nil = false
|
86
|
+
@log_level = nil
|
87
|
+
@log_format = nil
|
88
|
+
@log_include_source = nil
|
89
|
+
@log_structured_data = nil
|
90
|
+
@log_colorize = nil
|
91
|
+
@log_options = {}
|
92
|
+
end
|
93
|
+
|
94
|
+
# Custom logger setter to track explicit nil assignment
|
95
|
+
def logger=(value)
|
96
|
+
@logger = value
|
97
|
+
@logger_explicitly_set_to_nil = value.nil?
|
98
|
+
end
|
99
|
+
|
100
|
+
# Reset configuration to defaults
|
101
|
+
def reset!
|
102
|
+
@logger = nil
|
103
|
+
@transport = nil
|
104
|
+
@serializer = nil
|
105
|
+
@logger_explicitly_set_to_nil = false
|
106
|
+
@log_level = nil
|
107
|
+
@log_format = nil
|
108
|
+
@log_include_source = nil
|
109
|
+
@log_structured_data = nil
|
110
|
+
@log_colorize = nil
|
111
|
+
@log_options = {}
|
112
|
+
end
|
113
|
+
|
114
|
+
# Check if logger is configured (including explicit nil for no logging)
|
115
|
+
def logger_configured?
|
116
|
+
!@logger.nil? || @logger_explicitly_set_to_nil || @logger == :default || @logger.is_a?(String) || @logger == STDOUT || @logger == STDERR
|
117
|
+
end
|
118
|
+
|
119
|
+
# Check if transport is configured
|
120
|
+
def transport_configured?
|
121
|
+
!@transport.nil?
|
122
|
+
end
|
123
|
+
|
124
|
+
# Check if serializer is configured
|
125
|
+
def serializer_configured?
|
126
|
+
!@serializer.nil?
|
127
|
+
end
|
128
|
+
|
129
|
+
# Get the configured logger or no logging
|
130
|
+
def default_logger
|
131
|
+
case @logger
|
132
|
+
when nil
|
133
|
+
# If explicitly set to nil, use null logger (no logging)
|
134
|
+
if @logger_explicitly_set_to_nil
|
135
|
+
SmartMessage::Logger::Null.new
|
136
|
+
else
|
137
|
+
# Not configured, NO LOGGING
|
138
|
+
SmartMessage::Logger::Null.new
|
139
|
+
end
|
140
|
+
when :default
|
141
|
+
# Explicitly requested framework default
|
142
|
+
framework_default_logger
|
143
|
+
when String
|
144
|
+
# String path means use Lumberjack logger with that file path
|
145
|
+
SmartMessage::Logger::Lumberjack.new(**logger_options.merge(log_file: @logger))
|
146
|
+
when STDOUT, STDERR
|
147
|
+
# STDOUT/STDERR constants mean use Lumberjack logger with that output
|
148
|
+
SmartMessage::Logger::Lumberjack.new(**logger_options.merge(log_file: @logger))
|
149
|
+
else
|
150
|
+
@logger
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Get the configured transport or framework default
|
155
|
+
def default_transport
|
156
|
+
@transport || framework_default_transport
|
157
|
+
end
|
158
|
+
|
159
|
+
# Get the configured serializer or framework default
|
160
|
+
def default_serializer
|
161
|
+
@serializer || framework_default_serializer
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
|
166
|
+
# Framework's built-in default logger (Lumberjack)
|
167
|
+
def framework_default_logger
|
168
|
+
SmartMessage::Logger::Lumberjack.new(**logger_options)
|
169
|
+
end
|
170
|
+
|
171
|
+
# Build logger options from configuration
|
172
|
+
def logger_options
|
173
|
+
options = {}
|
174
|
+
options[:level] = @log_level if @log_level
|
175
|
+
options[:format] = @log_format if @log_format
|
176
|
+
options[:include_source] = @log_include_source unless @log_include_source.nil?
|
177
|
+
options[:structured_data] = @log_structured_data unless @log_structured_data.nil?
|
178
|
+
options[:colorize] = @log_colorize unless @log_colorize.nil?
|
179
|
+
|
180
|
+
# Merge in log_options (for roll_by_date, roll_by_size, max_file_size, etc.)
|
181
|
+
options.merge!(@log_options) if @log_options && @log_options.is_a?(Hash)
|
182
|
+
|
183
|
+
options
|
184
|
+
end
|
185
|
+
|
186
|
+
# Framework's built-in default transport (Redis)
|
187
|
+
def framework_default_transport
|
188
|
+
SmartMessage::Transport::RedisTransport.new
|
189
|
+
end
|
190
|
+
|
191
|
+
# Framework's built-in default serializer (JSON)
|
192
|
+
def framework_default_serializer
|
193
|
+
SmartMessage::Serializer::Json.new
|
194
|
+
rescue => e
|
195
|
+
# Fallback if JSON serializer is not available
|
196
|
+
nil
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
@@ -0,0 +1,361 @@
|
|
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
|
+
|
30
|
+
logger.debug { "[SmartMessage::DeadLetterQueue] Initialized with file path: #{@file_path}" }
|
31
|
+
rescue => e
|
32
|
+
logger&.error { "[SmartMessage] Error in dead letter queue initialization: #{e.class.name} - #{e.message}" }
|
33
|
+
raise
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def logger
|
39
|
+
@logger ||= SmartMessage::Logger.default
|
40
|
+
end
|
41
|
+
|
42
|
+
public
|
43
|
+
|
44
|
+
# Core FIFO queue operations
|
45
|
+
|
46
|
+
# Add a failed message to the dead letter queue
|
47
|
+
# @param message [SmartMessage::Base] The message instance
|
48
|
+
# @param error_info [Hash] Error details including :error, :retry_count, :transport, etc.
|
49
|
+
def enqueue(message, error_info = {})
|
50
|
+
message_header = message._sm_header
|
51
|
+
message_payload = message.encode
|
52
|
+
|
53
|
+
entry = {
|
54
|
+
timestamp: Time.now.iso8601,
|
55
|
+
header: message_header.to_hash,
|
56
|
+
payload: message_payload,
|
57
|
+
payload_format: error_info[:serializer] || 'json',
|
58
|
+
error: error_info[:error] || 'Unknown error',
|
59
|
+
retry_count: error_info[:retry_count] || 0,
|
60
|
+
transport: error_info[:transport],
|
61
|
+
stack_trace: error_info[:stack_trace]
|
62
|
+
}
|
63
|
+
|
64
|
+
@mutex.synchronize do
|
65
|
+
File.open(@file_path, 'a') do |file|
|
66
|
+
file.puts entry.to_json
|
67
|
+
file.fsync # Ensure immediate write to disk
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
entry
|
72
|
+
end
|
73
|
+
|
74
|
+
# Remove and return the oldest message from the queue
|
75
|
+
# @return [Hash, nil] The oldest DLQ entry or nil if queue is empty
|
76
|
+
def dequeue
|
77
|
+
@mutex.synchronize do
|
78
|
+
return nil unless File.exist?(@file_path)
|
79
|
+
|
80
|
+
lines = File.readlines(@file_path)
|
81
|
+
return nil if lines.empty?
|
82
|
+
|
83
|
+
# Get first line (oldest)
|
84
|
+
oldest_line = lines.shift
|
85
|
+
oldest_entry = JSON.parse(oldest_line.strip, symbolize_names: true)
|
86
|
+
|
87
|
+
# Rewrite file without the first line
|
88
|
+
File.open(@file_path, 'w') do |file|
|
89
|
+
lines.each { |line| file.write(line) }
|
90
|
+
end
|
91
|
+
|
92
|
+
oldest_entry
|
93
|
+
end
|
94
|
+
rescue JSON::ParserError => e
|
95
|
+
logger.warn { "[SmartMessage] Warning: Corrupted DLQ entry skipped: #{e.message}" }
|
96
|
+
nil
|
97
|
+
end
|
98
|
+
|
99
|
+
# Look at the oldest message without removing it
|
100
|
+
# @return [Hash, nil] The oldest DLQ entry or nil if queue is empty
|
101
|
+
def peek
|
102
|
+
return nil unless File.exist?(@file_path)
|
103
|
+
|
104
|
+
File.open(@file_path, 'r') do |file|
|
105
|
+
first_line = file.readline
|
106
|
+
return nil if first_line.nil? || first_line.strip.empty?
|
107
|
+
JSON.parse(first_line.strip, symbolize_names: true)
|
108
|
+
end
|
109
|
+
rescue EOFError, JSON::ParserError
|
110
|
+
nil
|
111
|
+
end
|
112
|
+
|
113
|
+
# Get the number of messages in the queue
|
114
|
+
# @return [Integer] Number of messages in the DLQ
|
115
|
+
def size
|
116
|
+
return 0 unless File.exist?(@file_path)
|
117
|
+
File.readlines(@file_path).size
|
118
|
+
end
|
119
|
+
|
120
|
+
# Clear all messages from the queue
|
121
|
+
def clear
|
122
|
+
@mutex.synchronize do
|
123
|
+
File.delete(@file_path) if File.exist?(@file_path)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Replay capabilities
|
128
|
+
|
129
|
+
# Replay all messages in the queue
|
130
|
+
# @param transport [SmartMessage::Transport::Base] Optional transport override
|
131
|
+
# @return [Hash] Results summary
|
132
|
+
def replay_all(transport = nil)
|
133
|
+
results = { success: 0, failed: 0, errors: [] }
|
134
|
+
|
135
|
+
while (entry = dequeue)
|
136
|
+
result = replay_entry(entry, transport)
|
137
|
+
if result[:success]
|
138
|
+
results[:success] += 1
|
139
|
+
else
|
140
|
+
results[:failed] += 1
|
141
|
+
results[:errors] << result[:error]
|
142
|
+
# Re-enqueue failed replay attempts
|
143
|
+
header = SmartMessage::Header.new(entry[:header])
|
144
|
+
enqueue(header, entry[:payload],
|
145
|
+
error: "Replay failed: #{result[:error]}",
|
146
|
+
retry_count: (entry[:retry_count] || 0) + 1)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
results
|
151
|
+
end
|
152
|
+
|
153
|
+
# Replay the oldest message
|
154
|
+
# @param transport [SmartMessage::Transport::Base] Optional transport override
|
155
|
+
# @return [Hash] Result of replay attempt
|
156
|
+
def replay_one(transport = nil)
|
157
|
+
entry = dequeue
|
158
|
+
return { success: false, error: 'Queue is empty' } unless entry
|
159
|
+
|
160
|
+
replay_entry(entry, transport)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Replay a batch of messages
|
164
|
+
# @param count [Integer] Number of messages to replay
|
165
|
+
# @param transport [SmartMessage::Transport::Base] Optional transport override
|
166
|
+
# @return [Hash] Results summary
|
167
|
+
def replay_batch(count = 10, transport = nil)
|
168
|
+
results = { success: 0, failed: 0, errors: [] }
|
169
|
+
|
170
|
+
count.times do
|
171
|
+
break if size == 0
|
172
|
+
|
173
|
+
result = replay_one(transport)
|
174
|
+
if result[:success]
|
175
|
+
results[:success] += 1
|
176
|
+
else
|
177
|
+
results[:failed] += 1
|
178
|
+
results[:errors] << result[:error]
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
results
|
183
|
+
end
|
184
|
+
|
185
|
+
# Administrative utilities
|
186
|
+
|
187
|
+
# Inspect messages in the queue without removing them
|
188
|
+
# @param limit [Integer] Maximum number of messages to show
|
189
|
+
# @return [Array<Hash>] Array of DLQ entries
|
190
|
+
def inspect_messages(limit: 10)
|
191
|
+
count = 0
|
192
|
+
read_entries_with_filter do |entry|
|
193
|
+
return [] if count >= limit
|
194
|
+
count += 1
|
195
|
+
entry
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
# Filter messages by message class
|
200
|
+
# @param message_class [String] The message class name to filter by
|
201
|
+
# @return [Array<Hash>] Filtered DLQ entries
|
202
|
+
def filter_by_class(message_class)
|
203
|
+
read_entries_with_filter do |entry|
|
204
|
+
entry if entry.dig(:header, :message_class) == message_class
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Filter messages by error pattern
|
209
|
+
# @param pattern [Regexp, String] Pattern to match against error messages
|
210
|
+
# @return [Array<Hash>] Filtered DLQ entries
|
211
|
+
def filter_by_error_pattern(pattern)
|
212
|
+
pattern = Regexp.new(pattern) if pattern.is_a?(String)
|
213
|
+
|
214
|
+
read_entries_with_filter do |entry|
|
215
|
+
entry if pattern.match?(entry[:error].to_s)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Get statistics about the dead letter queue
|
220
|
+
# @return [Hash] Statistics summary
|
221
|
+
def statistics
|
222
|
+
stats = { total: 0, by_class: Hash.new(0), by_error: Hash.new(0) }
|
223
|
+
|
224
|
+
read_entries_with_filter do |entry|
|
225
|
+
stats[:total] += 1
|
226
|
+
|
227
|
+
full_class_name = entry.dig(:header, :message_class) || 'Unknown'
|
228
|
+
# Extract short class name (everything after the last ::)
|
229
|
+
short_class_name = full_class_name.split('::').last || full_class_name
|
230
|
+
stats[:by_class][short_class_name] += 1
|
231
|
+
|
232
|
+
error = entry[:error] || 'Unknown error'
|
233
|
+
stats[:by_error][error] += 1
|
234
|
+
|
235
|
+
nil # Don't collect entries, just process for side effects
|
236
|
+
end
|
237
|
+
|
238
|
+
stats
|
239
|
+
end
|
240
|
+
|
241
|
+
# Export messages within a time range
|
242
|
+
# @param start_time [Time] Start of time range
|
243
|
+
# @param end_time [Time] End of time range
|
244
|
+
# @return [Array<Hash>] DLQ entries within the time range
|
245
|
+
def export_range(start_time, end_time)
|
246
|
+
read_entries_with_filter do |entry|
|
247
|
+
begin
|
248
|
+
timestamp = Time.parse(entry[:timestamp])
|
249
|
+
entry if timestamp >= start_time && timestamp <= end_time
|
250
|
+
rescue ArgumentError
|
251
|
+
# Skip entries with invalid timestamps
|
252
|
+
nil
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
private
|
258
|
+
|
259
|
+
# Replay a single DLQ entry by recreating the message instance
|
260
|
+
# @param entry [Hash] The DLQ entry to replay
|
261
|
+
# @param transport_override [SmartMessage::Transport::Base] Optional transport override
|
262
|
+
# @return [Hash] Result of replay attempt
|
263
|
+
def replay_entry(entry, transport_override = nil)
|
264
|
+
message_class_name = entry.dig(:header, :message_class)
|
265
|
+
return { success: false, error: 'Missing message class' } unless message_class_name
|
266
|
+
|
267
|
+
# Get the message class
|
268
|
+
message_class = message_class_name.constantize
|
269
|
+
|
270
|
+
# Deserialize the payload using the appropriate format
|
271
|
+
payload_data = deserialize_payload(entry[:payload], entry[:payload_format] || 'json')
|
272
|
+
return { success: false, error: 'Failed to deserialize payload' } unless payload_data
|
273
|
+
|
274
|
+
# Remove the header from payload data (it's stored separately in DLQ)
|
275
|
+
payload_data.delete(:_sm_header)
|
276
|
+
|
277
|
+
# Create new message instance with original data
|
278
|
+
message = message_class.new(**payload_data)
|
279
|
+
|
280
|
+
# Restore complete header information
|
281
|
+
restore_header_fields(message, entry[:header])
|
282
|
+
|
283
|
+
# Override transport if provided - this must be done before publishing
|
284
|
+
if transport_override
|
285
|
+
message.transport(transport_override)
|
286
|
+
end
|
287
|
+
|
288
|
+
# Attempt to publish the message
|
289
|
+
message.publish
|
290
|
+
|
291
|
+
{ success: true, message: message }
|
292
|
+
rescue => e
|
293
|
+
{ success: false, error: "#{e.class.name}: #{e.message}" }
|
294
|
+
end
|
295
|
+
|
296
|
+
# Deserialize payload based on format
|
297
|
+
# @param payload [String] The serialized payload
|
298
|
+
# @param format [String] The serialization format (json, etc.)
|
299
|
+
# @return [Hash, nil] Deserialized data or nil if failed
|
300
|
+
def deserialize_payload(payload, format)
|
301
|
+
case format.to_s.downcase
|
302
|
+
when 'json'
|
303
|
+
JSON.parse(payload, symbolize_names: true)
|
304
|
+
else
|
305
|
+
# For unknown formats, assume JSON as fallback but log warning
|
306
|
+
logger.warn { "[SmartMessage] Warning: Unknown payload format '#{format}', attempting JSON" }
|
307
|
+
JSON.parse(payload, symbolize_names: true)
|
308
|
+
end
|
309
|
+
rescue JSON::ParserError => e
|
310
|
+
logger.error { "[SmartMessage] Error in payload deserialization: #{e.class.name} - #{e.message}" }
|
311
|
+
nil
|
312
|
+
end
|
313
|
+
|
314
|
+
# Restore all header fields from DLQ entry
|
315
|
+
# @param message [SmartMessage::Base] The message instance
|
316
|
+
# @param header_data [Hash] The stored header data
|
317
|
+
def restore_header_fields(message, header_data)
|
318
|
+
return unless header_data
|
319
|
+
|
320
|
+
# Restore all available header fields
|
321
|
+
message._sm_header.uuid = header_data[:uuid] if header_data[:uuid]
|
322
|
+
message._sm_header.message_class = header_data[:message_class] if header_data[:message_class]
|
323
|
+
message._sm_header.published_at = Time.parse(header_data[:published_at]) if header_data[:published_at]
|
324
|
+
message._sm_header.publisher_pid = header_data[:publisher_pid] if header_data[:publisher_pid]
|
325
|
+
message._sm_header.version = header_data[:version] if header_data[:version]
|
326
|
+
message._sm_header.from = header_data[:from] if header_data[:from]
|
327
|
+
message._sm_header.to = header_data[:to] if header_data[:to]
|
328
|
+
message._sm_header.reply_to = header_data[:reply_to] if header_data[:reply_to]
|
329
|
+
rescue => e
|
330
|
+
logger.warn { "[SmartMessage] Warning: Failed to restore some header fields: #{e.message}" }
|
331
|
+
end
|
332
|
+
|
333
|
+
# Generic file reading iterator with error handling
|
334
|
+
# @param block [Proc] Block to execute for each valid entry
|
335
|
+
# @return [Array] Results from the block
|
336
|
+
def read_entries_with_filter(&block)
|
337
|
+
return [] unless File.exist?(@file_path)
|
338
|
+
|
339
|
+
results = []
|
340
|
+
File.open(@file_path, 'r') do |file|
|
341
|
+
file.each_line do |line|
|
342
|
+
begin
|
343
|
+
entry = JSON.parse(line.strip, symbolize_names: true)
|
344
|
+
result = block.call(entry)
|
345
|
+
results << result if result
|
346
|
+
rescue JSON::ParserError
|
347
|
+
# Skip corrupted lines
|
348
|
+
end
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
results
|
353
|
+
end
|
354
|
+
|
355
|
+
# Ensure the directory for the DLQ file exists
|
356
|
+
def ensure_directory_exists
|
357
|
+
directory = File.dirname(@file_path)
|
358
|
+
FileUtils.mkdir_p(directory) unless Dir.exist?(directory)
|
359
|
+
end
|
360
|
+
end
|
361
|
+
end
|