smart_message 0.0.8 → 0.0.10
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 +119 -0
- data/Gemfile.lock +6 -1
- data/README.md +389 -17
- data/docs/README.md +3 -1
- data/docs/addressing.md +119 -13
- data/docs/architecture.md +184 -46
- 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_deduplication.md +488 -0
- 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/10_message_deduplication.rb +209 -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 +123 -599
- data/lib/smart_message/circuit_breaker.rb +2 -1
- data/lib/smart_message/configuration.rb +199 -0
- data/lib/smart_message/ddq/base.rb +71 -0
- data/lib/smart_message/ddq/memory.rb +109 -0
- data/lib/smart_message/ddq/redis.rb +168 -0
- data/lib/smart_message/ddq.rb +31 -0
- data/lib/smart_message/dead_letter_queue.rb +27 -10
- data/lib/smart_message/deduplication.rb +174 -0
- data/lib/smart_message/dispatcher.rb +259 -61
- 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 +196 -0
- data/lib/smart_message/transport/base.rb +72 -41
- 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 -28
- data/smart_message.gemspec +3 -0
- metadata +83 -3
- data/lib/smart_message/serializer.rb +0 -10
- data/lib/smart_message/wrapper.rb +0 -43
@@ -182,7 +182,8 @@ module SmartMessage
|
|
182
182
|
sent_to_dlq = true
|
183
183
|
rescue => dlq_error
|
184
184
|
# DLQ storage failed - log but don't raise
|
185
|
-
|
185
|
+
# Note: Logger might not be available in circuit breaker context
|
186
|
+
warn "Warning: Failed to store message in DLQ: #{dlq_error.message}"
|
186
187
|
end
|
187
188
|
end
|
188
189
|
|
@@ -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,71 @@
|
|
1
|
+
# lib/smart_message/ddq/base.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
module SmartMessage
|
6
|
+
module DDQ
|
7
|
+
# Base class for Deduplication Queue implementations
|
8
|
+
#
|
9
|
+
# Defines the interface that all DDQ storage backends must implement.
|
10
|
+
# Provides circular queue semantics with O(1) lookup performance.
|
11
|
+
class Base
|
12
|
+
attr_reader :size, :logger
|
13
|
+
|
14
|
+
def initialize(size)
|
15
|
+
@size = size
|
16
|
+
@logger = SmartMessage::Logger.default
|
17
|
+
validate_size!
|
18
|
+
end
|
19
|
+
|
20
|
+
# Check if a UUID exists in the queue
|
21
|
+
# @param uuid [String] The UUID to check
|
22
|
+
# @return [Boolean] true if UUID exists, false otherwise
|
23
|
+
def contains?(uuid)
|
24
|
+
raise NotImplementedError, "Subclasses must implement #contains?"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Add a UUID to the queue (removes oldest if full)
|
28
|
+
# @param uuid [String] The UUID to add
|
29
|
+
# @return [void]
|
30
|
+
def add(uuid)
|
31
|
+
raise NotImplementedError, "Subclasses must implement #add"
|
32
|
+
end
|
33
|
+
|
34
|
+
# Get current queue statistics
|
35
|
+
# @return [Hash] Statistics about the queue
|
36
|
+
def stats
|
37
|
+
{
|
38
|
+
size: @size,
|
39
|
+
storage_type: storage_type,
|
40
|
+
implementation: self.class.name
|
41
|
+
}
|
42
|
+
end
|
43
|
+
|
44
|
+
# Clear all entries from the queue
|
45
|
+
# @return [void]
|
46
|
+
def clear
|
47
|
+
raise NotImplementedError, "Subclasses must implement #clear"
|
48
|
+
end
|
49
|
+
|
50
|
+
# Get the storage type identifier
|
51
|
+
# @return [Symbol] Storage type (:memory, :redis, etc.)
|
52
|
+
def storage_type
|
53
|
+
raise NotImplementedError, "Subclasses must implement #storage_type"
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def validate_size!
|
59
|
+
unless @size.is_a?(Integer) && @size > 0
|
60
|
+
raise ArgumentError, "DDQ size must be a positive integer, got: #{@size.inspect}"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def validate_uuid!(uuid)
|
65
|
+
unless uuid.is_a?(String) && !uuid.empty?
|
66
|
+
raise ArgumentError, "UUID must be a non-empty string, got: #{uuid.inspect}"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# lib/smart_message/ddq/memory.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
require 'set'
|
6
|
+
require_relative 'base'
|
7
|
+
|
8
|
+
module SmartMessage
|
9
|
+
module DDQ
|
10
|
+
# Memory-based Deduplication Queue implementation
|
11
|
+
#
|
12
|
+
# Uses a hybrid approach with Array for circular queue behavior
|
13
|
+
# and Set for O(1) lookup performance. Thread-safe with Mutex protection.
|
14
|
+
class Memory < Base
|
15
|
+
def initialize(size)
|
16
|
+
super(size)
|
17
|
+
@circular_array = Array.new(@size)
|
18
|
+
@lookup_set = Set.new
|
19
|
+
@index = 0
|
20
|
+
@mutex = Mutex.new
|
21
|
+
@count = 0
|
22
|
+
|
23
|
+
logger.debug { "[SmartMessage::DDQ::Memory] Initialized with size: #{@size}" }
|
24
|
+
end
|
25
|
+
|
26
|
+
# Check if a UUID exists in the queue (O(1) operation)
|
27
|
+
# @param uuid [String] The UUID to check
|
28
|
+
# @return [Boolean] true if UUID exists, false otherwise
|
29
|
+
def contains?(uuid)
|
30
|
+
validate_uuid!(uuid)
|
31
|
+
|
32
|
+
@mutex.synchronize do
|
33
|
+
@lookup_set.include?(uuid)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Add a UUID to the queue, removing oldest if full (O(1) operation)
|
38
|
+
# @param uuid [String] The UUID to add
|
39
|
+
# @return [void]
|
40
|
+
def add(uuid)
|
41
|
+
validate_uuid!(uuid)
|
42
|
+
|
43
|
+
@mutex.synchronize do
|
44
|
+
# Don't add if already exists
|
45
|
+
return if @lookup_set.include?(uuid)
|
46
|
+
|
47
|
+
# Remove old entry if slot is occupied
|
48
|
+
old_uuid = @circular_array[@index]
|
49
|
+
if old_uuid
|
50
|
+
@lookup_set.delete(old_uuid)
|
51
|
+
logger.debug { "[SmartMessage::DDQ::Memory] Evicted UUID: #{old_uuid}" }
|
52
|
+
end
|
53
|
+
|
54
|
+
# Add new entry
|
55
|
+
@circular_array[@index] = uuid
|
56
|
+
@lookup_set.add(uuid)
|
57
|
+
@index = (@index + 1) % @size
|
58
|
+
@count = [@count + 1, @size].min
|
59
|
+
|
60
|
+
logger.debug { "[SmartMessage::DDQ::Memory] Added UUID: #{uuid}, count: #{@count}" }
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Get current queue statistics
|
65
|
+
# @return [Hash] Statistics about the queue
|
66
|
+
def stats
|
67
|
+
@mutex.synchronize do
|
68
|
+
super.merge(
|
69
|
+
current_count: @count,
|
70
|
+
utilization: (@count.to_f / @size * 100).round(2),
|
71
|
+
next_index: @index
|
72
|
+
)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Clear all entries from the queue
|
77
|
+
# @return [void]
|
78
|
+
def clear
|
79
|
+
@mutex.synchronize do
|
80
|
+
@circular_array = Array.new(@size)
|
81
|
+
@lookup_set.clear
|
82
|
+
@index = 0
|
83
|
+
@count = 0
|
84
|
+
|
85
|
+
logger.debug { "[SmartMessage::DDQ::Memory] Cleared all entries" }
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Get the storage type identifier
|
90
|
+
# @return [Symbol] Storage type
|
91
|
+
def storage_type
|
92
|
+
:memory
|
93
|
+
end
|
94
|
+
|
95
|
+
# Get current entries (for debugging/testing)
|
96
|
+
# @return [Array<String>] Current UUIDs in insertion order
|
97
|
+
def entries
|
98
|
+
@mutex.synchronize do
|
99
|
+
result = []
|
100
|
+
@count.times do |i|
|
101
|
+
idx = (@index - @count + i) % @size
|
102
|
+
result << @circular_array[idx] if @circular_array[idx]
|
103
|
+
end
|
104
|
+
result
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,168 @@
|
|
1
|
+
# lib/smart_message/ddq/redis.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
require_relative 'base'
|
6
|
+
|
7
|
+
module SmartMessage
|
8
|
+
module DDQ
|
9
|
+
# Redis-based Deduplication Queue implementation
|
10
|
+
#
|
11
|
+
# Uses Redis SET for O(1) lookup and LIST for circular queue behavior.
|
12
|
+
# Supports distributed deduplication across multiple processes/servers.
|
13
|
+
class Redis < Base
|
14
|
+
attr_reader :redis, :key_prefix, :ttl
|
15
|
+
|
16
|
+
def initialize(size, options = {})
|
17
|
+
super(size)
|
18
|
+
@redis = options[:redis] || default_redis_connection
|
19
|
+
@key_prefix = options[:key_prefix] || 'smart_message:ddq'
|
20
|
+
@ttl = options[:ttl] || 3600 # 1 hour default TTL
|
21
|
+
|
22
|
+
validate_redis_connection!
|
23
|
+
|
24
|
+
logger.debug { "[SmartMessage::DDQ::Redis] Initialized with size: #{@size}, TTL: #{@ttl}s" }
|
25
|
+
end
|
26
|
+
|
27
|
+
# Check if a UUID exists in the queue (O(1) operation)
|
28
|
+
# @param uuid [String] The UUID to check
|
29
|
+
# @return [Boolean] true if UUID exists, false otherwise
|
30
|
+
def contains?(uuid)
|
31
|
+
validate_uuid!(uuid)
|
32
|
+
|
33
|
+
result = @redis.sismember(set_key, uuid)
|
34
|
+
logger.debug { "[SmartMessage::DDQ::Redis] UUID #{uuid} exists: #{result}" }
|
35
|
+
result
|
36
|
+
rescue ::Redis::BaseError => e
|
37
|
+
logger.error { "[SmartMessage::DDQ::Redis] Error checking UUID #{uuid}: #{e.message}" }
|
38
|
+
# Fail open - allow processing if Redis is down
|
39
|
+
false
|
40
|
+
end
|
41
|
+
|
42
|
+
# Add a UUID to the queue, removing oldest if full (O(1) amortized)
|
43
|
+
# @param uuid [String] The UUID to add
|
44
|
+
# @return [void]
|
45
|
+
def add(uuid)
|
46
|
+
validate_uuid!(uuid)
|
47
|
+
|
48
|
+
# Check if UUID already exists first (avoid unnecessary work)
|
49
|
+
return if @redis.sismember(set_key, uuid)
|
50
|
+
|
51
|
+
# Use Redis transaction for atomicity
|
52
|
+
@redis.multi do |pipeline|
|
53
|
+
# Add to set for O(1) lookup
|
54
|
+
pipeline.sadd(set_key, uuid)
|
55
|
+
|
56
|
+
# Add to list for ordering/eviction
|
57
|
+
pipeline.lpush(list_key, uuid)
|
58
|
+
|
59
|
+
# Trim list to maintain size (removes oldest)
|
60
|
+
pipeline.ltrim(list_key, 0, @size - 1)
|
61
|
+
|
62
|
+
# Set TTL on both keys
|
63
|
+
pipeline.expire(set_key, @ttl)
|
64
|
+
pipeline.expire(list_key, @ttl)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Get and remove evicted items from set (outside transaction)
|
68
|
+
list_length = @redis.llen(list_key)
|
69
|
+
if list_length > @size
|
70
|
+
evicted_uuids = @redis.lrange(list_key, @size, -1)
|
71
|
+
evicted_uuids.each do |evicted_uuid|
|
72
|
+
@redis.srem(set_key, evicted_uuid)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
logger.debug { "[SmartMessage::DDQ::Redis] Added UUID: #{uuid}" }
|
77
|
+
rescue ::Redis::BaseError => e
|
78
|
+
logger.error { "[SmartMessage::DDQ::Redis] Error adding UUID #{uuid}: #{e.message}" }
|
79
|
+
# Don't raise - deduplication failure shouldn't break message processing
|
80
|
+
end
|
81
|
+
|
82
|
+
# Get current queue statistics
|
83
|
+
# @return [Hash] Statistics about the queue
|
84
|
+
def stats
|
85
|
+
set_size = @redis.scard(set_key)
|
86
|
+
list_size = @redis.llen(list_key)
|
87
|
+
|
88
|
+
super.merge(
|
89
|
+
current_count: set_size,
|
90
|
+
list_count: list_size,
|
91
|
+
utilization: (set_size.to_f / @size * 100).round(2),
|
92
|
+
ttl_remaining: @redis.ttl(set_key),
|
93
|
+
redis_info: {
|
94
|
+
host: redis_host,
|
95
|
+
port: redis_port,
|
96
|
+
db: redis_db
|
97
|
+
}
|
98
|
+
)
|
99
|
+
rescue ::Redis::BaseError => e
|
100
|
+
logger.error { "[SmartMessage::DDQ::Redis] Error getting stats: #{e.message}" }
|
101
|
+
super.merge(error: e.message)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Clear all entries from the queue
|
105
|
+
# @return [void]
|
106
|
+
def clear
|
107
|
+
@redis.multi do |pipeline|
|
108
|
+
pipeline.del(set_key)
|
109
|
+
pipeline.del(list_key)
|
110
|
+
end
|
111
|
+
|
112
|
+
logger.debug { "[SmartMessage::DDQ::Redis] Cleared all entries" }
|
113
|
+
rescue ::Redis::BaseError => e
|
114
|
+
logger.error { "[SmartMessage::DDQ::Redis] Error clearing queue: #{e.message}" }
|
115
|
+
end
|
116
|
+
|
117
|
+
# Get the storage type identifier
|
118
|
+
# @return [Symbol] Storage type
|
119
|
+
def storage_type
|
120
|
+
:redis
|
121
|
+
end
|
122
|
+
|
123
|
+
# Get current entries (for debugging/testing)
|
124
|
+
# @return [Array<String>] Current UUIDs in insertion order (newest first)
|
125
|
+
def entries
|
126
|
+
@redis.lrange(list_key, 0, -1)
|
127
|
+
rescue ::Redis::BaseError => e
|
128
|
+
logger.error { "[SmartMessage::DDQ::Redis] Error getting entries: #{e.message}" }
|
129
|
+
[]
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
def set_key
|
135
|
+
"#{@key_prefix}:set"
|
136
|
+
end
|
137
|
+
|
138
|
+
def list_key
|
139
|
+
"#{@key_prefix}:list"
|
140
|
+
end
|
141
|
+
|
142
|
+
def default_redis_connection
|
143
|
+
require 'redis'
|
144
|
+
::Redis.new
|
145
|
+
rescue LoadError
|
146
|
+
raise LoadError, "Redis gem not available. Install with: gem install redis"
|
147
|
+
end
|
148
|
+
|
149
|
+
def validate_redis_connection!
|
150
|
+
@redis.ping
|
151
|
+
rescue ::Redis::BaseError => e
|
152
|
+
raise ConnectionError, "Failed to connect to Redis: #{e.message}"
|
153
|
+
end
|
154
|
+
|
155
|
+
def redis_host
|
156
|
+
@redis.connection[:host] rescue 'unknown'
|
157
|
+
end
|
158
|
+
|
159
|
+
def redis_port
|
160
|
+
@redis.connection[:port] rescue 'unknown'
|
161
|
+
end
|
162
|
+
|
163
|
+
def redis_db
|
164
|
+
@redis.connection[:db] rescue 'unknown'
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# lib/smart_message/ddq.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
require_relative 'ddq/base'
|
6
|
+
require_relative 'ddq/memory'
|
7
|
+
require_relative 'ddq/redis'
|
8
|
+
|
9
|
+
module SmartMessage
|
10
|
+
# Deduplication Queue (DDQ) for preventing duplicate message processing
|
11
|
+
#
|
12
|
+
# Provides a circular queue with O(1) lookup performance for detecting
|
13
|
+
# duplicate messages based on UUID. Supports both memory and Redis storage.
|
14
|
+
module DDQ
|
15
|
+
# Default configuration
|
16
|
+
DEFAULT_SIZE = 100
|
17
|
+
DEFAULT_STORAGE = :memory
|
18
|
+
|
19
|
+
# Create a DDQ instance based on storage type
|
20
|
+
def self.create(storage_type, size = DEFAULT_SIZE, options = {})
|
21
|
+
case storage_type.to_sym
|
22
|
+
when :memory
|
23
|
+
Memory.new(size)
|
24
|
+
when :redis
|
25
|
+
Redis.new(size, options)
|
26
|
+
else
|
27
|
+
raise ArgumentError, "Unknown DDQ storage type: #{storage_type}. Supported types: :memory, :redis"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -26,20 +26,35 @@ module SmartMessage
|
|
26
26
|
@file_path = File.expand_path(file_path)
|
27
27
|
@mutex = Mutex.new
|
28
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
|
29
40
|
end
|
41
|
+
|
42
|
+
public
|
30
43
|
|
31
44
|
# Core FIFO queue operations
|
32
45
|
|
33
46
|
# Add a failed message to the dead letter queue
|
34
|
-
# @param
|
35
|
-
# @param message_payload [String] The serialized message payload
|
47
|
+
# @param message [SmartMessage::Base] The message instance
|
36
48
|
# @param error_info [Hash] Error details including :error, :retry_count, :transport, etc.
|
37
|
-
def enqueue(
|
49
|
+
def enqueue(message, error_info = {})
|
50
|
+
message_header = message._sm_header
|
51
|
+
message_payload = message.encode
|
52
|
+
|
38
53
|
entry = {
|
39
54
|
timestamp: Time.now.iso8601,
|
40
55
|
header: message_header.to_hash,
|
41
56
|
payload: message_payload,
|
42
|
-
payload_format: error_info[:serializer] || 'json',
|
57
|
+
payload_format: error_info[:serializer] || 'json',
|
43
58
|
error: error_info[:error] || 'Unknown error',
|
44
59
|
retry_count: error_info[:retry_count] || 0,
|
45
60
|
transport: error_info[:transport],
|
@@ -77,7 +92,7 @@ module SmartMessage
|
|
77
92
|
oldest_entry
|
78
93
|
end
|
79
94
|
rescue JSON::ParserError => e
|
80
|
-
|
95
|
+
logger.warn { "[SmartMessage] Warning: Corrupted DLQ entry skipped: #{e.message}" }
|
81
96
|
nil
|
82
97
|
end
|
83
98
|
|
@@ -209,8 +224,10 @@ module SmartMessage
|
|
209
224
|
read_entries_with_filter do |entry|
|
210
225
|
stats[:total] += 1
|
211
226
|
|
212
|
-
|
213
|
-
|
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
|
214
231
|
|
215
232
|
error = entry[:error] || 'Unknown error'
|
216
233
|
stats[:by_error][error] += 1
|
@@ -286,11 +303,11 @@ module SmartMessage
|
|
286
303
|
JSON.parse(payload, symbolize_names: true)
|
287
304
|
else
|
288
305
|
# For unknown formats, assume JSON as fallback but log warning
|
289
|
-
|
306
|
+
logger.warn { "[SmartMessage] Warning: Unknown payload format '#{format}', attempting JSON" }
|
290
307
|
JSON.parse(payload, symbolize_names: true)
|
291
308
|
end
|
292
309
|
rescue JSON::ParserError => e
|
293
|
-
|
310
|
+
logger.error { "[SmartMessage] Error in payload deserialization: #{e.class.name} - #{e.message}" }
|
294
311
|
nil
|
295
312
|
end
|
296
313
|
|
@@ -310,7 +327,7 @@ module SmartMessage
|
|
310
327
|
message._sm_header.to = header_data[:to] if header_data[:to]
|
311
328
|
message._sm_header.reply_to = header_data[:reply_to] if header_data[:reply_to]
|
312
329
|
rescue => e
|
313
|
-
|
330
|
+
logger.warn { "[SmartMessage] Warning: Failed to restore some header fields: #{e.message}" }
|
314
331
|
end
|
315
332
|
|
316
333
|
# Generic file reading iterator with error handling
|