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.
data/lib/omq/cli/pipe.rb CHANGED
@@ -2,19 +2,27 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
+ # Runner for the virtual "pipe" socket type (PULL -> eval -> PUSH).
6
+ # Supports sequential and parallel (Ractor-based) processing modes.
5
7
  class PipeRunner
8
+ # @return [Config] frozen CLI configuration
6
9
  attr_reader :config
7
10
 
8
11
 
12
+ # @param config [Config] frozen CLI configuration
9
13
  def initialize(config)
10
14
  @config = config
11
15
  @fmt = Formatter.new(config.format, compress: config.compress)
12
16
  end
13
17
 
14
18
 
19
+ # Runs the pipe in sequential or parallel mode based on config.
20
+ #
21
+ # @param task [Async::Task] the parent async task
22
+ # @return [void]
15
23
  def call(task)
16
24
  if config.parallel
17
- run_parallel
25
+ run_parallel(task)
18
26
  else
19
27
  run_sequential(task)
20
28
  end
@@ -34,41 +42,64 @@ module OMQ
34
42
 
35
43
 
36
44
  def attach_endpoints(sock, endpoints)
37
- endpoints.each { |ep| ep.bind? ? sock.bind(ep.url) : sock.connect(ep.url) }
45
+ SocketSetup.attach_endpoints(sock, endpoints)
38
46
  end
39
47
 
40
48
 
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
49
+ # ── Sequential ───────────────────────────────────────────────────
50
50
 
51
- attach_endpoints(@pull, in_eps)
52
- attach_endpoints(@push, out_eps)
53
51
 
52
+ def run_sequential(task)
53
+ in_eps, out_eps = resolve_endpoints
54
+ @pull, @push = build_pull_push(
55
+ { linger: config.linger, recv_timeout: config.timeout },
56
+ { linger: config.linger, send_timeout: config.timeout },
57
+ in_eps, out_eps
58
+ )
54
59
  compile_expr
55
60
  @sock = @pull # for eval instance_exec
56
-
57
61
  with_timeout(config.timeout) do
58
62
  @push.peer_connected.wait
59
63
  @pull.peer_connected.wait
60
64
  end
65
+ setup_sequential_transient(task)
66
+ @sock.instance_exec(&@recv_begin_proc) if @recv_begin_proc
67
+ sequential_message_loop
68
+ @sock.instance_exec(&@recv_end_proc) if @recv_end_proc
69
+ ensure
70
+ @pull&.close
71
+ @push&.close
72
+ end
61
73
 
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
74
+
75
+ def apply_socket_intervals(sock)
76
+ sock.reconnect_interval = config.reconnect_ivl if config.reconnect_ivl
77
+ sock.heartbeat_interval = config.heartbeat_ivl if config.heartbeat_ivl
78
+ end
79
+
80
+
81
+ def build_pull_push(pull_opts, push_opts, in_eps, out_eps)
82
+ pull = OMQ::PULL.new(**pull_opts)
83
+ push = OMQ::PUSH.new(**push_opts)
84
+ apply_socket_intervals(pull)
85
+ apply_socket_intervals(push)
86
+ attach_endpoints(pull, in_eps)
87
+ attach_endpoints(push, out_eps)
88
+ [pull, push]
89
+ end
90
+
91
+
92
+ def setup_sequential_transient(task)
93
+ return unless config.transient
94
+ task.async do
95
+ @pull.all_peers_gone.wait
96
+ @pull.reconnect_enabled = false
97
+ @pull.close_read
68
98
  end
99
+ end
69
100
 
70
- @sock.instance_exec(&@recv_begin_proc) if @recv_begin_proc
71
101
 
102
+ def sequential_message_loop
72
103
  n = config.count
73
104
  i = 0
74
105
  loop do
@@ -76,114 +107,141 @@ module OMQ
76
107
  break if parts.nil?
77
108
  parts = @fmt.decompress(parts)
78
109
  parts = eval_recv_expr(parts)
79
- if parts && !parts.empty?
80
- @push.send(@fmt.compress(parts))
81
- end
110
+ @push.send(@fmt.compress(parts)) if parts && !parts.empty?
82
111
  i += 1
83
112
  break if n && n > 0 && i >= n
84
113
  end
114
+ end
85
115
 
86
- @sock.instance_exec(&@recv_end_proc) if @recv_end_proc
116
+
117
+ # ── Parallel ─────────────────────────────────────────────────────
118
+
119
+
120
+ def run_parallel(task)
121
+ in_eps, out_eps = resolve_endpoints
122
+ pairs = build_socket_pairs(config.parallel, in_eps, out_eps)
123
+ wait_for_pairs(pairs)
124
+ setup_parallel_transient(task, pairs)
125
+ workers = spawn_workers(pairs, build_worker_data)
126
+ join_workers(workers)
87
127
  ensure
88
- @pull&.close
89
- @push&.close
128
+ pairs&.each do |pull, push|
129
+ pull&.close
130
+ push&.close
131
+ end
90
132
  end
91
133
 
92
134
 
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
135
+ def build_socket_pairs(n_workers, in_eps, out_eps)
136
+ pull_opts = { linger: config.linger }
137
+ push_opts = { linger: config.linger }
138
+ pull_opts[:recv_timeout] = config.timeout if config.timeout
139
+ push_opts[:send_timeout] = config.timeout if config.timeout
140
+ n_workers.times.map { build_pull_push(pull_opts, push_opts, in_eps, out_eps) }
141
+ end
122
142
 
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
143
+
144
+ def wait_for_pairs(pairs)
145
+ with_timeout(config.timeout) do
146
+ pairs.each do |pull, push|
147
+ push.peer_connected.wait
148
+ pull.peer_connected.wait
149
+ end
150
+ end
151
+ end
152
+
153
+
154
+ def setup_parallel_transient(task, pairs)
155
+ return unless config.transient
156
+ task.async do
157
+ pairs[0][0].all_peers_gone.wait
158
+ pairs.each do |pull, _|
159
+ pull.reconnect_enabled = false
160
+ pull.close_read
161
+ end
162
+ end
163
+ end
164
+
165
+
166
+ def build_worker_data
167
+ # Pack worker config into a shareable Hash passed via omq.data —
168
+ # Ruby 4.0 forbids Ractor blocks from closing over outer locals.
169
+ ::Ractor.make_shareable({
170
+ recv_src: config.recv_expr,
171
+ fmt_format: config.format,
172
+ fmt_compr: config.compress,
173
+ n_count: config.count,
174
+ })
175
+ end
176
+
177
+
178
+ def spawn_workers(pairs, worker_data)
179
+ pairs.map do |pull, push|
180
+ OMQ::Ractor.new(pull, push, serialize: false, data: worker_data) do |omq|
181
+ pull_p, push_p = omq.sockets
182
+ d = omq.data
183
+
184
+ # Re-compile expression inside Ractor (Procs are not shareable)
185
+ begin_proc, end_proc, eval_proc =
186
+ OMQ::CLI::ExpressionEvaluator.compile_inside_ractor(d[:recv_src])
187
+
188
+ formatter = OMQ::CLI::Formatter.new(d[:fmt_format], compress: d[:fmt_compr])
189
+ # Use a dedicated context object so @ivar expressions in BEGIN/END/eval
190
+ # work inside Ractors (self in a Ractor is shareable; Object.new is not).
191
+ _ctx = Object.new
192
+ _ctx.instance_exec(&begin_proc) if begin_proc
193
+
194
+ n_count = d[:n_count]
195
+ if eval_proc
196
+ if n_count && n_count > 0
197
+ n_count.times do
198
+ parts = pull_p.receive
199
+ break if parts.nil?
200
+ parts = OMQ::CLI::ExpressionEvaluator.normalize_result(
201
+ _ctx.instance_exec(formatter.decompress(parts), &eval_proc)
202
+ )
203
+ next if parts.nil?
204
+ push_p << formatter.compress(parts) unless parts.empty?
140
205
  end
141
206
  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
207
+ loop do
208
+ parts = pull_p.receive
209
+ break if parts.nil?
210
+ parts = OMQ::CLI::ExpressionEvaluator.normalize_result(
211
+ _ctx.instance_exec(formatter.decompress(parts), &eval_proc)
212
+ )
213
+ next if parts.nil?
214
+ push_p << formatter.compress(parts) unless parts.empty?
151
215
  end
152
216
  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
217
+ else
218
+ if n_count && n_count > 0
219
+ n_count.times do
220
+ parts = pull_p.receive
221
+ break if parts.nil?
222
+ push_p << formatter.compress(formatter.decompress(parts))
169
223
  end
170
- if parts && !parts.empty?
171
- push.send(formatter.compress(parts))
224
+ else
225
+ loop do
226
+ parts = pull_p.receive
227
+ break if parts.nil?
228
+ push_p << formatter.compress(formatter.decompress(parts))
172
229
  end
173
- i += 1
174
- break if cfg.count && cfg.count > 0 && i >= cfg.count
175
230
  end
231
+ end
176
232
 
177
- end_proc&.call
178
- rescue Async::TimeoutError
179
- # exit cleanly on timeout
180
- ensure
181
- pull&.close
182
- push&.close
233
+ if end_proc
234
+ out = OMQ::CLI::ExpressionEvaluator.normalize_result(
235
+ _ctx.instance_exec(&end_proc)
236
+ )
237
+ push_p << formatter.compress(out) if out && !out.empty?
183
238
  end
184
239
  end
185
240
  end
241
+ end
186
242
 
243
+
244
+ def join_workers(workers)
187
245
  workers.each do |w|
188
246
  w.value
189
247
  rescue Ractor::RemoteError => e
@@ -192,6 +250,9 @@ module OMQ
192
250
  end
193
251
 
194
252
 
253
+ # ── Shared helpers ────────────────────────────────────────────────
254
+
255
+
195
256
  def with_timeout(seconds)
196
257
  if seconds
197
258
  Async::Task.current.with_timeout(seconds) { yield }
@@ -202,58 +263,16 @@ module OMQ
202
263
 
203
264
 
204
265
  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]
266
+ @recv_evaluator = ExpressionEvaluator.new(config.recv_expr, format: config.format)
267
+ @recv_begin_proc = @recv_evaluator.begin_proc
268
+ @recv_eval_proc = @recv_evaluator.eval_proc
269
+ @recv_end_proc = @recv_evaluator.end_proc
240
270
  end
241
271
 
242
272
 
243
273
  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
274
+ result = @recv_evaluator.call(parts, @sock)
275
+ result.equal?(ExpressionEvaluator::SENT) ? nil : result
257
276
  end
258
277
 
259
278
 
@@ -2,13 +2,17 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
+ # Runner for PUB sockets (publish messages to subscribers).
5
6
  class PubRunner < BaseRunner
6
7
  def run_loop(task) = run_send_logic
7
8
  end
8
9
 
9
10
 
11
+ # Runner for SUB sockets (subscribe and receive published messages).
10
12
  class SubRunner < BaseRunner
11
- def run_loop(task) = run_recv_logic
13
+ def run_loop(task)
14
+ config.parallel ? run_parallel_recv(task) : run_recv_logic
15
+ end
12
16
  end
13
17
  end
14
18
  end
@@ -2,13 +2,17 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
+ # Runner for PUSH sockets (send-only pipeline producer).
5
6
  class PushRunner < BaseRunner
6
7
  def run_loop(task) = run_send_logic
7
8
  end
8
9
 
9
10
 
11
+ # Runner for PULL sockets (receive-only pipeline consumer).
10
12
  class PullRunner < BaseRunner
11
- def run_loop(task) = run_recv_logic
13
+ def run_loop(task)
14
+ config.parallel ? run_parallel_recv(task) : run_recv_logic
15
+ end
12
16
  end
13
17
  end
14
18
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
+ # Runner for RADIO sockets (draft; group-based publish).
5
6
  class RadioRunner < BaseRunner
6
7
  def run_loop(task) = run_send_logic
7
8
 
@@ -20,8 +21,11 @@ module OMQ
20
21
  end
21
22
 
22
23
 
24
+ # Runner for DISH sockets (draft; group-based subscribe).
23
25
  class DishRunner < BaseRunner
24
- def run_loop(task) = run_recv_logic
26
+ def run_loop(task)
27
+ config.parallel ? run_parallel_recv(task) : run_recv_logic
28
+ end
25
29
  end
26
30
  end
27
31
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
+ # Runner for REQ sockets (synchronous request-reply client).
5
6
  class ReqRunner < BaseRunner
6
7
  private
7
8
 
@@ -10,43 +11,31 @@ module OMQ
10
11
  n = config.count
11
12
  i = 0
12
13
  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
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
+ output(eval_recv_expr(reply))
23
+ i += 1
24
+ break if n && n > 0 && i >= n
25
+ break if !config.interval && (config.data || config.file)
26
+ wait_for_interval if config.interval
45
27
  end
46
28
  end
29
+
30
+
31
+ def wait_for_interval
32
+ wait = config.interval - (Time.now.to_f % config.interval)
33
+ sleep(wait) if wait > 0
34
+ end
47
35
  end
48
36
 
49
37
 
38
+ # Runner for REP sockets (synchronous request-reply server).
50
39
  class RepRunner < BaseRunner
51
40
  private
52
41
 
@@ -57,27 +46,33 @@ module OMQ
57
46
  loop do
58
47
  msg = recv_msg
59
48
  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
49
+ break unless handle_rep_request(msg)
77
50
  i += 1
78
51
  break if n && n > 0 && i >= n
79
52
  end
80
53
  end
54
+
55
+
56
+ def handle_rep_request(msg)
57
+ if config.recv_expr || @recv_eval_proc
58
+ reply = eval_recv_expr(msg)
59
+ unless reply.equal?(SENT)
60
+ output(reply)
61
+ send_msg(reply || [""])
62
+ end
63
+ elsif config.echo
64
+ output(msg)
65
+ send_msg(msg)
66
+ elsif config.data || config.file || !config.stdin_is_tty
67
+ reply = read_next
68
+ return false unless reply
69
+ output(msg)
70
+ send_msg(reply)
71
+ else
72
+ abort "REP needs a reply source: --echo, --data, --file, -e, or stdin pipe"
73
+ end
74
+ true
75
+ end
81
76
  end
82
77
  end
83
78
  end
@@ -2,16 +2,22 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
- class DealerRunner < PairRunner
6
- end
7
-
8
-
5
+ # Runner for ROUTER sockets (identity-aware async routing).
9
6
  class RouterRunner < BaseRunner
7
+ include RoutingHelper
8
+
10
9
  private
11
10
 
12
11
 
13
12
  def run_loop(task)
14
- receiver = task.async do
13
+ receiver = recv_async(task)
14
+ sender = async_send_loop(task)
15
+ wait_for_loops(receiver, sender)
16
+ end
17
+
18
+
19
+ def recv_async(task)
20
+ task.async do
15
21
  n = config.count
16
22
  i = 0
17
23
  loop do
@@ -26,50 +32,11 @@ module OMQ
26
32
  break if n && n > 0 && i >= n
27
33
  end
28
34
  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
35
  end
58
36
 
59
37
 
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
38
+ def send_to_peer(id, parts)
39
+ @sock.send([id, "", *@fmt.compress(parts)])
73
40
  end
74
41
  end
75
42
  end