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.
@@ -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
@@ -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.async do
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