omq-cli 0.1.0

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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ # Handles encoding/decoding messages in the configured format,
6
+ # plus optional Zstandard compression.
7
+ class Formatter
8
+ def initialize(format, compress: false)
9
+ @format = format
10
+ @compress = compress
11
+ end
12
+
13
+
14
+ def encode(parts)
15
+ case @format
16
+ when :ascii
17
+ parts.map { |p| p.b.gsub(/[^[:print:]\t]/, ".") }.join("\t") + "\n"
18
+ when :quoted
19
+ parts.map { |p| p.b.dump[1..-2] }.join("\t") + "\n"
20
+ when :raw
21
+ parts.each_with_index.map do |p, i|
22
+ ZMTP::Codec::Frame.new(p.to_s, more: i < parts.size - 1).to_wire
23
+ end.join
24
+ when :jsonl
25
+ JSON.generate(parts) + "\n"
26
+ when :msgpack
27
+ MessagePack.pack(parts)
28
+ when :marshal
29
+ parts.map(&:inspect).join("\t") + "\n"
30
+ end
31
+ end
32
+
33
+
34
+ def decode(line)
35
+ case @format
36
+ when :ascii, :marshal
37
+ line.chomp.split("\t")
38
+ when :quoted
39
+ line.chomp.split("\t").map { |p| "\"#{p}\"".undump }
40
+ when :raw
41
+ [line]
42
+ when :jsonl
43
+ arr = JSON.parse(line.chomp)
44
+ abort "JSON Lines input must be an array of strings" unless arr.is_a?(Array) && arr.all? { |e| e.is_a?(String) }
45
+ arr
46
+ end
47
+ end
48
+
49
+
50
+ def decode_marshal(io)
51
+ Marshal.load(io)
52
+ rescue EOFError, TypeError
53
+ nil
54
+ end
55
+
56
+
57
+ def decode_msgpack(io)
58
+ @msgpack_unpacker ||= MessagePack::Unpacker.new(io)
59
+ @msgpack_unpacker.read
60
+ rescue EOFError
61
+ nil
62
+ end
63
+
64
+
65
+ def compress(parts)
66
+ @compress ? parts.map { |p| Zstd.compress(p) } : parts
67
+ end
68
+
69
+
70
+ def decompress(parts)
71
+ @compress ? parts.map { |p| Zstd.decompress(p) } : parts
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class PairRunner < BaseRunner
6
+ private
7
+
8
+
9
+ def run_loop(task)
10
+ receiver = task.async do
11
+ n = config.count
12
+ i = 0
13
+ loop do
14
+ parts = recv_msg
15
+ break if parts.nil?
16
+ parts = eval_recv_expr(parts)
17
+ output(parts)
18
+ i += 1
19
+ break if n && n > 0 && i >= n
20
+ end
21
+ end
22
+
23
+ sender = task.async do
24
+ run_send_logic
25
+ end
26
+
27
+ wait_for_loops(receiver, sender)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class PeerRunner < ServerRunner
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class PipeRunner
6
+ attr_reader :config
7
+
8
+
9
+ def initialize(config)
10
+ @config = config
11
+ @fmt = Formatter.new(config.format, compress: config.compress)
12
+ end
13
+
14
+
15
+ def call(task)
16
+ if config.parallel
17
+ run_parallel
18
+ else
19
+ run_sequential(task)
20
+ end
21
+ end
22
+
23
+
24
+ private
25
+
26
+
27
+ def resolve_endpoints
28
+ if config.in_endpoints.any?
29
+ [config.in_endpoints, config.out_endpoints]
30
+ else
31
+ [[config.endpoints[0]], [config.endpoints[1]]]
32
+ end
33
+ end
34
+
35
+
36
+ def attach_endpoints(sock, endpoints)
37
+ endpoints.each { |ep| ep.bind? ? sock.bind(ep.url) : sock.connect(ep.url) }
38
+ end
39
+
40
+
41
+ def run_sequential(task)
42
+ in_eps, out_eps = resolve_endpoints
43
+
44
+ @pull = OMQ::PULL.new(linger: config.linger, recv_timeout: config.timeout)
45
+ @push = OMQ::PUSH.new(linger: config.linger, send_timeout: config.timeout)
46
+ @pull.reconnect_interval = config.reconnect_ivl if config.reconnect_ivl
47
+ @push.reconnect_interval = config.reconnect_ivl if config.reconnect_ivl
48
+ @pull.heartbeat_interval = config.heartbeat_ivl if config.heartbeat_ivl
49
+ @push.heartbeat_interval = config.heartbeat_ivl if config.heartbeat_ivl
50
+
51
+ attach_endpoints(@pull, in_eps)
52
+ attach_endpoints(@push, out_eps)
53
+
54
+ compile_expr
55
+ @sock = @pull # for eval instance_exec
56
+
57
+ with_timeout(config.timeout) do
58
+ @push.peer_connected.wait
59
+ @pull.peer_connected.wait
60
+ end
61
+
62
+ if config.transient
63
+ task.async do
64
+ @pull.all_peers_gone.wait
65
+ @pull.reconnect_enabled = false
66
+ @pull.close_read
67
+ end
68
+ end
69
+
70
+ @sock.instance_exec(&@recv_begin_proc) if @recv_begin_proc
71
+
72
+ n = config.count
73
+ i = 0
74
+ loop do
75
+ parts = @pull.receive
76
+ break if parts.nil?
77
+ parts = @fmt.decompress(parts)
78
+ parts = eval_recv_expr(parts)
79
+ if parts && !parts.empty?
80
+ @push.send(@fmt.compress(parts))
81
+ end
82
+ i += 1
83
+ break if n && n > 0 && i >= n
84
+ end
85
+
86
+ @sock.instance_exec(&@recv_end_proc) if @recv_end_proc
87
+ ensure
88
+ @pull&.close
89
+ @push&.close
90
+ end
91
+
92
+
93
+ def run_parallel
94
+ workers = config.parallel.times.map do
95
+ Ractor.new(config) do |cfg|
96
+ $VERBOSE = nil
97
+ Console.logger = Console::Logger.new(Console::Output::Null.new)
98
+
99
+ Sync do |task|
100
+ # Parse BEGIN/END blocks and per-message expression
101
+ begin_proc = end_proc = eval_proc = nil
102
+ if cfg.recv_expr
103
+ extract = ->(src, kw) {
104
+ s = src.index(/#{kw}\s*\{/)
105
+ return [src, nil] unless s
106
+ i = src.index("{", s); d = 1; j = i + 1
107
+ while j < src.length && d > 0
108
+ d += 1 if src[j] == "{"; d -= 1 if src[j] == "}"
109
+ j += 1
110
+ end
111
+ [src[0...s] + src[j..], src[(i + 1)..(j - 2)]]
112
+ }
113
+ expr, begin_body = extract.(cfg.recv_expr, "BEGIN")
114
+ expr, end_body = extract.(expr, "END")
115
+ begin_proc = eval("proc { #{begin_body} }") if begin_body
116
+ end_proc = eval("proc { #{end_body} }") if end_body
117
+ if expr && !expr.strip.empty?
118
+ ractor_expr = expr.gsub(/\$F\b/, "__F")
119
+ eval_proc = eval("proc { |__F| $_ = __F&.first; #{ractor_expr} }")
120
+ end
121
+ end
122
+
123
+ formatter = OMQ::CLI::Formatter.new(cfg.format, compress: cfg.compress)
124
+
125
+ pull = OMQ::PULL.new(linger: cfg.linger, recv_timeout: cfg.timeout)
126
+ push = OMQ::PUSH.new(linger: cfg.linger, send_timeout: cfg.timeout)
127
+ pull.reconnect_interval = cfg.reconnect_ivl if cfg.reconnect_ivl
128
+ push.reconnect_interval = cfg.reconnect_ivl if cfg.reconnect_ivl
129
+ pull.heartbeat_interval = cfg.heartbeat_ivl if cfg.heartbeat_ivl
130
+ push.heartbeat_interval = cfg.heartbeat_ivl if cfg.heartbeat_ivl
131
+ in_eps = cfg.in_endpoints.any? ? cfg.in_endpoints : [cfg.endpoints[0]]
132
+ out_eps = cfg.out_endpoints.any? ? cfg.out_endpoints : [cfg.endpoints[1]]
133
+ in_eps.each { |ep| pull.connect(ep.url) }
134
+ out_eps.each { |ep| push.connect(ep.url) }
135
+
136
+ if cfg.timeout
137
+ task.with_timeout(cfg.timeout) do
138
+ push.peer_connected.wait
139
+ pull.peer_connected.wait
140
+ end
141
+ else
142
+ push.peer_connected.wait
143
+ pull.peer_connected.wait
144
+ end
145
+
146
+ if cfg.transient
147
+ task.async do
148
+ pull.all_peers_gone.wait
149
+ pull.reconnect_enabled = false
150
+ pull.close_read
151
+ end
152
+ end
153
+
154
+ begin_proc&.call
155
+
156
+ i = 0
157
+ loop do
158
+ parts = pull.receive
159
+ break if parts.nil?
160
+ parts = formatter.decompress(parts)
161
+ if eval_proc
162
+ result = eval_proc.call(parts)
163
+ parts = case result
164
+ when nil then nil
165
+ when Array then result
166
+ when String then [result]
167
+ else [result.to_s]
168
+ end
169
+ end
170
+ if parts && !parts.empty?
171
+ push.send(formatter.compress(parts))
172
+ end
173
+ i += 1
174
+ break if cfg.count && cfg.count > 0 && i >= cfg.count
175
+ end
176
+
177
+ end_proc&.call
178
+ rescue Async::TimeoutError
179
+ # exit cleanly on timeout
180
+ ensure
181
+ pull&.close
182
+ push&.close
183
+ end
184
+ end
185
+ end
186
+
187
+ workers.each do |w|
188
+ w.value
189
+ rescue Ractor::RemoteError => e
190
+ $stderr.puts "omq: Ractor error: #{e.cause&.message || e.message}"
191
+ end
192
+ end
193
+
194
+
195
+ def with_timeout(seconds)
196
+ if seconds
197
+ Async::Task.current.with_timeout(seconds) { yield }
198
+ else
199
+ yield
200
+ end
201
+ end
202
+
203
+
204
+ def compile_expr
205
+ src = config.recv_expr
206
+ return unless src
207
+ expr, begin_body, end_body = extract_blocks(src)
208
+ @recv_begin_proc = eval("proc { #{begin_body} }") if begin_body
209
+ @recv_end_proc = eval("proc { #{end_body} }") if end_body
210
+ @recv_eval_proc = eval("proc { $_ = $F&.first; #{expr} }") if expr && !expr.strip.empty?
211
+ end
212
+
213
+
214
+ def extract_blocks(expr)
215
+ begin_body = end_body = nil
216
+ expr, begin_body = extract_block(expr, "BEGIN")
217
+ expr, end_body = extract_block(expr, "END")
218
+ [expr, begin_body, end_body]
219
+ end
220
+
221
+
222
+ def extract_block(expr, keyword)
223
+ start = expr.index(/#{keyword}\s*\{/)
224
+ return [expr, nil] unless start
225
+
226
+ i = expr.index("{", start)
227
+ depth = 1
228
+ j = i + 1
229
+ while j < expr.length && depth > 0
230
+ case expr[j]
231
+ when "{" then depth += 1
232
+ when "}" then depth -= 1
233
+ end
234
+ j += 1
235
+ end
236
+
237
+ body = expr[(i + 1)..(j - 2)]
238
+ trimmed = expr[0...start] + expr[j..]
239
+ [trimmed, body]
240
+ end
241
+
242
+
243
+ def eval_recv_expr(parts)
244
+ return parts unless @recv_eval_proc
245
+ $F = parts
246
+ result = @sock.instance_exec(&@recv_eval_proc)
247
+ return nil if result.nil? || result.equal?(@sock)
248
+ return [result] if config.format == :marshal
249
+ case result
250
+ when Array then result
251
+ when String then [result]
252
+ else [result.to_str]
253
+ end
254
+ rescue => e
255
+ $stderr.puts "omq: eval error: #{e.message} (#{e.class})"
256
+ exit 3
257
+ end
258
+
259
+
260
+ def log(msg)
261
+ $stderr.puts(msg) if config.verbose
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class PubRunner < BaseRunner
6
+ def run_loop(task) = run_send_logic
7
+ end
8
+
9
+
10
+ class SubRunner < BaseRunner
11
+ def run_loop(task) = run_recv_logic
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class PushRunner < BaseRunner
6
+ def run_loop(task) = run_send_logic
7
+ end
8
+
9
+
10
+ class PullRunner < BaseRunner
11
+ def run_loop(task) = run_recv_logic
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class RadioRunner < BaseRunner
6
+ def run_loop(task) = run_send_logic
7
+
8
+
9
+ private
10
+
11
+
12
+ def send_msg(parts)
13
+ return if parts.empty?
14
+ parts = [Marshal.dump(parts)] if config.format == :marshal
15
+ parts = @fmt.compress(parts)
16
+ group = config.group || parts.shift
17
+ @sock.publish(group, parts.first || "")
18
+ transient_ready!
19
+ end
20
+ end
21
+
22
+
23
+ class DishRunner < BaseRunner
24
+ def run_loop(task) = run_recv_logic
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class ReqRunner < BaseRunner
6
+ private
7
+
8
+
9
+ def run_loop(task)
10
+ n = config.count
11
+ i = 0
12
+ sleep(config.delay) if config.delay
13
+ if config.interval
14
+ loop do
15
+ parts = read_next
16
+ break unless parts
17
+ parts = eval_send_expr(parts)
18
+ next unless parts
19
+ send_msg(parts)
20
+ reply = recv_msg
21
+ break if reply.nil?
22
+ reply = eval_recv_expr(reply)
23
+ output(reply)
24
+ i += 1
25
+ break if n && n > 0 && i >= n
26
+ interval = config.interval
27
+ wait = interval - (Time.now.to_f % interval)
28
+ sleep(wait) if wait > 0
29
+ end
30
+ else
31
+ loop do
32
+ parts = read_next
33
+ break unless parts
34
+ parts = eval_send_expr(parts)
35
+ next unless parts
36
+ send_msg(parts)
37
+ reply = recv_msg
38
+ break if reply.nil?
39
+ reply = eval_recv_expr(reply)
40
+ output(reply)
41
+ i += 1
42
+ break if n && n > 0 && i >= n
43
+ break if config.data || config.file
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+
50
+ class RepRunner < BaseRunner
51
+ private
52
+
53
+
54
+ def run_loop(task)
55
+ n = config.count
56
+ i = 0
57
+ loop do
58
+ msg = recv_msg
59
+ break if msg.nil?
60
+ if config.recv_expr || @recv_eval_proc
61
+ reply = eval_recv_expr(msg)
62
+ unless reply.equal?(SENT)
63
+ output(reply)
64
+ send_msg(reply || [""])
65
+ end
66
+ elsif config.echo
67
+ output(msg)
68
+ send_msg(msg)
69
+ elsif config.data || config.file || !config.stdin_is_tty
70
+ reply = read_next
71
+ break unless reply
72
+ output(msg)
73
+ send_msg(reply)
74
+ else
75
+ abort "REP needs a reply source: --echo, --data, --file, -e, or stdin pipe"
76
+ end
77
+ i += 1
78
+ break if n && n > 0 && i >= n
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class DealerRunner < PairRunner
6
+ end
7
+
8
+
9
+ class RouterRunner < BaseRunner
10
+ private
11
+
12
+
13
+ def run_loop(task)
14
+ receiver = task.async do
15
+ n = config.count
16
+ i = 0
17
+ loop do
18
+ parts = recv_msg_raw
19
+ break if parts.nil?
20
+ identity = parts.shift
21
+ parts.shift if parts.first == ""
22
+ parts = @fmt.decompress(parts)
23
+ result = eval_recv_expr([display_routing_id(identity), *parts])
24
+ output(result)
25
+ i += 1
26
+ break if n && n > 0 && i >= n
27
+ end
28
+ end
29
+
30
+ sender = task.async do
31
+ n = config.count
32
+ i = 0
33
+ sleep(config.delay) if config.delay
34
+ if config.interval
35
+ Async::Loop.quantized(interval: config.interval) do
36
+ parts = read_next
37
+ break unless parts
38
+ send_targeted_or_eval(parts)
39
+ i += 1
40
+ break if n && n > 0 && i >= n
41
+ end
42
+ elsif config.data || config.file
43
+ parts = read_next
44
+ send_targeted_or_eval(parts) if parts
45
+ else
46
+ loop do
47
+ parts = read_next
48
+ break unless parts
49
+ send_targeted_or_eval(parts)
50
+ i += 1
51
+ break if n && n > 0 && i >= n
52
+ end
53
+ end
54
+ end
55
+
56
+ wait_for_loops(receiver, sender)
57
+ end
58
+
59
+
60
+ def send_targeted_or_eval(parts)
61
+ if @send_eval_proc
62
+ parts = eval_send_expr(parts)
63
+ return unless parts
64
+ identity = resolve_target(parts.shift)
65
+ payload = @fmt.compress(parts)
66
+ @sock.send([identity, "", *payload])
67
+ elsif config.target
68
+ payload = @fmt.compress(parts)
69
+ @sock.send([resolve_target(config.target), "", *payload])
70
+ else
71
+ send_msg(parts)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class ScatterRunner < BaseRunner
6
+ def run_loop(task) = run_send_logic
7
+ end
8
+
9
+
10
+ class GatherRunner < BaseRunner
11
+ def run_loop(task) = run_recv_logic
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ VERSION = "0.1.0"
6
+ end
7
+ end