omq-cli 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/README.md +14 -9
- data/lib/omq/cli/base_runner.rb +152 -210
- data/lib/omq/cli/cli_parser.rb +470 -0
- data/lib/omq/cli/client_server.rb +32 -59
- data/lib/omq/cli/config.rb +10 -0
- data/lib/omq/cli/expression_evaluator.rb +156 -0
- data/lib/omq/cli/formatter.rb +27 -1
- data/lib/omq/cli/pair.rb +9 -7
- data/lib/omq/cli/parallel_recv_runner.rb +150 -0
- data/lib/omq/cli/pipe.rb +175 -156
- data/lib/omq/cli/pub_sub.rb +5 -1
- data/lib/omq/cli/push_pull.rb +5 -1
- data/lib/omq/cli/radio_dish.rb +5 -1
- data/lib/omq/cli/req_rep.rb +44 -49
- data/lib/omq/cli/router_dealer.rb +13 -46
- data/lib/omq/cli/routing_helper.rb +95 -0
- data/lib/omq/cli/scatter_gather.rb +5 -1
- data/lib/omq/cli/socket_setup.rb +100 -0
- data/lib/omq/cli/transient_monitor.rb +41 -0
- data/lib/omq/cli/version.rb +1 -1
- data/lib/omq/cli.rb +72 -426
- metadata +94 -6
- data/lib/omq/cli/channel.rb +0 -8
- data/lib/omq/cli/peer.rb +0 -8
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
module CLI
|
|
5
|
+
# Compiles and evaluates a single Ruby expression string for use in
|
|
6
|
+
# --recv-eval / --send-eval. Handles BEGIN{}/END{} block extraction,
|
|
7
|
+
# proc compilation, and result normalisation.
|
|
8
|
+
#
|
|
9
|
+
# One instance per direction (send or recv).
|
|
10
|
+
#
|
|
11
|
+
class ExpressionEvaluator
|
|
12
|
+
attr_reader :begin_proc, :end_proc, :eval_proc
|
|
13
|
+
|
|
14
|
+
# Sentinel: eval proc returned the context object, meaning it already
|
|
15
|
+
# sent the reply itself.
|
|
16
|
+
SENT = Object.new.freeze
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# @param src [String, nil] the raw expression string (may include BEGIN{}/END{})
|
|
20
|
+
# @param format [Symbol] the active format, used to normalise results
|
|
21
|
+
# @param fallback_proc [Proc, nil] registered OMQ.outgoing/incoming handler;
|
|
22
|
+
# used only when +src+ is nil (no inline expression)
|
|
23
|
+
#
|
|
24
|
+
def initialize(src, format:, fallback_proc: nil)
|
|
25
|
+
@format = format
|
|
26
|
+
|
|
27
|
+
if src
|
|
28
|
+
expr, begin_body, end_body = extract_blocks(src)
|
|
29
|
+
@begin_proc = eval("proc { #{begin_body} }") if begin_body # rubocop:disable Security/Eval
|
|
30
|
+
@end_proc = eval("proc { #{end_body} }") if end_body # rubocop:disable Security/Eval
|
|
31
|
+
if expr && !expr.strip.empty?
|
|
32
|
+
@eval_proc = eval("proc { $_ = $F&.first; #{expr} }") # rubocop:disable Security/Eval
|
|
33
|
+
end
|
|
34
|
+
elsif fallback_proc
|
|
35
|
+
@eval_proc = proc { |msg|
|
|
36
|
+
$_ = msg&.first
|
|
37
|
+
fallback_proc.call(msg)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Runs the eval proc against +parts+ using +context+ as self.
|
|
44
|
+
# Returns the normalised result Array, nil (filter/skip), or SENT.
|
|
45
|
+
#
|
|
46
|
+
def call(parts, context)
|
|
47
|
+
return parts unless @eval_proc
|
|
48
|
+
|
|
49
|
+
$F = parts
|
|
50
|
+
result = context.instance_exec(parts, &@eval_proc)
|
|
51
|
+
return nil if result.nil?
|
|
52
|
+
return SENT if result.equal?(context)
|
|
53
|
+
return [result] if @format == :marshal
|
|
54
|
+
|
|
55
|
+
case result
|
|
56
|
+
when Array
|
|
57
|
+
result
|
|
58
|
+
when String
|
|
59
|
+
[result]
|
|
60
|
+
else
|
|
61
|
+
[result.to_str]
|
|
62
|
+
end
|
|
63
|
+
rescue => e
|
|
64
|
+
$stderr.puts "omq: eval error: #{e.message} (#{e.class})"
|
|
65
|
+
exit 3
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Normalises an eval result to nil (skip) or an Array of strings.
|
|
70
|
+
# Used inside Ractor worker blocks where instance methods are unavailable.
|
|
71
|
+
#
|
|
72
|
+
def self.normalize_result(result)
|
|
73
|
+
case result
|
|
74
|
+
when nil
|
|
75
|
+
nil
|
|
76
|
+
when Array
|
|
77
|
+
result
|
|
78
|
+
when String
|
|
79
|
+
[result]
|
|
80
|
+
else
|
|
81
|
+
[result.to_s]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Compiles begin/end/eval procs inside a Ractor from a raw expression
|
|
87
|
+
# string. Returns [begin_proc, end_proc, eval_proc], any may be nil.
|
|
88
|
+
#
|
|
89
|
+
# Must be called inside the Ractor block (Procs are not Ractor-shareable).
|
|
90
|
+
#
|
|
91
|
+
def self.compile_inside_ractor(src)
|
|
92
|
+
return [nil, nil, nil] unless src
|
|
93
|
+
|
|
94
|
+
extract = ->(expr, kw) {
|
|
95
|
+
s = expr.index(/#{kw}\s*\{/)
|
|
96
|
+
return [expr, nil] unless s
|
|
97
|
+
ci = expr.index("{", s)
|
|
98
|
+
depth = 1
|
|
99
|
+
j = ci + 1
|
|
100
|
+
while j < expr.length && depth > 0
|
|
101
|
+
depth += 1 if expr[j] == "{"
|
|
102
|
+
depth -= 1 if expr[j] == "}"
|
|
103
|
+
j += 1
|
|
104
|
+
end
|
|
105
|
+
[expr[0...s] + expr[j..], expr[(ci + 1)..(j - 2)]]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
expr, begin_body = extract.(src, "BEGIN")
|
|
109
|
+
expr, end_body = extract.(expr, "END")
|
|
110
|
+
|
|
111
|
+
begin_proc = eval("proc { #{begin_body} }") if begin_body # rubocop:disable Security/Eval
|
|
112
|
+
end_proc = eval("proc { #{end_body} }") if end_body # rubocop:disable Security/Eval
|
|
113
|
+
eval_proc = nil
|
|
114
|
+
if expr && !expr.strip.empty?
|
|
115
|
+
ractor_expr = expr.gsub(/\$F\b/, "__F")
|
|
116
|
+
eval_proc = eval("proc { |__F| $_ = __F&.first; #{ractor_expr} }") # rubocop:disable Security/Eval
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
[begin_proc, end_proc, eval_proc]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def extract_blocks(expr)
|
|
127
|
+
expr, begin_body = extract_block(expr, "BEGIN")
|
|
128
|
+
expr, end_body = extract_block(expr, "END")
|
|
129
|
+
[expr, begin_body, end_body]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def extract_block(expr, keyword)
|
|
134
|
+
start = expr.index(/#{keyword}\s*\{/)
|
|
135
|
+
return [expr, nil] unless start
|
|
136
|
+
|
|
137
|
+
i = expr.index("{", start)
|
|
138
|
+
depth = 1
|
|
139
|
+
j = i + 1
|
|
140
|
+
while j < expr.length && depth > 0
|
|
141
|
+
case expr[j]
|
|
142
|
+
when "{"
|
|
143
|
+
depth += 1
|
|
144
|
+
when "}"
|
|
145
|
+
depth -= 1
|
|
146
|
+
end
|
|
147
|
+
j += 1
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
body = expr[(i + 1)..(j - 2)]
|
|
151
|
+
trimmed = expr[0...start] + expr[j..]
|
|
152
|
+
[trimmed, body]
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/omq/cli/formatter.rb
CHANGED
|
@@ -5,12 +5,18 @@ module OMQ
|
|
|
5
5
|
# Handles encoding/decoding messages in the configured format,
|
|
6
6
|
# plus optional Zstandard compression.
|
|
7
7
|
class Formatter
|
|
8
|
+
# @param format [Symbol] wire format (:ascii, :quoted, :raw, :jsonl, :msgpack, :marshal)
|
|
9
|
+
# @param compress [Boolean] whether to apply Zstandard compression per frame
|
|
8
10
|
def initialize(format, compress: false)
|
|
9
11
|
@format = format
|
|
10
12
|
@compress = compress
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
|
|
16
|
+
# Encodes message parts into a printable string for output.
|
|
17
|
+
#
|
|
18
|
+
# @param parts [Array<String>] message frames
|
|
19
|
+
# @return [String] formatted output line
|
|
14
20
|
def encode(parts)
|
|
15
21
|
case @format
|
|
16
22
|
when :ascii
|
|
@@ -19,7 +25,7 @@ module OMQ
|
|
|
19
25
|
parts.map { |p| p.b.dump[1..-2] }.join("\t") + "\n"
|
|
20
26
|
when :raw
|
|
21
27
|
parts.each_with_index.map do |p, i|
|
|
22
|
-
ZMTP::Codec::Frame.new(p.to_s, more: i < parts.size - 1).to_wire
|
|
28
|
+
Protocol::ZMTP::Codec::Frame.new(p.to_s, more: i < parts.size - 1).to_wire
|
|
23
29
|
end.join
|
|
24
30
|
when :jsonl
|
|
25
31
|
JSON.generate(parts) + "\n"
|
|
@@ -31,6 +37,10 @@ module OMQ
|
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
|
|
40
|
+
# Decodes a formatted input line into message parts.
|
|
41
|
+
#
|
|
42
|
+
# @param line [String] input line (newline-terminated)
|
|
43
|
+
# @return [Array<String>] message frames
|
|
34
44
|
def decode(line)
|
|
35
45
|
case @format
|
|
36
46
|
when :ascii, :marshal
|
|
@@ -47,6 +57,10 @@ module OMQ
|
|
|
47
57
|
end
|
|
48
58
|
|
|
49
59
|
|
|
60
|
+
# Decodes one Marshal object from the given IO stream.
|
|
61
|
+
#
|
|
62
|
+
# @param io [IO] input stream
|
|
63
|
+
# @return [Object, nil] deserialized object, or nil on EOF
|
|
50
64
|
def decode_marshal(io)
|
|
51
65
|
Marshal.load(io)
|
|
52
66
|
rescue EOFError, TypeError
|
|
@@ -54,6 +68,10 @@ module OMQ
|
|
|
54
68
|
end
|
|
55
69
|
|
|
56
70
|
|
|
71
|
+
# Decodes one MessagePack object from the given IO stream.
|
|
72
|
+
#
|
|
73
|
+
# @param io [IO] input stream
|
|
74
|
+
# @return [Object, nil] deserialized object, or nil on EOF
|
|
57
75
|
def decode_msgpack(io)
|
|
58
76
|
@msgpack_unpacker ||= MessagePack::Unpacker.new(io)
|
|
59
77
|
@msgpack_unpacker.read
|
|
@@ -62,11 +80,19 @@ module OMQ
|
|
|
62
80
|
end
|
|
63
81
|
|
|
64
82
|
|
|
83
|
+
# Compresses each frame with Zstandard if compression is enabled.
|
|
84
|
+
#
|
|
85
|
+
# @param parts [Array<String>] message frames
|
|
86
|
+
# @return [Array<String>] optionally compressed frames
|
|
65
87
|
def compress(parts)
|
|
66
88
|
@compress ? parts.map { |p| Zstd.compress(p) } : parts
|
|
67
89
|
end
|
|
68
90
|
|
|
69
91
|
|
|
92
|
+
# Decompresses each frame with Zstandard if compression is enabled.
|
|
93
|
+
#
|
|
94
|
+
# @param parts [Array<String>] possibly compressed message frames
|
|
95
|
+
# @return [Array<String>] decompressed frames
|
|
70
96
|
def decompress(parts)
|
|
71
97
|
@compress ? parts.map { |p| Zstd.decompress(p) } : parts
|
|
72
98
|
end
|
data/lib/omq/cli/pair.rb
CHANGED
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module OMQ
|
|
4
4
|
module CLI
|
|
5
|
+
# Runner for PAIR, DEALER, and CHANNEL sockets (bidirectional messaging).
|
|
5
6
|
class PairRunner < BaseRunner
|
|
6
7
|
private
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def run_loop(task)
|
|
10
|
-
receiver = task
|
|
11
|
+
receiver = recv_async(task)
|
|
12
|
+
sender = task.async { run_send_logic }
|
|
13
|
+
wait_for_loops(receiver, sender)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def recv_async(task)
|
|
18
|
+
task.async do
|
|
11
19
|
n = config.count
|
|
12
20
|
i = 0
|
|
13
21
|
loop do
|
|
@@ -19,12 +27,6 @@ module OMQ
|
|
|
19
27
|
break if n && n > 0 && i >= n
|
|
20
28
|
end
|
|
21
29
|
end
|
|
22
|
-
|
|
23
|
-
sender = task.async do
|
|
24
|
-
run_send_logic
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
wait_for_loops(receiver, sender)
|
|
28
30
|
end
|
|
29
31
|
end
|
|
30
32
|
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
module CLI
|
|
5
|
+
# Manages N OMQ::Ractor workers for parallel recv-eval.
|
|
6
|
+
#
|
|
7
|
+
# Each worker gets its own input socket connecting to the external
|
|
8
|
+
# endpoints; ZMQ distributes work naturally. Results are collected via
|
|
9
|
+
# an inproc PULL back to the main task for output.
|
|
10
|
+
#
|
|
11
|
+
class ParallelRecvRunner
|
|
12
|
+
# @param klass [Class] OMQ socket class for worker input sockets
|
|
13
|
+
# @param config [Config] frozen CLI configuration
|
|
14
|
+
# @param fmt [Formatter] message formatter
|
|
15
|
+
# @param output_fn [Method] callable for writing output
|
|
16
|
+
def initialize(klass, config, fmt, output_fn)
|
|
17
|
+
@klass = klass
|
|
18
|
+
@config = config
|
|
19
|
+
@fmt = fmt
|
|
20
|
+
@output_fn = output_fn
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Spawns N Ractor workers, distributes incoming messages, and collects results.
|
|
25
|
+
#
|
|
26
|
+
# @param task [Async::Task] parent async task
|
|
27
|
+
# @return [void]
|
|
28
|
+
def run(task)
|
|
29
|
+
cfg = @config
|
|
30
|
+
n_workers = cfg.parallel
|
|
31
|
+
inproc = "inproc://omq-out-#{object_id}"
|
|
32
|
+
|
|
33
|
+
# Pack worker config into a shareable Hash passed via omq.data —
|
|
34
|
+
# Ruby 4.0 forbids Ractor blocks from closing over outer locals.
|
|
35
|
+
worker_data = ::Ractor.make_shareable({
|
|
36
|
+
recv_src: cfg.recv_expr,
|
|
37
|
+
fmt_sym: cfg.format,
|
|
38
|
+
fmt_compr: cfg.compress,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
# Create N input sockets in the main Async context
|
|
42
|
+
input_socks = n_workers.times.map do
|
|
43
|
+
sock = SocketSetup.build(@klass, cfg)
|
|
44
|
+
SocketSetup.setup_subscriptions(sock, cfg)
|
|
45
|
+
cfg.connects.each { |url| sock.connect(url) }
|
|
46
|
+
sock
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
with_timeout(cfg.timeout) { input_socks.each { |s| s.peer_connected.wait } }
|
|
50
|
+
|
|
51
|
+
# Inproc collector: one bound PULL to receive all worker output
|
|
52
|
+
collector = OMQ::PULL.new(linger: cfg.linger)
|
|
53
|
+
collector.recv_timeout = cfg.timeout if cfg.timeout
|
|
54
|
+
collector.bind(inproc)
|
|
55
|
+
|
|
56
|
+
# N output sockets connecting to the collector
|
|
57
|
+
output_socks = n_workers.times.map do
|
|
58
|
+
s = OMQ::PUSH.new(linger: cfg.linger)
|
|
59
|
+
s.connect(inproc)
|
|
60
|
+
s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
workers = n_workers.times.map do |i|
|
|
64
|
+
OMQ::Ractor.new(input_socks[i], output_socks[i], serialize: false, data: worker_data) do |omq|
|
|
65
|
+
pull_p, push_p = omq.sockets
|
|
66
|
+
d = omq.data
|
|
67
|
+
|
|
68
|
+
# Re-compile expression inside Ractor (Procs are not shareable)
|
|
69
|
+
begin_proc, end_proc, eval_proc =
|
|
70
|
+
OMQ::CLI::ExpressionEvaluator.compile_inside_ractor(d[:recv_src])
|
|
71
|
+
|
|
72
|
+
formatter = OMQ::CLI::Formatter.new(d[:fmt_sym], compress: d[:fmt_compr])
|
|
73
|
+
# Use a dedicated context object so @ivar expressions in BEGIN/END/eval
|
|
74
|
+
# work inside Ractors (self in a Ractor is shareable; Object.new is not).
|
|
75
|
+
_ctx = Object.new
|
|
76
|
+
_ctx.instance_exec(&begin_proc) if begin_proc
|
|
77
|
+
|
|
78
|
+
if eval_proc
|
|
79
|
+
loop do
|
|
80
|
+
parts = pull_p.receive
|
|
81
|
+
break if parts.nil?
|
|
82
|
+
parts = OMQ::CLI::ExpressionEvaluator.normalize_result(
|
|
83
|
+
_ctx.instance_exec(formatter.decompress(parts), &eval_proc)
|
|
84
|
+
)
|
|
85
|
+
next if parts.nil?
|
|
86
|
+
push_p << parts unless parts.empty?
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
loop do
|
|
90
|
+
parts = pull_p.receive
|
|
91
|
+
break if parts.nil?
|
|
92
|
+
push_p << formatter.decompress(parts)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if end_proc
|
|
97
|
+
out = OMQ::CLI::ExpressionEvaluator.normalize_result(
|
|
98
|
+
_ctx.instance_exec(&end_proc)
|
|
99
|
+
)
|
|
100
|
+
push_p << out if out && !out.empty?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Collect loop: drain inproc PULL → output
|
|
107
|
+
n_count = cfg.count
|
|
108
|
+
if n_count && n_count > 0
|
|
109
|
+
n_count.times do
|
|
110
|
+
parts = collector.receive
|
|
111
|
+
break if parts.nil?
|
|
112
|
+
@output_fn.call(parts)
|
|
113
|
+
end
|
|
114
|
+
else
|
|
115
|
+
loop do
|
|
116
|
+
parts = collector.receive
|
|
117
|
+
break if parts.nil?
|
|
118
|
+
@output_fn.call(parts)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Inject nil into each worker's input port so it exits its loop
|
|
124
|
+
# without waiting for recv_timeout (workers don't self-terminate
|
|
125
|
+
# when the collect loop exits on count).
|
|
126
|
+
workers.each do |w|
|
|
127
|
+
w.close
|
|
128
|
+
rescue Ractor::RemoteError => e
|
|
129
|
+
$stderr.puts "omq: Ractor error: #{e.cause&.message || e.message}"
|
|
130
|
+
end
|
|
131
|
+
ensure
|
|
132
|
+
input_socks&.each(&:close)
|
|
133
|
+
output_socks&.each(&:close)
|
|
134
|
+
collector&.close
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def with_timeout(seconds)
|
|
142
|
+
if seconds
|
|
143
|
+
Async::Task.current.with_timeout(seconds) { yield }
|
|
144
|
+
else
|
|
145
|
+
yield
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|