smart_message 0.0.13 → 0.0.17
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/CHANGELOG.md +184 -0
- data/Gemfile.lock +6 -6
- data/README.md +75 -25
- data/docs/guides/transport-selection.md +361 -0
- data/docs/index.md +2 -0
- data/docs/reference/transports.md +78 -29
- data/docs/transports/file-transport.md +535 -0
- data/docs/transports/memory-transport.md +2 -1
- data/docs/transports/multi-transport.md +484 -0
- data/docs/transports/redis-transport.md +1 -1
- data/docs/transports/stdout-transport.md +580 -0
- data/examples/file/00_run_all_file_demos.rb +260 -0
- data/examples/file/01_basic_file_transport_demo.rb +237 -0
- data/examples/file/02_fifo_transport_demo.rb +289 -0
- data/examples/file/03_file_watching_demo.rb +332 -0
- data/examples/file/04_multi_transport_file_demo.rb +432 -0
- data/examples/file/README.md +257 -0
- data/examples/memory/00_run_all_demos.rb +317 -0
- data/examples/memory/01_message_deduplication_demo.rb +18 -30
- data/examples/memory/02_dead_letter_queue_demo.rb +9 -9
- data/examples/memory/03_point_to_point_orders.rb +3 -3
- data/examples/memory/04_publish_subscribe_events.rb +15 -15
- data/examples/memory/05_many_to_many_chat.rb +19 -19
- data/examples/memory/06_stdout_publish_only.rb +118 -0
- data/examples/memory/07_proc_handlers_demo.rb +13 -13
- data/examples/memory/08_custom_logger_demo.rb +136 -136
- data/examples/memory/09_error_handling_demo.rb +7 -7
- data/examples/memory/10_entity_addressing_basic.rb +25 -25
- data/examples/memory/11_entity_addressing_with_filtering.rb +32 -32
- data/examples/memory/12_regex_filtering_microservices.rb +10 -10
- data/examples/memory/14_global_configuration_demo.rb +12 -12
- data/examples/memory/README.md +34 -17
- data/examples/memory/log/demo_app.log.2 +100 -0
- data/examples/multi_transport_example.rb +114 -0
- data/examples/redis/01_smart_home_iot_demo.rb +20 -20
- data/examples/utilities/box_it.rb +12 -0
- data/examples/utilities/doing.rb +19 -0
- data/examples/utilities/temp.md +28 -0
- data/lib/smart_message/base.rb +5 -7
- data/lib/smart_message/errors.rb +3 -0
- data/lib/smart_message/header.rb +1 -1
- data/lib/smart_message/logger/default.rb +1 -1
- data/lib/smart_message/messaging.rb +36 -6
- data/lib/smart_message/plugins.rb +46 -4
- data/lib/smart_message/serializer/base.rb +1 -1
- data/lib/smart_message/serializer.rb +3 -2
- data/lib/smart_message/subscription.rb +18 -20
- data/lib/smart_message/transport/async_publish_queue.rb +284 -0
- data/lib/smart_message/transport/fifo_operations.rb +264 -0
- data/lib/smart_message/transport/file_operations.rb +232 -0
- data/lib/smart_message/transport/file_transport.rb +152 -0
- data/lib/smart_message/transport/file_watching.rb +72 -0
- data/lib/smart_message/transport/partitioned_files.rb +46 -0
- data/lib/smart_message/transport/stdout_transport.rb +7 -81
- data/lib/smart_message/transport/stdout_transport.rb.backup +88 -0
- data/lib/smart_message/version.rb +1 -1
- data/mkdocs.yml +4 -5
- metadata +26 -10
- data/ideas/README.md +0 -41
- data/ideas/agents.md +0 -1001
- data/ideas/database_transport.md +0 -980
- data/ideas/improvement.md +0 -359
- data/ideas/meshage.md +0 -1788
- data/ideas/message_discovery.md +0 -178
- data/ideas/message_schema.md +0 -1381
- data/lib/smart_message/wrapper.rb.bak +0 -132
- /data/examples/memory/{06_pretty_print_demo.rb → 16_pretty_print_demo.rb} +0 -0
@@ -0,0 +1,232 @@
|
|
1
|
+
# lib/smart_message/transport/file_operations.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
module SmartMessage
|
6
|
+
module Transport
|
7
|
+
# Module for shared file operations, including buffering, rotation, and basic I/O.
|
8
|
+
module FileOperations
|
9
|
+
def configure_file_output
|
10
|
+
ensure_directory_exists if @options[:create_directories]
|
11
|
+
@file_handle = open_file_handle
|
12
|
+
@write_buffer = []
|
13
|
+
@last_flush = Time.now
|
14
|
+
setup_rotation_timer if rotation_enabled?
|
15
|
+
@file_mutex = Mutex.new # For thread-safety
|
16
|
+
end
|
17
|
+
|
18
|
+
def write_to_file(serialized_message)
|
19
|
+
content = prepare_file_content(serialized_message)
|
20
|
+
|
21
|
+
@file_mutex.synchronize do
|
22
|
+
if buffered_mode?
|
23
|
+
buffer_write(content)
|
24
|
+
else
|
25
|
+
direct_write(content)
|
26
|
+
end
|
27
|
+
|
28
|
+
rotate_file_if_needed
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def flush_buffer
|
33
|
+
return if @write_buffer.empty?
|
34
|
+
|
35
|
+
# Only synchronize if we're not already holding the lock
|
36
|
+
if @file_mutex.owned?
|
37
|
+
@file_handle.write(@write_buffer.join)
|
38
|
+
@file_handle.flush
|
39
|
+
@write_buffer.clear
|
40
|
+
@last_flush = Time.now
|
41
|
+
else
|
42
|
+
@file_mutex.synchronize do
|
43
|
+
@file_handle.write(@write_buffer.join)
|
44
|
+
@file_handle.flush
|
45
|
+
@write_buffer.clear
|
46
|
+
@last_flush = Time.now
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def close_file_handle
|
52
|
+
flush_buffer if buffered_mode?
|
53
|
+
if @file_mutex
|
54
|
+
@file_mutex.synchronize do
|
55
|
+
@file_handle&.flush unless @file_handle&.closed?
|
56
|
+
@file_handle&.close unless @file_handle&.closed?
|
57
|
+
@file_handle = nil
|
58
|
+
end
|
59
|
+
else
|
60
|
+
@file_handle&.flush unless @file_handle&.closed?
|
61
|
+
@file_handle&.close unless @file_handle&.closed?
|
62
|
+
@file_handle = nil
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def prepare_file_content(serialized_message)
|
69
|
+
case @options[:format] || @options[:file_format] || :jsonl
|
70
|
+
when :json, :raw
|
71
|
+
serialized_message
|
72
|
+
when :jsonl, :lines
|
73
|
+
"#{serialized_message}\n"
|
74
|
+
when :pretty
|
75
|
+
begin
|
76
|
+
require 'amazing_print'
|
77
|
+
# Use the serializer to decode back to the original data structure
|
78
|
+
if @serializer.respond_to?(:decode)
|
79
|
+
data = @serializer.decode(serialized_message)
|
80
|
+
data.ai + "\n"
|
81
|
+
else
|
82
|
+
# Fallback: try to parse as JSON
|
83
|
+
begin
|
84
|
+
require 'json'
|
85
|
+
data = JSON.parse(serialized_message)
|
86
|
+
data.ai + "\n"
|
87
|
+
rescue JSON::ParserError
|
88
|
+
# If not JSON, pretty print the raw string
|
89
|
+
serialized_message.ai + "\n"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
rescue LoadError
|
93
|
+
# Fallback if amazing_print not available
|
94
|
+
"#{serialized_message}\n"
|
95
|
+
rescue => e
|
96
|
+
# Handle any other errors (like circuit breaker issues)
|
97
|
+
"#{serialized_message}\n"
|
98
|
+
end
|
99
|
+
else
|
100
|
+
"#{serialized_message}\n" # default to jsonl
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def open_file_handle
|
105
|
+
# Return IO objects directly, don't try to open them
|
106
|
+
return @options[:file_path] if @options[:file_path].respond_to?(:write)
|
107
|
+
# Open file handle for string paths
|
108
|
+
File.open(current_file_path, file_mode, encoding: @options[:encoding])
|
109
|
+
end
|
110
|
+
|
111
|
+
def ensure_directory_exists
|
112
|
+
return unless @options[:create_directories]
|
113
|
+
return if @options[:file_path].respond_to?(:write) # Skip for IO objects
|
114
|
+
|
115
|
+
dir = File.dirname(current_file_path)
|
116
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
117
|
+
end
|
118
|
+
|
119
|
+
def current_file_path
|
120
|
+
if rotation_enabled? && time_based_rotation?
|
121
|
+
timestamped_file_path
|
122
|
+
else
|
123
|
+
@options[:file_path]
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def file_mode
|
128
|
+
@options[:file_mode] || 'a' # append by default
|
129
|
+
end
|
130
|
+
|
131
|
+
def buffered_mode?
|
132
|
+
@options[:buffer_size] && @options[:buffer_size] > 0
|
133
|
+
end
|
134
|
+
|
135
|
+
def buffer_write(content)
|
136
|
+
@write_buffer << content
|
137
|
+
|
138
|
+
if buffer_full? || flush_interval_exceeded?
|
139
|
+
flush_buffer
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def direct_write(content)
|
144
|
+
return unless @file_handle
|
145
|
+
@file_handle.write(content)
|
146
|
+
@file_handle.flush if @options[:auto_flush]
|
147
|
+
end
|
148
|
+
|
149
|
+
def buffer_full?
|
150
|
+
@write_buffer.join.bytesize >= @options[:buffer_size]
|
151
|
+
end
|
152
|
+
|
153
|
+
def flush_interval_exceeded?
|
154
|
+
@options[:flush_interval] &&
|
155
|
+
(Time.now - @last_flush) >= @options[:flush_interval]
|
156
|
+
end
|
157
|
+
|
158
|
+
def rotation_enabled?
|
159
|
+
@options[:rotate_size] || @options[:rotate_time]
|
160
|
+
end
|
161
|
+
|
162
|
+
def time_based_rotation?
|
163
|
+
@options[:rotate_time]
|
164
|
+
end
|
165
|
+
|
166
|
+
def should_rotate?
|
167
|
+
size_rotation_needed? || time_rotation_needed?
|
168
|
+
end
|
169
|
+
|
170
|
+
def size_rotation_needed?
|
171
|
+
@options[:rotate_size] &&
|
172
|
+
File.exist?(current_file_path) && File.size(current_file_path) >= @options[:rotate_size]
|
173
|
+
end
|
174
|
+
|
175
|
+
def time_rotation_needed?
|
176
|
+
return false unless @options[:rotate_time]
|
177
|
+
|
178
|
+
case @options[:rotate_time]
|
179
|
+
when :hourly
|
180
|
+
Time.now.min == 0 && Time.now.sec == 0
|
181
|
+
when :daily
|
182
|
+
Time.now.hour == 0 && Time.now.min == 0 && Time.now.sec == 0
|
183
|
+
else
|
184
|
+
false
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
def rotate_file_if_needed
|
189
|
+
return unless should_rotate?
|
190
|
+
|
191
|
+
close_current_file
|
192
|
+
archive_current_file
|
193
|
+
@file_handle = open_file_handle
|
194
|
+
end
|
195
|
+
|
196
|
+
def close_current_file
|
197
|
+
flush_buffer if buffered_mode?
|
198
|
+
@file_handle&.close
|
199
|
+
@file_handle = nil
|
200
|
+
end
|
201
|
+
|
202
|
+
def archive_current_file
|
203
|
+
return unless File.exist?(current_file_path)
|
204
|
+
|
205
|
+
timestamp = Time.now.strftime(@options[:timestamp_format] || '%Y%m%d_%H%M%S')
|
206
|
+
base = File.basename(@options[:file_path], '.*')
|
207
|
+
ext = File.extname(@options[:file_path])
|
208
|
+
dir = File.dirname(@options[:file_path])
|
209
|
+
archive_path = File.join(dir, "#{base}_#{timestamp}#{ext}")
|
210
|
+
|
211
|
+
FileUtils.mv(current_file_path, archive_path)
|
212
|
+
|
213
|
+
# Maintain rotation count
|
214
|
+
if @options[:rotate_count]
|
215
|
+
files = Dir.glob(File.join(dir, "#{base}_*#{ext}")).sort
|
216
|
+
while files.size > @options[:rotate_count]
|
217
|
+
File.delete(files.shift)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
def timestamped_file_path
|
223
|
+
base = File.basename(@options[:file_path], '.*')
|
224
|
+
ext = File.extname(@options[:file_path])
|
225
|
+
dir = File.dirname(@options[:file_path])
|
226
|
+
timestamp = Time.now.strftime(@options[:timestamp_format] || '%Y%m%d_%H%M%S')
|
227
|
+
|
228
|
+
File.join(dir, "#{base}_#{timestamp}#{ext}")
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# lib/smart_message/transport/file_transport.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
require_relative 'file_operations'
|
6
|
+
require_relative 'file_watching'
|
7
|
+
require_relative 'partitioned_files'
|
8
|
+
require_relative 'async_publish_queue'
|
9
|
+
require_relative 'fifo_operations'
|
10
|
+
|
11
|
+
module SmartMessage
|
12
|
+
module Transport
|
13
|
+
class FileTransport < Base
|
14
|
+
include FileOperations
|
15
|
+
include FileWatching
|
16
|
+
include PartitionedFiles
|
17
|
+
include AsyncPublishQueue
|
18
|
+
include FifoOperations
|
19
|
+
|
20
|
+
# @param path [String, IO, Pathname] file path or IO-like object
|
21
|
+
# @param mode [String] file open mode ("a" for append, etc.)
|
22
|
+
# @param encoding [String, nil] file encoding
|
23
|
+
def initialize(options = {})
|
24
|
+
@current_message_class = nil
|
25
|
+
super(**options)
|
26
|
+
end
|
27
|
+
|
28
|
+
def default_options
|
29
|
+
{
|
30
|
+
file_path: 'messages.log',
|
31
|
+
file_mode: 'a',
|
32
|
+
encoding: nil,
|
33
|
+
file_format: :lines,
|
34
|
+
buffer_size: 0,
|
35
|
+
flush_interval: nil,
|
36
|
+
auto_flush: true,
|
37
|
+
rotate_size: nil,
|
38
|
+
rotate_time: nil,
|
39
|
+
rotate_count: 5,
|
40
|
+
timestamp_format: '%Y%m%d_%H%M%S',
|
41
|
+
create_directories: true,
|
42
|
+
async: false,
|
43
|
+
max_queue: nil,
|
44
|
+
drop_when_full: false,
|
45
|
+
queue_overflow_strategy: :block,
|
46
|
+
max_retries: 3,
|
47
|
+
max_retry_delay: 30,
|
48
|
+
worker_timeout: 5,
|
49
|
+
shutdown_timeout: 10,
|
50
|
+
queue_warning_threshold: 0.8,
|
51
|
+
enable_queue_monitoring: true,
|
52
|
+
drain_queue_on_shutdown: true,
|
53
|
+
send_dropped_to_dlq: false,
|
54
|
+
read_from_end: true,
|
55
|
+
poll_interval: 1.0,
|
56
|
+
file_type: :regular,
|
57
|
+
create_fifo: false,
|
58
|
+
fifo_mode: :blocking,
|
59
|
+
fifo_permissions: 0644,
|
60
|
+
fallback_transport: nil,
|
61
|
+
enable_subscriptions: false,
|
62
|
+
subscription_mode: :polling,
|
63
|
+
filename_selector: nil,
|
64
|
+
directory: nil,
|
65
|
+
subscription_file_path: nil
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
def configure
|
70
|
+
# Call parent configuration first
|
71
|
+
super if defined?(super)
|
72
|
+
|
73
|
+
# Then configure our file-specific features
|
74
|
+
if @options[:async]
|
75
|
+
configure_async_publishing
|
76
|
+
elsif @options[:file_type] == :fifo
|
77
|
+
configure_fifo
|
78
|
+
else
|
79
|
+
configure_file_output
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def publish(message)
|
84
|
+
# Extract message class and serialize the message
|
85
|
+
message_class = message._sm_header.message_class
|
86
|
+
serialized_message = encode_message(message)
|
87
|
+
do_publish(message_class, serialized_message)
|
88
|
+
end
|
89
|
+
|
90
|
+
def do_publish(message_class, serialized_message)
|
91
|
+
@current_message_class = message_class
|
92
|
+
if @options[:async]
|
93
|
+
async_publish(message_class, serialized_message)
|
94
|
+
else
|
95
|
+
if @options[:filename_selector] || @options[:directory]
|
96
|
+
header = { message_class_name: message_class.to_s }
|
97
|
+
path = determine_file_path(serialized_message, header)
|
98
|
+
@file_handle = get_or_open_partition_handle(path)
|
99
|
+
write_to_file(serialized_message)
|
100
|
+
elsif @options[:file_type] == :fifo
|
101
|
+
write_to_fifo(serialized_message)
|
102
|
+
else
|
103
|
+
write_to_file(serialized_message)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def subscribe(message_class, process_method, filter_options = {})
|
109
|
+
unless @options[:enable_subscriptions]
|
110
|
+
logger.warn { "[FileTransport] Subscriptions disabled - set enable_subscriptions: true" }
|
111
|
+
return
|
112
|
+
end
|
113
|
+
|
114
|
+
if @options[:file_type] == :fifo
|
115
|
+
start_fifo_reader(message_class, process_method, filter_options)
|
116
|
+
else
|
117
|
+
start_file_polling(message_class, process_method, filter_options)
|
118
|
+
end
|
119
|
+
|
120
|
+
super(message_class, process_method, filter_options)
|
121
|
+
end
|
122
|
+
|
123
|
+
def connected?
|
124
|
+
case @options[:file_type]
|
125
|
+
when :fifo
|
126
|
+
subscription_active? || fifo_active?
|
127
|
+
else
|
128
|
+
(@file_handle && !@file_handle.closed?) || subscription_active?
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def disconnect
|
133
|
+
stop_file_subscriptions
|
134
|
+
stop_fifo_operations if @options[:file_type] == :fifo
|
135
|
+
stop_async_publishing if @options[:async]
|
136
|
+
close_partition_handles if @options[:filename_selector] || @options[:directory]
|
137
|
+
close_file_handle
|
138
|
+
end
|
139
|
+
|
140
|
+
private
|
141
|
+
|
142
|
+
def subscription_active?
|
143
|
+
@polling_thread&.alive? || fifo_active?
|
144
|
+
end
|
145
|
+
|
146
|
+
def stop_file_subscriptions
|
147
|
+
@polling_thread&.kill
|
148
|
+
@polling_thread&.join(5)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# lib/smart_message/transport/file_watching.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
module SmartMessage
|
6
|
+
module Transport
|
7
|
+
# Module for file watching and subscription support (reading/tailing).
|
8
|
+
module FileWatching
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def logger
|
13
|
+
# Ensure we have a proper logger, not just an IO object
|
14
|
+
if @logger && @logger.respond_to?(:error) && @logger.respond_to?(:info) && @logger.respond_to?(:warn)
|
15
|
+
return @logger
|
16
|
+
end
|
17
|
+
@logger = SmartMessage::Logger.default
|
18
|
+
end
|
19
|
+
|
20
|
+
public
|
21
|
+
def start_file_polling(message_class, process_method, filter_options)
|
22
|
+
poll_interval = filter_options[:poll_interval] || @options[:poll_interval] || 1.0
|
23
|
+
file_path = subscription_file_path(message_class, filter_options)
|
24
|
+
|
25
|
+
@polling_thread = Thread.new do
|
26
|
+
Thread.current.name = "FileTransport-Poller"
|
27
|
+
last_position = @options[:read_from_end] ? (File.exist?(file_path) ? File.size(file_path) : 0) : 0
|
28
|
+
|
29
|
+
loop do
|
30
|
+
sleep poll_interval
|
31
|
+
next unless File.exist?(file_path)
|
32
|
+
|
33
|
+
current_size = File.size(file_path)
|
34
|
+
if current_size > last_position
|
35
|
+
process_new_file_content(file_path, last_position, current_size, message_class)
|
36
|
+
last_position = current_size
|
37
|
+
end
|
38
|
+
end
|
39
|
+
rescue => e
|
40
|
+
logger.error { "[FileTransport] Polling thread error: #{e.message}" }
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def process_new_file_content(file_path, start_pos, end_pos, message_class)
|
45
|
+
File.open(file_path, 'r', encoding: @options[:encoding]) do |file|
|
46
|
+
file.seek(start_pos)
|
47
|
+
content = file.read(end_pos - start_pos)
|
48
|
+
|
49
|
+
content.each_line do |line|
|
50
|
+
next if line.strip.empty?
|
51
|
+
|
52
|
+
begin
|
53
|
+
receive(message_class, line.strip)
|
54
|
+
rescue => e
|
55
|
+
begin
|
56
|
+
logger.error { "[FileTransport] Error processing message: #{e.message}" }
|
57
|
+
rescue
|
58
|
+
# Fallback if logger is not available
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def subscription_file_path(message_class, filter_options)
|
66
|
+
filter_options[:file_path] ||
|
67
|
+
@options[:subscription_file_path] ||
|
68
|
+
File.join(File.dirname(@options[:file_path]), "#{message_class.to_s.downcase}.jsonl")
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# lib/smart_message/transport/partitioned_files.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
module SmartMessage
|
6
|
+
module Transport
|
7
|
+
# Module for fan-out/partitioned file support.
|
8
|
+
module PartitionedFiles
|
9
|
+
def determine_file_path(payload, header)
|
10
|
+
if @options[:filename_selector]&.respond_to?(:call)
|
11
|
+
@options[:filename_selector].call(payload, header)
|
12
|
+
elsif @options[:directory]
|
13
|
+
File.join(@options[:directory], "#{header[:message_class_name].to_s.downcase}.log")
|
14
|
+
else
|
15
|
+
@options[:file_path]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def get_or_open_partition_handle(full_path)
|
20
|
+
@partition_handles ||= {}
|
21
|
+
@partition_mutexes ||= {}
|
22
|
+
|
23
|
+
unless @partition_handles[full_path]
|
24
|
+
ensure_directory_exists_for(full_path)
|
25
|
+
@partition_handles[full_path] = File.open(full_path, file_mode, encoding: @options[:encoding])
|
26
|
+
@partition_mutexes[full_path] = Mutex.new
|
27
|
+
end
|
28
|
+
|
29
|
+
@partition_handles[full_path]
|
30
|
+
end
|
31
|
+
|
32
|
+
def ensure_directory_exists_for(path)
|
33
|
+
dir = File.dirname(path)
|
34
|
+
FileUtils.mkdir_p(dir) if @options[:create_directories] && !Dir.exist?(dir)
|
35
|
+
end
|
36
|
+
|
37
|
+
def close_partition_handles
|
38
|
+
@partition_handles&.each do |_, handle|
|
39
|
+
handle.close
|
40
|
+
end
|
41
|
+
@partition_handles = {}
|
42
|
+
@partition_mutexes = {}
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -2,89 +2,15 @@
|
|
2
2
|
# encoding: utf-8
|
3
3
|
# frozen_string_literal: true
|
4
4
|
|
5
|
-
|
6
|
-
module Transport
|
7
|
-
# STDOUT transport for testing and development
|
8
|
-
# This transport outputs messages to STDOUT and optionally loops them back
|
9
|
-
class StdoutTransport < Base
|
10
|
-
def default_options
|
11
|
-
{
|
12
|
-
loopback: false,
|
13
|
-
output: $stdout,
|
14
|
-
format: :pretty # :pretty or :json
|
15
|
-
}
|
16
|
-
end
|
17
|
-
|
18
|
-
# Default to JSON for readability in STDOUT
|
19
|
-
def default_serializer
|
20
|
-
SmartMessage::Serializer::Json.new
|
21
|
-
end
|
22
|
-
|
23
|
-
def configure
|
24
|
-
@output = @options[:output].is_a?(String) ? File.open(@options[:output], 'w') : @options[:output]
|
25
|
-
end
|
26
|
-
|
27
|
-
# Enable/disable loopback mode
|
28
|
-
def loopback=(enabled)
|
29
|
-
@options[:loopback] = enabled
|
30
|
-
end
|
31
|
-
|
32
|
-
def loopback?
|
33
|
-
@options[:loopback]
|
34
|
-
end
|
35
|
-
|
36
|
-
# Publish message to STDOUT
|
37
|
-
def do_publish(message_class, serialized_message)
|
38
|
-
logger.debug { "[SmartMessage::StdoutTransport] do_publish called" }
|
39
|
-
logger.debug { "[SmartMessage::StdoutTransport] message_class: #{message_class}" }
|
40
|
-
|
41
|
-
@output.puts format_message(message_class, serialized_message)
|
42
|
-
@output.flush
|
5
|
+
require_relative 'file_transport'
|
43
6
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
logger.error { "[SmartMessage] Error in stdout transport do_publish: #{e.class.name} - #{e.message}" }
|
51
|
-
raise
|
52
|
-
end
|
53
|
-
|
54
|
-
def connected?
|
55
|
-
!@output.closed?
|
56
|
-
end
|
57
|
-
|
58
|
-
def disconnect
|
59
|
-
@output.close if @output.respond_to?(:close) && @output != $stdout && @output != $stderr
|
60
|
-
end
|
61
|
-
|
62
|
-
private
|
63
|
-
|
64
|
-
def format_message(message_class, serialized_message)
|
65
|
-
if @options[:format] == :json
|
66
|
-
# Output as JSON for machine parsing
|
67
|
-
{
|
68
|
-
transport: 'stdout',
|
69
|
-
message_class: message_class,
|
70
|
-
serialized_message: serialized_message,
|
71
|
-
timestamp: Time.now.iso8601
|
72
|
-
}.to_json
|
73
|
-
else
|
74
|
-
# Pretty format for human reading
|
75
|
-
<<~MESSAGE
|
76
|
-
|
77
|
-
===================================================
|
78
|
-
== SmartMessage Published via STDOUT Transport
|
79
|
-
== Message Class: #{message_class}
|
80
|
-
== Serializer: #{@serializer.class.name}
|
81
|
-
== Serialized Message:
|
82
|
-
#{serialized_message}
|
83
|
-
===================================================
|
84
|
-
|
85
|
-
MESSAGE
|
7
|
+
module SmartMessage
|
8
|
+
module Transport
|
9
|
+
class StdoutTransport < FileTransport
|
10
|
+
def initialize(options = {})
|
11
|
+
defaults = { file_path: $stdout, file_mode: 'w', file_type: :regular }
|
12
|
+
super(defaults.merge(options))
|
86
13
|
end
|
87
14
|
end
|
88
15
|
end
|
89
16
|
end
|
90
|
-
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# lib/smart_message/transport/stdout_transport.rb
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
module SmartMessage
|
6
|
+
module Transport
|
7
|
+
# STDOUT transport for testing and development
|
8
|
+
# This is a publish-only transport that outputs messages to STDOUT
|
9
|
+
class StdoutTransport < Base
|
10
|
+
def default_options
|
11
|
+
{
|
12
|
+
output: $stdout,
|
13
|
+
format: :pretty # :pretty or :json
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
# Default to JSON for readability in STDOUT
|
18
|
+
def default_serializer
|
19
|
+
SmartMessage::Serializer::Json.new
|
20
|
+
end
|
21
|
+
|
22
|
+
def configure
|
23
|
+
@output = @options[:output].is_a?(String) ? File.open(@options[:output], 'w') : @options[:output]
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
# Publish message to STDOUT
|
28
|
+
def do_publish(message_class, serialized_message)
|
29
|
+
logger.debug { "[SmartMessage::StdoutTransport] do_publish called" }
|
30
|
+
logger.debug { "[SmartMessage::StdoutTransport] message_class: #{message_class}" }
|
31
|
+
|
32
|
+
@output.puts format_message(message_class, serialized_message)
|
33
|
+
@output.flush
|
34
|
+
rescue => e
|
35
|
+
logger.error { "[SmartMessage] Error in stdout transport do_publish: #{e.class.name} - #{e.message}" }
|
36
|
+
raise
|
37
|
+
end
|
38
|
+
|
39
|
+
def connected?
|
40
|
+
!@output.closed?
|
41
|
+
end
|
42
|
+
|
43
|
+
def disconnect
|
44
|
+
@output.close if @output.respond_to?(:close) && @output != $stdout && @output != $stderr
|
45
|
+
end
|
46
|
+
|
47
|
+
# Override subscribe methods to log warnings since this is a publish-only transport
|
48
|
+
def subscribe(message_class, process_method, filter_options = {})
|
49
|
+
logger.warn { "[SmartMessage::StdoutTransport] Subscription attempt ignored - STDOUT transport is publish-only (message_class: #{message_class}, process_method: #{process_method})" }
|
50
|
+
end
|
51
|
+
|
52
|
+
def unsubscribe(message_class, process_method)
|
53
|
+
logger.warn { "[SmartMessage::StdoutTransport] Unsubscribe attempt ignored - STDOUT transport is publish-only (message_class: #{message_class}, process_method: #{process_method})" }
|
54
|
+
end
|
55
|
+
|
56
|
+
def unsubscribe!(message_class)
|
57
|
+
logger.warn { "[SmartMessage::StdoutTransport] Unsubscribe all attempt ignored - STDOUT transport is publish-only (message_class: #{message_class})" }
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def format_message(message_class, serialized_message)
|
63
|
+
if @options[:format] == :json
|
64
|
+
# Output as JSON for machine parsing
|
65
|
+
{
|
66
|
+
transport: 'stdout',
|
67
|
+
message_class: message_class,
|
68
|
+
serialized_message: serialized_message,
|
69
|
+
timestamp: Time.now.iso8601
|
70
|
+
}.to_json
|
71
|
+
else
|
72
|
+
# Pretty format for human reading
|
73
|
+
<<~MESSAGE
|
74
|
+
|
75
|
+
===================================================
|
76
|
+
== SmartMessage Published via STDOUT Transport
|
77
|
+
== Message Class: #{message_class}
|
78
|
+
== Serializer: #{@serializer.class.name}
|
79
|
+
== Serialized Message:
|
80
|
+
#{serialized_message}
|
81
|
+
===================================================
|
82
|
+
|
83
|
+
MESSAGE
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|