omq 0.21.0 → 0.22.1
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 +62 -0
- data/lib/omq/engine/heartbeat.rb +1 -1
- data/lib/omq/routing/conn_send_pump.rb +8 -3
- data/lib/omq/routing/fan_out.rb +16 -12
- data/lib/omq/routing/pair.rb +4 -2
- data/lib/omq/routing/rep.rb +8 -4
- data/lib/omq/routing/req.rb +1 -1
- data/lib/omq/routing/round_robin.rb +6 -3
- data/lib/omq/routing/sub.rb +27 -7
- data/lib/omq/routing/xsub.rb +12 -2
- data/lib/omq/routing.rb +5 -2
- data/lib/omq/transport/inproc/direct_pipe.rb +23 -8
- data/lib/omq/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f58b9e0c8c49bcdfb8dd80ec5eda6a6b6e5b09f04a2a7851001449449ba448fd
|
|
4
|
+
data.tar.gz: 0f6cbd451adc8b1d1cde0771d6535c5959f09e4b3f0442164b0544964755795e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d7d13f97eeacf998f1e0137bdbc5dd4a471195ff628fd7da97720a019e30c2a9c200fab761c38943bbbada056bf81880e6ca2dd87d3936f150f0fa22566cebe5
|
|
7
|
+
data.tar.gz: f97931ecb4893ecd0155c37ea18793c9414bd57de6918496ef7af18bad714720aa2e146531788956265d6f85bc3e18f6dd403f71df8683913d322c94a6dd1bdb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.1 — 2026-04-16
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **Reuse batch arrays in send pumps.** All send pumps (RoundRobin,
|
|
8
|
+
Pair, ConnSendPump, FanOut, FanOut-conflate) now pre-allocate a
|
|
9
|
+
single batch array and clear it between cycles instead of
|
|
10
|
+
allocating a fresh `[msg]` per dequeue.
|
|
11
|
+
|
|
12
|
+
- **`Routing.dequeue_batch`** consolidates the blocking-dequeue +
|
|
13
|
+
non-blocking-sweep pattern that was duplicated across four call
|
|
14
|
+
sites into one method. `dequeue_batch_capped` does the same for
|
|
15
|
+
the byte/message-capped RoundRobin variant.
|
|
16
|
+
|
|
17
|
+
- **REP envelope stored as `[conn, envelope]`** instead of a Hash,
|
|
18
|
+
and reply assembly uses `<<` + `concat` instead of double splat.
|
|
19
|
+
|
|
20
|
+
- **Heartbeat drops redundant `context: "".b`** — the default is
|
|
21
|
+
now `EMPTY_BINARY` in protocol-zmtp.
|
|
22
|
+
|
|
23
|
+
- **Bench harness accepts `OMQ_BENCH_SIZES`, `OMQ_BENCH_TRANSPORTS`,
|
|
24
|
+
and `OMQ_BENCH_PEERS`** env vars to scope runs without editing
|
|
25
|
+
code.
|
|
26
|
+
|
|
27
|
+
## 0.22.0 — 2026-04-15
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **PUB/SUB interop with ZMTP 3.0 peers** (libzmq, JeroMQ, pyzmq,
|
|
32
|
+
NetMQ). OMQ previously sent `SUBSCRIBE`/`CANCEL` as ZMTP 3.1
|
|
33
|
+
command frames unconditionally; 3.0 peers expect message-form
|
|
34
|
+
(`\x01`/`\x00` + prefix data frames) and silently dropped them.
|
|
35
|
+
`Routing::Sub` and `Routing::XSub` now dispatch on
|
|
36
|
+
`conn.peer_minor`: command-form to ZMTP 3.1+ peers,
|
|
37
|
+
message-form to ZMTP 3.0 peers. `FanOut`'s subscription listener
|
|
38
|
+
already accepts both forms via `Protocol::ZMTP::Codec::Subscription.parse`,
|
|
39
|
+
so PUB/XPUB now also accept legacy message-form subscriptions
|
|
40
|
+
from 3.0 peers. Verified against JeroMQ in all six role/direction
|
|
41
|
+
combinations.
|
|
42
|
+
- **ZMTP/2.0 peers are now dropped loudly** during handshake
|
|
43
|
+
instead of hanging `read_exactly` forever. The underlying
|
|
44
|
+
`Greeting.read_from` helper in `protocol-zmtp` sniffs the
|
|
45
|
+
revision byte after 11 bytes and raises; the engine's existing
|
|
46
|
+
handshake-failure path closes the connection.
|
|
47
|
+
- **`Inproc::DirectPipe#read_frame`** now returns a data `Frame`
|
|
48
|
+
for non-command queue entries instead of silently dropping
|
|
49
|
+
them. Previously the fast-path `read_frame` only handled
|
|
50
|
+
`[:command, cmd]`-tagged items, so a message-form subscription
|
|
51
|
+
arriving on an inproc pipe was lost. Fallout from the PUB/SUB
|
|
52
|
+
fix above — without it the inproc tests for that path hung.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **ZMTP 3.0 / 3.1 compat tests** (`test/omq/zmtp_30_compat_test.rb`).
|
|
57
|
+
Hand-crafted raw TCP peer fakes cover: OMQ SUB → 3.0 PUB (message-form),
|
|
58
|
+
OMQ SUB → 3.1 PUB (command-form), OMQ XSUB → 3.0 PUB, OMQ XSUB → 3.1 PUB,
|
|
59
|
+
and OMQ PUB accepting message-form SUBSCRIBE from a 3.0 SUB peer.
|
|
60
|
+
- **`Inproc::DirectPipe#peer_major` / `#peer_minor`** — hard-coded to
|
|
61
|
+
3/1 since both ends of an inproc pipe are OMQ. Lets the routing
|
|
62
|
+
layer dispatch uniformly on `conn.peer_minor` without special-casing
|
|
63
|
+
the transport.
|
|
64
|
+
|
|
3
65
|
## 0.21.0 — 2026-04-15
|
|
4
66
|
|
|
5
67
|
### Changed
|
data/lib/omq/engine/heartbeat.rb
CHANGED
|
@@ -24,7 +24,7 @@ module OMQ
|
|
|
24
24
|
tasks << parent.async(transient: true, annotation: "heartbeat") do
|
|
25
25
|
loop do
|
|
26
26
|
sleep interval
|
|
27
|
-
conn.send_command(Protocol::ZMTP::Codec::Command.ping(ttl: ttl
|
|
27
|
+
conn.send_command(Protocol::ZMTP::Codec::Command.ping(ttl: ttl))
|
|
28
28
|
if conn.heartbeat_expired?(timeout)
|
|
29
29
|
conn.close
|
|
30
30
|
break
|
|
@@ -18,9 +18,10 @@ module OMQ
|
|
|
18
18
|
#
|
|
19
19
|
def self.start(engine, conn, q, tasks)
|
|
20
20
|
task = engine.spawn_conn_pump_task(conn, annotation: "send pump") do
|
|
21
|
+
batch = []
|
|
22
|
+
|
|
21
23
|
loop do
|
|
22
|
-
batch
|
|
23
|
-
Routing.drain_send_queue(q, batch)
|
|
24
|
+
Routing.dequeue_batch(q, batch)
|
|
24
25
|
|
|
25
26
|
if batch.size == 1
|
|
26
27
|
conn.write_message batch.first
|
|
@@ -30,7 +31,11 @@ module OMQ
|
|
|
30
31
|
|
|
31
32
|
conn.flush
|
|
32
33
|
|
|
33
|
-
batch.each
|
|
34
|
+
batch.each do |parts|
|
|
35
|
+
engine.emit_verbose_msg_sent(conn, parts)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
batch.clear
|
|
34
39
|
end
|
|
35
40
|
end
|
|
36
41
|
|
data/lib/omq/routing/fan_out.rb
CHANGED
|
@@ -133,15 +133,14 @@ module OMQ
|
|
|
133
133
|
@tasks << @engine.spawn_conn_pump_task(conn, annotation: "subscription listener") do
|
|
134
134
|
loop do
|
|
135
135
|
frame = conn.read_frame
|
|
136
|
-
next unless frame.command?
|
|
137
136
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
case Protocol::ZMTP::Codec::Subscription.parse(frame)
|
|
138
|
+
in [:subscribe, prefix]
|
|
139
|
+
on_subscribe(conn, prefix)
|
|
140
|
+
in [:cancel, prefix]
|
|
141
|
+
on_cancel(conn, prefix)
|
|
142
|
+
else
|
|
143
|
+
next
|
|
145
144
|
end
|
|
146
145
|
end
|
|
147
146
|
end
|
|
@@ -182,14 +181,16 @@ module OMQ
|
|
|
182
181
|
#
|
|
183
182
|
def start_conn_send_pump_normal(conn, q, use_wire)
|
|
184
183
|
@engine.spawn_conn_pump_task(conn, annotation: "send pump") do
|
|
184
|
+
batch = []
|
|
185
|
+
|
|
185
186
|
loop do
|
|
186
|
-
batch
|
|
187
|
-
Routing.drain_send_queue(q, batch)
|
|
187
|
+
Routing.dequeue_batch(q, batch)
|
|
188
188
|
|
|
189
189
|
if write_matching_batch(conn, batch, use_wire)
|
|
190
190
|
conn.flush
|
|
191
191
|
batch.each { |parts| @engine.emit_verbose_msg_sent(conn, parts) }
|
|
192
192
|
end
|
|
193
|
+
batch.clear
|
|
193
194
|
end
|
|
194
195
|
end
|
|
195
196
|
end
|
|
@@ -228,14 +229,17 @@ module OMQ
|
|
|
228
229
|
#
|
|
229
230
|
def start_conn_send_pump_conflate(conn, q)
|
|
230
231
|
@engine.spawn_conn_pump_task(conn, annotation: "send pump") do
|
|
232
|
+
batch = []
|
|
233
|
+
|
|
231
234
|
loop do
|
|
232
|
-
batch
|
|
233
|
-
Routing.drain_send_queue(q, batch)
|
|
235
|
+
Routing.dequeue_batch(q, batch)
|
|
234
236
|
|
|
235
237
|
# Keep only the latest message that matches the subscription.
|
|
236
238
|
latest = batch.reverse.find do |parts|
|
|
237
239
|
subscribed?(conn, parts.first || EMPTY_BINARY)
|
|
238
240
|
end
|
|
241
|
+
|
|
242
|
+
batch.clear
|
|
239
243
|
next unless latest
|
|
240
244
|
|
|
241
245
|
conn.write_message(latest)
|
data/lib/omq/routing/pair.rb
CHANGED
|
@@ -106,9 +106,10 @@ module OMQ
|
|
|
106
106
|
|
|
107
107
|
def start_send_pump(conn)
|
|
108
108
|
@send_pump = @engine.spawn_conn_pump_task(conn, annotation: "send pump") do
|
|
109
|
+
batch = []
|
|
110
|
+
|
|
109
111
|
loop do
|
|
110
|
-
|
|
111
|
-
Routing.drain_send_queue(@send_queue, batch)
|
|
112
|
+
Routing.dequeue_batch(@send_queue, batch)
|
|
112
113
|
|
|
113
114
|
if batch.size == 1
|
|
114
115
|
conn.write_message(batch.first)
|
|
@@ -120,6 +121,7 @@ module OMQ
|
|
|
120
121
|
batch.each do |parts|
|
|
121
122
|
@engine.emit_verbose_msg_sent(conn, parts)
|
|
122
123
|
end
|
|
124
|
+
batch.clear
|
|
123
125
|
end
|
|
124
126
|
end
|
|
125
127
|
|
data/lib/omq/routing/rep.rb
CHANGED
|
@@ -55,7 +55,7 @@ module OMQ
|
|
|
55
55
|
envelope = msg[0, delimiter]
|
|
56
56
|
body = msg[(delimiter + 1)..] || []
|
|
57
57
|
|
|
58
|
-
@pending_replies <<
|
|
58
|
+
@pending_replies << [connection, envelope]
|
|
59
59
|
body
|
|
60
60
|
end
|
|
61
61
|
@tasks << task if task
|
|
@@ -69,7 +69,7 @@ module OMQ
|
|
|
69
69
|
# @param connection [Connection]
|
|
70
70
|
#
|
|
71
71
|
def connection_removed(connection)
|
|
72
|
-
@pending_replies.reject! { |r| r[
|
|
72
|
+
@pending_replies.reject! { |r| r[0] == connection }
|
|
73
73
|
@conn_queues.delete(connection)
|
|
74
74
|
@conn_send_tasks.delete(connection)&.stop
|
|
75
75
|
end
|
|
@@ -83,8 +83,12 @@ module OMQ
|
|
|
83
83
|
def enqueue(parts)
|
|
84
84
|
reply_info = @pending_replies.shift
|
|
85
85
|
return unless reply_info
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
|
|
87
|
+
conn, envelope = reply_info
|
|
88
|
+
msg = envelope
|
|
89
|
+
msg << EMPTY_FRAME
|
|
90
|
+
msg.concat(parts)
|
|
91
|
+
@conn_queues[conn]&.enqueue(msg)
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
|
data/lib/omq/routing/req.rb
CHANGED
|
@@ -133,9 +133,10 @@ module OMQ
|
|
|
133
133
|
#
|
|
134
134
|
def start_conn_send_pump(conn)
|
|
135
135
|
task = @engine.spawn_conn_pump_task(conn, annotation: "send pump") do
|
|
136
|
+
batch = []
|
|
137
|
+
|
|
136
138
|
loop do
|
|
137
|
-
batch
|
|
138
|
-
drain_send_queue_capped(batch)
|
|
139
|
+
dequeue_batch_capped(batch)
|
|
139
140
|
@in_flight += batch.size
|
|
140
141
|
|
|
141
142
|
begin
|
|
@@ -147,6 +148,7 @@ module OMQ
|
|
|
147
148
|
batch.each do |parts|
|
|
148
149
|
@engine.emit_verbose_msg_sent(conn, parts)
|
|
149
150
|
end
|
|
151
|
+
batch.clear
|
|
150
152
|
|
|
151
153
|
Async::Task.current.yield
|
|
152
154
|
end
|
|
@@ -157,7 +159,8 @@ module OMQ
|
|
|
157
159
|
end
|
|
158
160
|
|
|
159
161
|
|
|
160
|
-
def
|
|
162
|
+
def dequeue_batch_capped(batch = [])
|
|
163
|
+
batch << @send_queue.dequeue
|
|
161
164
|
bytes = batch_bytes(batch.first)
|
|
162
165
|
|
|
163
166
|
while batch.size < BATCH_MSG_CAP && bytes < BATCH_BYTE_CAP
|
data/lib/omq/routing/sub.rb
CHANGED
|
@@ -48,7 +48,7 @@ module OMQ
|
|
|
48
48
|
@connections << connection
|
|
49
49
|
|
|
50
50
|
@subscriptions.each do |prefix|
|
|
51
|
-
connection
|
|
51
|
+
send_subscribe(connection, prefix)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
task = @engine.start_recv_pump(connection, @recv_queue)
|
|
@@ -76,9 +76,7 @@ module OMQ
|
|
|
76
76
|
#
|
|
77
77
|
def subscribe(prefix)
|
|
78
78
|
@subscriptions << prefix
|
|
79
|
-
@connections.each
|
|
80
|
-
conn.send_command(Protocol::ZMTP::Codec::Command.subscribe(prefix))
|
|
81
|
-
end
|
|
79
|
+
@connections.each { |conn| send_subscribe(conn, prefix) }
|
|
82
80
|
end
|
|
83
81
|
|
|
84
82
|
|
|
@@ -88,9 +86,7 @@ module OMQ
|
|
|
88
86
|
#
|
|
89
87
|
def unsubscribe(prefix)
|
|
90
88
|
@subscriptions.delete(prefix)
|
|
91
|
-
@connections.each
|
|
92
|
-
conn.send_command(Protocol::ZMTP::Codec::Command.cancel(prefix))
|
|
93
|
-
end
|
|
89
|
+
@connections.each { |conn| send_cancel(conn, prefix) }
|
|
94
90
|
end
|
|
95
91
|
|
|
96
92
|
|
|
@@ -103,6 +99,30 @@ module OMQ
|
|
|
103
99
|
@tasks.clear
|
|
104
100
|
end
|
|
105
101
|
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Sends a SUBSCRIBE to +conn+ using the wire form the peer understands:
|
|
107
|
+
# command-form for ZMTP 3.1+, legacy message-form for ZMTP 3.0.
|
|
108
|
+
#
|
|
109
|
+
def send_subscribe(conn, prefix)
|
|
110
|
+
if conn.peer_minor >= 1
|
|
111
|
+
conn.send_command(Protocol::ZMTP::Codec::Command.subscribe(prefix))
|
|
112
|
+
else
|
|
113
|
+
conn.send_message([Protocol::ZMTP::Codec::Subscription.body(prefix)])
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def send_cancel(conn, prefix)
|
|
119
|
+
if conn.peer_minor >= 1
|
|
120
|
+
conn.send_command(Protocol::ZMTP::Codec::Command.cancel(prefix))
|
|
121
|
+
else
|
|
122
|
+
conn.send_message([Protocol::ZMTP::Codec::Subscription.body(prefix, cancel: true)])
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
106
126
|
end
|
|
107
127
|
end
|
|
108
128
|
end
|
data/lib/omq/routing/xsub.rb
CHANGED
|
@@ -112,9 +112,19 @@ module OMQ
|
|
|
112
112
|
|
|
113
113
|
case flag
|
|
114
114
|
when 0x01
|
|
115
|
-
conn.
|
|
115
|
+
if conn.peer_minor >= 1
|
|
116
|
+
conn.send_command(Protocol::ZMTP::Codec::Command.subscribe(prefix))
|
|
117
|
+
else
|
|
118
|
+
conn.send_message([frame])
|
|
119
|
+
end
|
|
116
120
|
when 0x00
|
|
117
|
-
conn.
|
|
121
|
+
if conn.peer_minor >= 1
|
|
122
|
+
conn.send_command(Protocol::ZMTP::Codec::Command.cancel(prefix))
|
|
123
|
+
else
|
|
124
|
+
conn.send_message([frame])
|
|
125
|
+
end
|
|
126
|
+
else
|
|
127
|
+
next
|
|
118
128
|
end
|
|
119
129
|
end
|
|
120
130
|
end
|
data/lib/omq/routing.rb
CHANGED
|
@@ -57,7 +57,8 @@ module OMQ
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
# Drains all available messages from +queue+ into +batch+ without
|
|
60
|
-
#
|
|
60
|
+
# Blocks for the first message, then sweeps all immediately
|
|
61
|
+
# available messages into +batch+ without blocking.
|
|
61
62
|
#
|
|
62
63
|
# No cap is needed: IO::Stream auto-flushes at 64 KB, so the
|
|
63
64
|
# write buffer hits the wire naturally under sustained load.
|
|
@@ -67,7 +68,9 @@ module OMQ
|
|
|
67
68
|
# @param batch [Array]
|
|
68
69
|
# @return [void]
|
|
69
70
|
#
|
|
70
|
-
def self.
|
|
71
|
+
def self.dequeue_batch(queue, batch = [])
|
|
72
|
+
batch << queue.dequeue
|
|
73
|
+
|
|
71
74
|
loop do
|
|
72
75
|
msg = queue.dequeue(timeout: 0) or break
|
|
73
76
|
batch << msg
|
|
@@ -20,6 +20,20 @@ module OMQ
|
|
|
20
20
|
attr_reader :peer_socket_type
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
# @return [Integer] always 3 — inproc peers are OMQ
|
|
24
|
+
#
|
|
25
|
+
def peer_major
|
|
26
|
+
3
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# @return [Integer] always 1 — inproc peers are OMQ (ZMTP 3.1)
|
|
31
|
+
#
|
|
32
|
+
def peer_minor
|
|
33
|
+
1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
|
|
23
37
|
# @return [String] peer's identity
|
|
24
38
|
#
|
|
25
39
|
attr_reader :peer_identity
|
|
@@ -158,19 +172,20 @@ module OMQ
|
|
|
158
172
|
end
|
|
159
173
|
|
|
160
174
|
|
|
161
|
-
# Reads one
|
|
162
|
-
#
|
|
175
|
+
# Reads one frame. Used by PUB/XPUB subscription listeners,
|
|
176
|
+
# which must see both the legacy message-form subscription
|
|
177
|
+
# (ZMTP 3.0) and the command-form (ZMTP 3.1).
|
|
163
178
|
#
|
|
164
179
|
# @return [Protocol::ZMTP::Codec::Frame]
|
|
165
180
|
#
|
|
166
181
|
def read_frame
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
raise EOFError, "connection closed" if item.nil?
|
|
182
|
+
item = @receive_queue.dequeue
|
|
183
|
+
raise EOFError, "connection closed" if item.nil?
|
|
170
184
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
185
|
+
if item.is_a?(Array) && item.first == :command
|
|
186
|
+
Protocol::ZMTP::Codec::Frame.new(item[1].to_body, command: true)
|
|
187
|
+
else
|
|
188
|
+
Protocol::ZMTP::Codec::Frame.new(item.first || "".b)
|
|
174
189
|
end
|
|
175
190
|
end
|
|
176
191
|
|
data/lib/omq/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: omq
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.22.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrik Wenger
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
18
|
+
version: '0.8'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.
|
|
25
|
+
version: '0.8'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: async
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|