smart_message 0.0.10 → 0.0.12
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/.github/workflows/deploy-github-pages.yml +38 -0
- data/.gitignore +5 -0
- data/CHANGELOG.md +30 -0
- data/Gemfile.lock +35 -4
- data/README.md +169 -71
- data/Rakefile +29 -4
- data/docs/assets/images/ddq_architecture.svg +130 -0
- data/docs/assets/images/dlq_architecture.svg +115 -0
- data/docs/assets/images/enhanced-dual-publishing.svg +136 -0
- data/docs/assets/images/enhanced-fluent-api.svg +149 -0
- data/docs/assets/images/enhanced-microservices-routing.svg +115 -0
- data/docs/assets/images/enhanced-pattern-matching.svg +107 -0
- data/docs/assets/images/fluent-api-demo.svg +59 -0
- data/docs/assets/images/performance-comparison.svg +161 -0
- data/docs/assets/images/redis-basic-architecture.svg +53 -0
- data/docs/assets/images/redis-enhanced-architecture.svg +88 -0
- data/docs/assets/images/redis-queue-architecture.svg +101 -0
- data/docs/assets/images/smart_message.jpg +0 -0
- data/docs/assets/images/smart_message_walking.jpg +0 -0
- data/docs/assets/images/smartmessage_architecture_overview.svg +173 -0
- data/docs/assets/images/transport-comparison-matrix.svg +171 -0
- data/docs/assets/javascripts/mathjax.js +17 -0
- data/docs/assets/stylesheets/extra.css +51 -0
- data/docs/{addressing.md → core-concepts/addressing.md} +5 -7
- data/docs/{architecture.md → core-concepts/architecture.md} +78 -138
- data/docs/{dispatcher.md → core-concepts/dispatcher.md} +21 -21
- data/docs/{message_filtering.md → core-concepts/message-filtering.md} +2 -3
- data/docs/{message_processing.md → core-concepts/message-processing.md} +17 -17
- data/docs/{troubleshooting.md → development/troubleshooting.md} +7 -7
- data/docs/{examples.md → getting-started/examples.md} +115 -89
- data/docs/{getting-started.md → getting-started/quick-start.md} +47 -18
- data/docs/guides/redis-queue-getting-started.md +697 -0
- data/docs/guides/redis-queue-patterns.md +889 -0
- data/docs/guides/redis-queue-production.md +1091 -0
- data/docs/index.md +64 -0
- data/docs/{dead_letter_queue.md → reference/dead-letter-queue.md} +2 -3
- data/docs/{logging.md → reference/logging.md} +1 -1
- data/docs/{message_deduplication.md → reference/message-deduplication.md} +1 -0
- data/docs/{proc_handlers_summary.md → reference/proc-handlers.md} +7 -6
- data/docs/{serializers.md → reference/serializers.md} +3 -5
- data/docs/{transports.md → reference/transports.md} +133 -11
- data/docs/transports/memory-transport.md +374 -0
- data/docs/transports/redis-enhanced-transport.md +524 -0
- data/docs/transports/redis-queue-transport.md +1304 -0
- data/docs/transports/redis-transport-comparison.md +496 -0
- data/docs/transports/redis-transport.md +509 -0
- data/examples/README.md +98 -5
- data/examples/city_scenario/911_emergency_call_flow.svg +99 -0
- data/examples/city_scenario/README.md +515 -0
- data/examples/city_scenario/ai_visitor_intelligence_flow.svg +108 -0
- data/examples/city_scenario/citizen.rb +195 -0
- data/examples/city_scenario/city_diagram.svg +125 -0
- data/examples/city_scenario/common/health_monitor.rb +80 -0
- data/examples/city_scenario/common/logger.rb +30 -0
- data/examples/city_scenario/emergency_dispatch_center.rb +270 -0
- data/examples/city_scenario/fire_department.rb +446 -0
- data/examples/city_scenario/fire_emergency_flow.svg +95 -0
- data/examples/city_scenario/health_department.rb +100 -0
- data/examples/city_scenario/health_monitoring_system.svg +130 -0
- data/examples/city_scenario/house.rb +244 -0
- data/examples/city_scenario/local_bank.rb +217 -0
- data/examples/city_scenario/messages/emergency_911_message.rb +81 -0
- data/examples/city_scenario/messages/emergency_resolved_message.rb +43 -0
- data/examples/city_scenario/messages/fire_dispatch_message.rb +43 -0
- data/examples/city_scenario/messages/fire_emergency_message.rb +45 -0
- data/examples/city_scenario/messages/health_check_message.rb +22 -0
- data/examples/city_scenario/messages/health_status_message.rb +35 -0
- data/examples/city_scenario/messages/police_dispatch_message.rb +46 -0
- data/examples/city_scenario/messages/silent_alarm_message.rb +38 -0
- data/examples/city_scenario/police_department.rb +316 -0
- data/examples/city_scenario/redis_monitor.rb +129 -0
- data/examples/city_scenario/redis_stats.rb +743 -0
- data/examples/city_scenario/room_for_improvement.md +240 -0
- data/examples/city_scenario/security_emergency_flow.svg +95 -0
- data/examples/city_scenario/service_internal_architecture.svg +154 -0
- data/examples/city_scenario/smart_message_ai_agent.rb +364 -0
- data/examples/city_scenario/start_demo.sh +236 -0
- data/examples/city_scenario/stop_demo.sh +106 -0
- data/examples/city_scenario/visitor.rb +631 -0
- data/examples/{10_message_deduplication.rb → memory/01_message_deduplication_demo.rb} +1 -1
- data/examples/{09_dead_letter_queue_demo.rb → memory/02_dead_letter_queue_demo.rb} +13 -40
- data/examples/{01_point_to_point_orders.rb → memory/03_point_to_point_orders.rb} +1 -1
- data/examples/{02_publish_subscribe_events.rb → memory/04_publish_subscribe_events.rb} +2 -2
- data/examples/{03_many_to_many_chat.rb → memory/05_many_to_many_chat.rb} +4 -4
- data/examples/{show_me.rb → memory/06_pretty_print_demo.rb} +1 -1
- data/examples/{05_proc_handlers.rb → memory/07_proc_handlers_demo.rb} +2 -2
- data/examples/{06_custom_logger_example.rb → memory/08_custom_logger_demo.rb} +17 -14
- data/examples/{07_error_handling_scenarios.rb → memory/09_error_handling_demo.rb} +4 -4
- data/examples/{08_entity_addressing_basic.rb → memory/10_entity_addressing_basic.rb} +8 -8
- data/examples/{08_entity_addressing_with_filtering.rb → memory/11_entity_addressing_with_filtering.rb} +6 -6
- data/examples/{09_regex_filtering_microservices.rb → memory/12_regex_filtering_microservices.rb} +2 -2
- data/examples/{10_header_block_configuration.rb → memory/13_header_block_configuration.rb} +6 -6
- data/examples/{11_global_configuration_example.rb → memory/14_global_configuration_demo.rb} +19 -8
- data/examples/{show_logger.rb → memory/15_logger_demo.rb} +1 -1
- data/examples/memory/README.md +163 -0
- data/examples/memory/memory_transport_architecture.svg +90 -0
- data/examples/memory/point_to_point_pattern.svg +94 -0
- data/examples/memory/publish_subscribe_pattern.svg +125 -0
- data/examples/{04_redis_smart_home_iot.rb → redis/01_smart_home_iot_demo.rb} +5 -5
- data/examples/redis/README.md +230 -0
- data/examples/redis/alert_system_flow.svg +127 -0
- data/examples/redis/dashboard_status_flow.svg +107 -0
- data/examples/redis/device_command_flow.svg +113 -0
- data/examples/redis/redis_transport_architecture.svg +115 -0
- data/examples/{smart_home_iot_dataflow.md → redis/smart_home_iot_dataflow.md} +4 -116
- data/examples/redis/smart_home_system_architecture.svg +133 -0
- data/examples/redis_enhanced/README.md +319 -0
- data/examples/redis_enhanced/enhanced_01_basic_patterns.rb +233 -0
- data/examples/redis_enhanced/enhanced_02_fluent_api.rb +331 -0
- data/examples/redis_enhanced/enhanced_03_dual_publishing.rb +281 -0
- data/examples/redis_enhanced/enhanced_04_advanced_routing.rb +419 -0
- data/examples/redis_queue/01_basic_messaging.rb +221 -0
- data/examples/redis_queue/01_comprehensive_examples.rb +508 -0
- data/examples/redis_queue/02_pattern_routing.rb +405 -0
- data/examples/redis_queue/03_fluent_api.rb +422 -0
- data/examples/redis_queue/04_load_balancing.rb +486 -0
- data/examples/redis_queue/05_microservices.rb +735 -0
- data/examples/redis_queue/06_emergency_alerts.rb +777 -0
- data/examples/redis_queue/07_queue_management.rb +587 -0
- data/examples/redis_queue/README.md +366 -0
- data/examples/redis_queue/enhanced_01_basic_patterns.rb +233 -0
- data/examples/redis_queue/enhanced_02_fluent_api.rb +331 -0
- data/examples/redis_queue/enhanced_03_dual_publishing.rb +281 -0
- data/examples/redis_queue/enhanced_04_advanced_routing.rb +419 -0
- data/examples/redis_queue/redis_queue_architecture.svg +148 -0
- data/ideas/README.md +41 -0
- data/ideas/agents.md +1001 -0
- data/ideas/database_transport.md +980 -0
- data/ideas/improvement.md +359 -0
- data/ideas/meshage.md +1788 -0
- data/ideas/message_discovery.md +178 -0
- data/ideas/message_schema.md +1381 -0
- data/lib/smart_message/.idea/.gitignore +8 -0
- data/lib/smart_message/.idea/markdown.xml +6 -0
- data/lib/smart_message/.idea/misc.xml +4 -0
- data/lib/smart_message/.idea/modules.xml +8 -0
- data/lib/smart_message/.idea/smart_message.iml +16 -0
- data/lib/smart_message/.idea/vcs.xml +6 -0
- data/lib/smart_message/addressing.rb +15 -0
- data/lib/smart_message/base.rb +0 -2
- data/lib/smart_message/configuration.rb +1 -1
- data/lib/smart_message/logger.rb +15 -4
- data/lib/smart_message/plugins.rb +5 -2
- data/lib/smart_message/serializer.rb +14 -0
- data/lib/smart_message/transport/redis_enhanced_transport.rb +399 -0
- data/lib/smart_message/transport/redis_queue_transport.rb +555 -0
- data/lib/smart_message/transport/registry.rb +1 -0
- data/lib/smart_message/transport.rb +34 -1
- data/lib/smart_message/version.rb +1 -1
- data/lib/smart_message.rb +5 -52
- data/mkdocs.yml +184 -0
- data/p2p_plan.md +326 -0
- data/p2p_roadmap.md +287 -0
- data/smart_message.gemspec +2 -0
- data/smart_message.svg +51 -0
- metadata +170 -44
- data/docs/README.md +0 -57
- data/examples/dead_letters.jsonl +0 -12
- data/examples/temp.txt +0 -94
- data/examples/tmux_chat/README.md +0 -283
- data/examples/tmux_chat/bot_agent.rb +0 -278
- data/examples/tmux_chat/human_agent.rb +0 -199
- data/examples/tmux_chat/room_monitor.rb +0 -160
- data/examples/tmux_chat/shared_chat_system.rb +0 -328
- data/examples/tmux_chat/start_chat_demo.sh +0 -190
- data/examples/tmux_chat/stop_chat_demo.sh +0 -22
- /data/docs/{properties.md → core-concepts/properties.md} +0 -0
- /data/docs/{ideas_to_think_about.md → development/ideas.md} +0 -0
@@ -0,0 +1,555 @@
|
|
1
|
+
# lib/smart_message/transport/redis_queue_transport_async.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
require 'async'
|
6
|
+
require 'async/redis'
|
7
|
+
require 'json'
|
8
|
+
|
9
|
+
module SmartMessage
|
10
|
+
module Transport
|
11
|
+
# Redis Queue Transport - Async-powered routing with RabbitMQ-style patterns
|
12
|
+
# This transport provides intelligent routing using Redis Lists as queues with pattern matching
|
13
|
+
# Built on Ruby's Async framework for modern concurrency and testing
|
14
|
+
#
|
15
|
+
# Key Features:
|
16
|
+
# - Async/Fiber-based concurrency (thousands of subscriptions)
|
17
|
+
# - RabbitMQ-style topic exchange pattern matching (#.*.my_uuid)
|
18
|
+
# - Load balancing via consumer groups
|
19
|
+
# - Queue persistence using Redis Lists
|
20
|
+
# - FIFO message ordering
|
21
|
+
# - 10x faster than RabbitMQ with same routing intelligence
|
22
|
+
# - Test-friendly with proper async lifecycle management
|
23
|
+
#
|
24
|
+
# Usage:
|
25
|
+
# Async do
|
26
|
+
# transport = SmartMessage::Transport::RedisQueueTransport.new
|
27
|
+
# transport.subscribe_pattern("#.*.my_service") do |msg_class, data|
|
28
|
+
# puts "Processing: #{msg_class}"
|
29
|
+
# end
|
30
|
+
# end
|
31
|
+
class RedisQueueTransport < Base
|
32
|
+
|
33
|
+
DEFAULT_CONFIG = {
|
34
|
+
url: 'redis://localhost:6379',
|
35
|
+
db: 0,
|
36
|
+
exchange_name: 'smart_message',
|
37
|
+
queue_prefix: 'smart_message.queue',
|
38
|
+
routing_prefix: 'smart_message.routing',
|
39
|
+
consumer_timeout: 1, # 1 second blocking pop timeout
|
40
|
+
max_queue_size: 10000, # Max messages per queue (circular buffer)
|
41
|
+
cleanup_on_disconnect: true, # Remove queues on shutdown
|
42
|
+
reconnect_attempts: 5,
|
43
|
+
reconnect_delay: 1,
|
44
|
+
async_timeout: 30 # Global async task timeout
|
45
|
+
}.freeze
|
46
|
+
|
47
|
+
attr_reader :redis, :exchange_name, :active_queues, :consumer_tasks
|
48
|
+
|
49
|
+
def initialize(**options)
|
50
|
+
@active_queues = {} # queue_name => consumer_info
|
51
|
+
@consumer_tasks = {} # queue_name => async task
|
52
|
+
@routing_table = {} # pattern => [queue_names]
|
53
|
+
@shutdown = false
|
54
|
+
# Don't initialize @redis to nil here since super() calls configure which sets it
|
55
|
+
super(**options)
|
56
|
+
end
|
57
|
+
|
58
|
+
def configure
|
59
|
+
@exchange_name = @options[:exchange_name]
|
60
|
+
|
61
|
+
# Setup Redis connection synchronously for immediate availability
|
62
|
+
begin
|
63
|
+
redis_client = nil
|
64
|
+
Async do
|
65
|
+
endpoint = Async::Redis::Endpoint.parse(@options[:url], db: @options[:db])
|
66
|
+
redis_client = Async::Redis::Client.new(endpoint)
|
67
|
+
|
68
|
+
# Test connection
|
69
|
+
redis_client.call('PING')
|
70
|
+
|
71
|
+
# Initialize exchange metadata with local redis client
|
72
|
+
@redis = redis_client # Set instance variable
|
73
|
+
setup_exchange
|
74
|
+
|
75
|
+
logger.info { "[RedisQueue] Async transport configured with exchange: #{@exchange_name}" }
|
76
|
+
end.wait
|
77
|
+
|
78
|
+
# Ensure @redis is set outside the async block too
|
79
|
+
@redis = redis_client if redis_client
|
80
|
+
rescue => e
|
81
|
+
logger.error { "[RedisQueue] Failed to configure transport: #{e.message}" }
|
82
|
+
@redis = nil
|
83
|
+
raise
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def default_options
|
88
|
+
DEFAULT_CONFIG
|
89
|
+
end
|
90
|
+
|
91
|
+
# Publish message with intelligent routing to matching queues (Async)
|
92
|
+
# @param message_class [String] The message class name
|
93
|
+
# @param serialized_message [String] The serialized message content
|
94
|
+
def do_publish(message_class, serialized_message)
|
95
|
+
async_task do
|
96
|
+
routing_info = extract_routing_info(serialized_message)
|
97
|
+
routing_key = build_enhanced_routing_key(message_class, routing_info)
|
98
|
+
|
99
|
+
# Find all queues that match this routing key (like RabbitMQ topic exchange)
|
100
|
+
matching_queues = find_matching_queues(routing_key)
|
101
|
+
|
102
|
+
if matching_queues.empty?
|
103
|
+
logger.debug { "[RedisQueue] No queues match routing key: #{routing_key}" }
|
104
|
+
next
|
105
|
+
end
|
106
|
+
|
107
|
+
# Create message envelope with metadata
|
108
|
+
message_envelope = {
|
109
|
+
routing_key: routing_key,
|
110
|
+
message_class: message_class.to_s,
|
111
|
+
data: serialized_message,
|
112
|
+
timestamp: Time.now.to_f,
|
113
|
+
headers: routing_info
|
114
|
+
}.to_json
|
115
|
+
|
116
|
+
# Publish to all matching queues atomically using async redis
|
117
|
+
published_count = 0
|
118
|
+
|
119
|
+
# Use pipelined operations for better performance
|
120
|
+
commands = []
|
121
|
+
matching_queues.each do |queue_name|
|
122
|
+
commands << [:lpush, queue_name, message_envelope]
|
123
|
+
commands << [:ltrim, queue_name, 0, @options[:max_queue_size] - 1]
|
124
|
+
published_count += 1
|
125
|
+
end
|
126
|
+
|
127
|
+
# Execute all commands in pipeline
|
128
|
+
@redis.pipelined(commands) if commands.any?
|
129
|
+
|
130
|
+
logger.debug { "[RedisQueue] Published to #{published_count} queues with key: #{routing_key}" }
|
131
|
+
rescue => e
|
132
|
+
logger.error { "[RedisQueue] Publish error: #{e.message}" }
|
133
|
+
raise
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Subscribe to messages using RabbitMQ-style pattern matching (Async)
|
138
|
+
# @param pattern [String] Routing pattern (e.g., "#.*.my_service", "order.#.*.*")
|
139
|
+
# @param process_method [String] Method identifier for processing
|
140
|
+
# @param filter_options [Hash] Additional filtering options
|
141
|
+
# @param block [Proc] Optional block for message processing
|
142
|
+
def subscribe_pattern(pattern, process_method = :process, filter_options = {}, &block)
|
143
|
+
queue_name = derive_queue_name(pattern, filter_options)
|
144
|
+
|
145
|
+
# Add pattern to routing table (no mutex needed with Fibers)
|
146
|
+
@routing_table[pattern] ||= []
|
147
|
+
@routing_table[pattern] << queue_name unless @routing_table[pattern].include?(queue_name)
|
148
|
+
|
149
|
+
# Store queue metadata
|
150
|
+
@active_queues[queue_name] = {
|
151
|
+
pattern: pattern,
|
152
|
+
process_method: process_method,
|
153
|
+
filter_options: filter_options,
|
154
|
+
created_at: Time.now,
|
155
|
+
block_handler: block
|
156
|
+
}
|
157
|
+
|
158
|
+
# Start async consumer task for this queue (unless in test mode)
|
159
|
+
start_queue_consumer(queue_name) unless @options[:test_mode]
|
160
|
+
|
161
|
+
logger.info { "[RedisQueue] Subscribed to pattern '#{pattern}' via queue '#{queue_name}'" }
|
162
|
+
end
|
163
|
+
|
164
|
+
# Subscribe to all messages sent to a specific recipient
|
165
|
+
# @param recipient_id [String] The recipient identifier
|
166
|
+
def subscribe_to_recipient(recipient_id, process_method = :process, &block)
|
167
|
+
pattern = "#.*.#{sanitize_for_routing_key(recipient_id)}"
|
168
|
+
subscribe_pattern(pattern, process_method, {}, &block)
|
169
|
+
end
|
170
|
+
|
171
|
+
# Subscribe to all messages from a specific sender
|
172
|
+
# @param sender_id [String] The sender identifier
|
173
|
+
def subscribe_from_sender(sender_id, process_method = :process, &block)
|
174
|
+
pattern = "#.#{sanitize_for_routing_key(sender_id)}.*"
|
175
|
+
subscribe_pattern(pattern, process_method, {}, &block)
|
176
|
+
end
|
177
|
+
|
178
|
+
# Subscribe to all messages of a specific type regardless of routing
|
179
|
+
# @param message_type [String] The message class name
|
180
|
+
def subscribe_to_type(message_type, process_method = :process, &block)
|
181
|
+
pattern = "*.#{message_type.to_s.gsub('::', '.').downcase}.*.*"
|
182
|
+
subscribe_pattern(pattern, process_method, {}, &block)
|
183
|
+
end
|
184
|
+
|
185
|
+
# Subscribe to all alert/emergency messages
|
186
|
+
def subscribe_to_alerts(process_method = :process, &block)
|
187
|
+
patterns = [
|
188
|
+
"emergency.#.*.*",
|
189
|
+
"#.alert.*.*",
|
190
|
+
"#.alarm.*.*",
|
191
|
+
"#.critical.*.*"
|
192
|
+
]
|
193
|
+
|
194
|
+
patterns.each { |pattern| subscribe_pattern(pattern, process_method, {}, &block) }
|
195
|
+
end
|
196
|
+
|
197
|
+
# Subscribe to all broadcast messages
|
198
|
+
def subscribe_to_broadcasts(process_method = :process, &block)
|
199
|
+
pattern = "#.*.broadcast"
|
200
|
+
subscribe_pattern(pattern, process_method, {}, &block)
|
201
|
+
end
|
202
|
+
|
203
|
+
# Test-friendly disconnect method
|
204
|
+
def disconnect
|
205
|
+
@shutdown = true
|
206
|
+
|
207
|
+
# Stop all consumer tasks
|
208
|
+
@consumer_tasks.each do |queue_name, task|
|
209
|
+
task.stop if task&.running?
|
210
|
+
rescue => e
|
211
|
+
logger.debug { "[RedisQueue] Error stopping consumer task for #{queue_name}: #{e.message}" }
|
212
|
+
end
|
213
|
+
|
214
|
+
@consumer_tasks.clear
|
215
|
+
|
216
|
+
# Close Redis connection
|
217
|
+
async_task do
|
218
|
+
@redis&.close
|
219
|
+
@redis = nil
|
220
|
+
rescue => e
|
221
|
+
logger.debug { "[RedisQueue] Error closing Redis connection: #{e.message}" }
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
def connected?
|
226
|
+
return false unless @redis
|
227
|
+
|
228
|
+
# Test connection with async ping
|
229
|
+
begin
|
230
|
+
if Async::Task.current?
|
231
|
+
# Already in async context
|
232
|
+
return @redis.call('PING') == 'PONG'
|
233
|
+
else
|
234
|
+
# Need to create async context
|
235
|
+
result = Async do
|
236
|
+
@redis.call('PING') == 'PONG'
|
237
|
+
end.wait
|
238
|
+
return result
|
239
|
+
end
|
240
|
+
rescue => e
|
241
|
+
logger.debug { "[RedisQueue] Connection test failed: #{e.message}" }
|
242
|
+
return false
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# Get statistics about all active queues
|
247
|
+
def queue_stats
|
248
|
+
return {} unless @redis
|
249
|
+
|
250
|
+
stats = {}
|
251
|
+
|
252
|
+
begin
|
253
|
+
Async do
|
254
|
+
@active_queues.each do |queue_name, queue_info|
|
255
|
+
length = @redis.llen(queue_name).to_i
|
256
|
+
stats[queue_name] = {
|
257
|
+
length: length,
|
258
|
+
pattern: queue_info[:pattern],
|
259
|
+
created_at: queue_info[:created_at],
|
260
|
+
consumers: @consumer_tasks.key?(queue_name) ? 1 : 0
|
261
|
+
}
|
262
|
+
end
|
263
|
+
end.wait
|
264
|
+
rescue => e
|
265
|
+
logger.error { "[RedisQueue] Error getting queue stats: #{e.message}" }
|
266
|
+
end
|
267
|
+
|
268
|
+
stats
|
269
|
+
end
|
270
|
+
|
271
|
+
# Get the current routing table
|
272
|
+
def routing_table
|
273
|
+
@routing_table.dup
|
274
|
+
end
|
275
|
+
|
276
|
+
# Fluent API builder for complex subscriptions
|
277
|
+
def where
|
278
|
+
RedisQueueSubscriptionBuilder.new(self)
|
279
|
+
end
|
280
|
+
|
281
|
+
private
|
282
|
+
|
283
|
+
# Ensure async operations run in proper context
|
284
|
+
def async_task(&block)
|
285
|
+
if Async::Task.current?
|
286
|
+
# Already in async context
|
287
|
+
yield
|
288
|
+
else
|
289
|
+
# Need to create async context
|
290
|
+
Async { yield }
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
# Start async consumer for a queue
|
295
|
+
def start_queue_consumer(queue_name)
|
296
|
+
return if @consumer_tasks.key?(queue_name)
|
297
|
+
|
298
|
+
@consumer_tasks[queue_name] = async_task do |task|
|
299
|
+
begin
|
300
|
+
consume_from_queue(queue_name)
|
301
|
+
rescue => e
|
302
|
+
logger.error { "[RedisQueue] Consumer task error for #{queue_name}: #{e.message}" }
|
303
|
+
ensure
|
304
|
+
@consumer_tasks.delete(queue_name)
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
# Async consumer loop for a specific queue
|
310
|
+
def consume_from_queue(queue_name)
|
311
|
+
queue_info = @active_queues[queue_name]
|
312
|
+
return unless queue_info
|
313
|
+
|
314
|
+
while !@shutdown
|
315
|
+
begin
|
316
|
+
# Use BRPOP for blocking read with timeout (cooperative blocking)
|
317
|
+
result = @redis.brpop(queue_name, timeout: @options[:consumer_timeout])
|
318
|
+
|
319
|
+
if result && result.length >= 2
|
320
|
+
_, message_envelope = result
|
321
|
+
process_queue_message(message_envelope, queue_info)
|
322
|
+
end
|
323
|
+
|
324
|
+
rescue => e
|
325
|
+
logger.error { "[RedisQueue] Redis connection error in consumer: #{e.message}" }
|
326
|
+
# Async will handle reconnection automatically
|
327
|
+
sleep(1) unless @shutdown
|
328
|
+
rescue => e
|
329
|
+
logger.error { "[RedisQueue] Consumer error for #{queue_name}: #{e.message}" }
|
330
|
+
sleep(1) unless @shutdown
|
331
|
+
end
|
332
|
+
end
|
333
|
+
end
|
334
|
+
|
335
|
+
# Process a message from the queue
|
336
|
+
def process_queue_message(message_envelope, queue_info)
|
337
|
+
begin
|
338
|
+
message_data = JSON.parse(message_envelope)
|
339
|
+
|
340
|
+
message_class = message_data['message_class']
|
341
|
+
payload = message_data['data']
|
342
|
+
headers = message_data['headers'] || {}
|
343
|
+
|
344
|
+
# Apply additional filtering if specified
|
345
|
+
if should_process_message?(headers, queue_info[:filter_options])
|
346
|
+
# Use block handler if provided, otherwise route through dispatcher
|
347
|
+
if queue_info[:block_handler]
|
348
|
+
queue_info[:block_handler].call(message_class, payload)
|
349
|
+
else
|
350
|
+
# Route through dispatcher (inherited from Base)
|
351
|
+
receive(message_class, payload)
|
352
|
+
end
|
353
|
+
else
|
354
|
+
logger.debug { "[RedisQueue] Message filtered out by queue rules" }
|
355
|
+
end
|
356
|
+
|
357
|
+
rescue JSON::ParserError => e
|
358
|
+
logger.error { "[RedisQueue] Invalid message envelope: #{e.message}" }
|
359
|
+
rescue => e
|
360
|
+
logger.error { "[RedisQueue] Error processing message: #{e.message}" }
|
361
|
+
end
|
362
|
+
end
|
363
|
+
|
364
|
+
# Check if message should be processed based on filters
|
365
|
+
def should_process_message?(headers, filter_options)
|
366
|
+
return true if filter_options.empty?
|
367
|
+
|
368
|
+
# Apply from/to filters if specified
|
369
|
+
if filter_options[:from] && headers['from'] != filter_options[:from]
|
370
|
+
return false
|
371
|
+
end
|
372
|
+
|
373
|
+
if filter_options[:to] && headers['to'] != filter_options[:to]
|
374
|
+
return false
|
375
|
+
end
|
376
|
+
|
377
|
+
true
|
378
|
+
end
|
379
|
+
|
380
|
+
# Setup exchange metadata in Redis
|
381
|
+
def setup_exchange
|
382
|
+
exchange_key = "#{@options[:routing_prefix]}:#{@exchange_name}:metadata"
|
383
|
+
@redis.hset(exchange_key, 'type', 'topic')
|
384
|
+
@redis.hset(exchange_key, 'created_at', Time.now.to_f)
|
385
|
+
@redis.expire(exchange_key, 86400) # Expire in 24 hours
|
386
|
+
end
|
387
|
+
|
388
|
+
# Generate queue name from pattern and options
|
389
|
+
def derive_queue_name(pattern, filter_options = {})
|
390
|
+
base_name = pattern.gsub(/[#*]/, 'wildcard')
|
391
|
+
filter_suffix = filter_options.empty? ? '' : "_#{filter_options.hash.abs}"
|
392
|
+
"#{@options[:queue_prefix]}.#{base_name}#{filter_suffix}"
|
393
|
+
end
|
394
|
+
|
395
|
+
# Extract routing information from message
|
396
|
+
def extract_routing_info(serialized_message)
|
397
|
+
data = JSON.parse(serialized_message)
|
398
|
+
header = data['_sm_header'] || {}
|
399
|
+
{
|
400
|
+
from: header['from'],
|
401
|
+
to: header['to']
|
402
|
+
}
|
403
|
+
rescue JSON::ParserError
|
404
|
+
{ from: nil, to: nil }
|
405
|
+
end
|
406
|
+
|
407
|
+
# Build enhanced routing key similar to RabbitMQ topic exchange
|
408
|
+
def build_enhanced_routing_key(message_class, routing_info)
|
409
|
+
# Create hierarchical routing key: namespace.message_type.from.to
|
410
|
+
parts = []
|
411
|
+
|
412
|
+
# Add exchange name as namespace
|
413
|
+
parts << @exchange_name
|
414
|
+
|
415
|
+
# Add message class (normalized)
|
416
|
+
normalized_class = message_class.to_s.gsub('::', '.').downcase
|
417
|
+
parts << normalized_class
|
418
|
+
|
419
|
+
# Add from/to if present (default to 'any')
|
420
|
+
parts << (routing_info[:from] || 'any')
|
421
|
+
parts << (routing_info[:to] || 'any')
|
422
|
+
|
423
|
+
parts.join('.')
|
424
|
+
end
|
425
|
+
|
426
|
+
# Find queues that match the routing key pattern
|
427
|
+
def find_matching_queues(routing_key)
|
428
|
+
matching = []
|
429
|
+
|
430
|
+
@routing_table.each do |pattern, queue_names|
|
431
|
+
if routing_key_matches_pattern?(routing_key, pattern)
|
432
|
+
matching.concat(queue_names)
|
433
|
+
end
|
434
|
+
end
|
435
|
+
|
436
|
+
matching.uniq
|
437
|
+
end
|
438
|
+
|
439
|
+
# Check if routing key matches the pattern (RabbitMQ-style)
|
440
|
+
def routing_key_matches_pattern?(routing_key, pattern)
|
441
|
+
return false if routing_key.nil? || routing_key.empty?
|
442
|
+
return false if pattern.nil? || pattern.empty?
|
443
|
+
|
444
|
+
# Split into segments for proper RabbitMQ-style matching
|
445
|
+
routing_segments = routing_key.split('.')
|
446
|
+
pattern_segments = pattern.split('.')
|
447
|
+
|
448
|
+
match_segments(routing_segments, pattern_segments)
|
449
|
+
end
|
450
|
+
|
451
|
+
# Recursively match routing key segments against pattern segments
|
452
|
+
def match_segments(routing_segments, pattern_segments)
|
453
|
+
return routing_segments.empty? && pattern_segments.empty? if pattern_segments.empty?
|
454
|
+
return pattern_segments.all? { |seg| seg == '#' } if routing_segments.empty?
|
455
|
+
|
456
|
+
pattern_seg = pattern_segments[0]
|
457
|
+
|
458
|
+
case pattern_seg
|
459
|
+
when '#'
|
460
|
+
# # matches zero or more segments
|
461
|
+
# Try matching with zero segments (skip the # pattern)
|
462
|
+
if match_segments(routing_segments, pattern_segments[1..-1])
|
463
|
+
return true
|
464
|
+
end
|
465
|
+
# Try matching with one or more segments (consume one routing segment)
|
466
|
+
return match_segments(routing_segments[1..-1], pattern_segments)
|
467
|
+
|
468
|
+
when '*'
|
469
|
+
# * matches exactly one segment
|
470
|
+
return false if routing_segments.empty?
|
471
|
+
return match_segments(routing_segments[1..-1], pattern_segments[1..-1])
|
472
|
+
|
473
|
+
else
|
474
|
+
# Literal match required
|
475
|
+
return false if routing_segments.empty? || routing_segments[0] != pattern_seg
|
476
|
+
return match_segments(routing_segments[1..-1], pattern_segments[1..-1])
|
477
|
+
end
|
478
|
+
end
|
479
|
+
|
480
|
+
# Sanitize string for use in routing key
|
481
|
+
def sanitize_for_routing_key(str)
|
482
|
+
str.to_s.gsub(/[^a-zA-Z0-9_-]/, '_').downcase
|
483
|
+
end
|
484
|
+
end
|
485
|
+
|
486
|
+
# Fluent API builder for complex subscription patterns
|
487
|
+
class RedisQueueSubscriptionBuilder
|
488
|
+
def initialize(transport)
|
489
|
+
@transport = transport
|
490
|
+
@conditions = {}
|
491
|
+
end
|
492
|
+
|
493
|
+
def from(sender_uuid)
|
494
|
+
@conditions[:from] = sender_uuid
|
495
|
+
self
|
496
|
+
end
|
497
|
+
|
498
|
+
def to(recipient_uuid)
|
499
|
+
@conditions[:to] = recipient_uuid
|
500
|
+
self
|
501
|
+
end
|
502
|
+
|
503
|
+
def type(message_type)
|
504
|
+
@conditions[:type] = message_type
|
505
|
+
self
|
506
|
+
end
|
507
|
+
|
508
|
+
def broadcast
|
509
|
+
@conditions[:to] = 'broadcast'
|
510
|
+
self
|
511
|
+
end
|
512
|
+
|
513
|
+
def alerts
|
514
|
+
@conditions[:alerts] = true
|
515
|
+
self
|
516
|
+
end
|
517
|
+
|
518
|
+
def consumer_group(group_name)
|
519
|
+
@conditions[:consumer_group] = group_name
|
520
|
+
self
|
521
|
+
end
|
522
|
+
|
523
|
+
def build
|
524
|
+
parts = []
|
525
|
+
|
526
|
+
# Build pattern based on conditions
|
527
|
+
if @conditions[:alerts]
|
528
|
+
return "alert.#.*.*"
|
529
|
+
end
|
530
|
+
|
531
|
+
if @conditions[:type]
|
532
|
+
parts << "*"
|
533
|
+
parts << @conditions[:type].to_s.gsub('::', '.').downcase
|
534
|
+
else
|
535
|
+
parts << "#"
|
536
|
+
end
|
537
|
+
|
538
|
+
parts << (@conditions[:from] || "*")
|
539
|
+
parts << (@conditions[:to] || "*")
|
540
|
+
|
541
|
+
parts.join(".")
|
542
|
+
end
|
543
|
+
|
544
|
+
def subscribe(process_method = :process, &block)
|
545
|
+
pattern = build
|
546
|
+
|
547
|
+
filter_options = {}
|
548
|
+
filter_options[:from] = @conditions[:from] if @conditions[:from]
|
549
|
+
filter_options[:to] = @conditions[:to] if @conditions[:to]
|
550
|
+
|
551
|
+
@transport.subscribe_pattern(pattern, process_method, filter_options, &block)
|
552
|
+
end
|
553
|
+
end
|
554
|
+
end
|
555
|
+
end
|
@@ -54,6 +54,7 @@ module SmartMessage
|
|
54
54
|
register(:stdout, SmartMessage::Transport::StdoutTransport)
|
55
55
|
register(:memory, SmartMessage::Transport::MemoryTransport)
|
56
56
|
register(:redis, SmartMessage::Transport::RedisTransport)
|
57
|
+
register(:redis_queue, SmartMessage::Transport::RedisQueueTransport)
|
57
58
|
end
|
58
59
|
end
|
59
60
|
end
|
@@ -6,4 +6,37 @@ require_relative 'transport/base'
|
|
6
6
|
require_relative 'transport/registry'
|
7
7
|
require_relative 'transport/stdout_transport'
|
8
8
|
require_relative 'transport/memory_transport'
|
9
|
-
require_relative 'transport/redis_transport'
|
9
|
+
require_relative 'transport/redis_transport'
|
10
|
+
require_relative 'transport/redis_queue_transport'
|
11
|
+
|
12
|
+
module SmartMessage
|
13
|
+
module Transport
|
14
|
+
class << self
|
15
|
+
def default
|
16
|
+
# Check global configuration first, then fall back to framework default
|
17
|
+
SmartMessage.configuration.default_transport
|
18
|
+
end
|
19
|
+
|
20
|
+
def registry
|
21
|
+
@registry ||= Registry.new
|
22
|
+
end
|
23
|
+
|
24
|
+
def register(name, transport_class)
|
25
|
+
registry.register(name, transport_class)
|
26
|
+
end
|
27
|
+
|
28
|
+
def get(name)
|
29
|
+
registry.get(name)
|
30
|
+
end
|
31
|
+
|
32
|
+
def create(name, **options)
|
33
|
+
transport_class = get(name)
|
34
|
+
transport_class&.new(**options)
|
35
|
+
end
|
36
|
+
|
37
|
+
def available
|
38
|
+
registry.list
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/lib/smart_message.rb
CHANGED
@@ -28,7 +28,7 @@ require_relative './simple_stats'
|
|
28
28
|
module SmartMessage
|
29
29
|
class << self
|
30
30
|
# Global configuration for SmartMessage
|
31
|
-
#
|
31
|
+
#
|
32
32
|
# Usage:
|
33
33
|
# SmartMessage.configure do |config|
|
34
34
|
# config.logger = MyApp::Logger.new
|
@@ -38,65 +38,18 @@ module SmartMessage
|
|
38
38
|
def configure
|
39
39
|
yield(configuration)
|
40
40
|
end
|
41
|
-
|
41
|
+
|
42
42
|
# Get the global configuration instance
|
43
43
|
def configuration
|
44
44
|
@configuration ||= Configuration.new
|
45
45
|
end
|
46
|
-
|
46
|
+
|
47
47
|
# Reset global configuration to defaults
|
48
48
|
def reset_configuration!
|
49
49
|
@configuration = Configuration.new
|
50
|
+
# Also reset the cached logger
|
51
|
+
SmartMessage::Logger.reset!
|
50
52
|
end
|
51
53
|
end
|
52
54
|
# Module definitions for Zeitwerk to populate
|
53
|
-
module Serializer
|
54
|
-
class << self
|
55
|
-
def default
|
56
|
-
# Check global configuration first, then fall back to framework default
|
57
|
-
SmartMessage.configuration.default_serializer
|
58
|
-
end
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
|
-
module Logger
|
63
|
-
class << self
|
64
|
-
def default
|
65
|
-
# Check global configuration first, then fall back to framework default
|
66
|
-
SmartMessage.configuration.default_logger
|
67
|
-
end
|
68
|
-
end
|
69
|
-
end
|
70
|
-
|
71
|
-
module Transport
|
72
|
-
class << self
|
73
|
-
def default
|
74
|
-
# Check global configuration first, then fall back to framework default
|
75
|
-
SmartMessage.configuration.default_transport
|
76
|
-
end
|
77
|
-
|
78
|
-
def registry
|
79
|
-
@registry ||= Registry.new
|
80
|
-
end
|
81
|
-
|
82
|
-
def register(name, transport_class)
|
83
|
-
registry.register(name, transport_class)
|
84
|
-
end
|
85
|
-
|
86
|
-
def get(name)
|
87
|
-
registry.get(name)
|
88
|
-
end
|
89
|
-
|
90
|
-
def create(name, **options)
|
91
|
-
transport_class = get(name)
|
92
|
-
transport_class&.new(**options)
|
93
|
-
end
|
94
|
-
|
95
|
-
def available
|
96
|
-
registry.list
|
97
|
-
end
|
98
|
-
end
|
99
|
-
end
|
100
55
|
end # module SmartMessage
|
101
|
-
|
102
|
-
# Don't eager load initially - let Zeitwerk handle lazy loading
|