async-rabbitmq 0.4.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 +7 -0
- data/CHANGELOG.md +268 -0
- data/LICENSE +21 -0
- data/README.md +594 -0
- data/exe/async-rabbitmq +12 -0
- data/lib/async_rabbitmq/channel.rb +1422 -0
- data/lib/async_rabbitmq/channel_id_allocator.rb +57 -0
- data/lib/async_rabbitmq/cli.rb +300 -0
- data/lib/async_rabbitmq/cluster.rb +433 -0
- data/lib/async_rabbitmq/errors.rb +113 -0
- data/lib/async_rabbitmq/exchange.rb +70 -0
- data/lib/async_rabbitmq/frame_io.rb +309 -0
- data/lib/async_rabbitmq/log.rb +52 -0
- data/lib/async_rabbitmq/notifier.rb +122 -0
- data/lib/async_rabbitmq/pool.rb +43 -0
- data/lib/async_rabbitmq/queue.rb +88 -0
- data/lib/async_rabbitmq/sasl.rb +123 -0
- data/lib/async_rabbitmq/session.rb +1154 -0
- data/lib/async_rabbitmq/telemetry/open_telemetry.rb +211 -0
- data/lib/async_rabbitmq/tls.rb +82 -0
- data/lib/async_rabbitmq/topology_registry.rb +210 -0
- data/lib/async_rabbitmq/version.rb +3 -0
- data/lib/async_rabbitmq/versioned_delivery_tag.rb +62 -0
- data/lib/async_rabbitmq.rb +18 -0
- metadata +180 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "amq/bit_set"
|
|
4
|
+
|
|
5
|
+
module AsyncRabbitMQ
|
|
6
|
+
# Hands out channel ids from 1 to +limit+ and takes them back when a channel
|
|
7
|
+
# closes, so a process that opens a channel per unit of work never runs out.
|
|
8
|
+
# Built on AMQ::BitSet from amq-protocol, the same structure under Bunny's
|
|
9
|
+
# ChannelIdAllocator: finding the lowest free id is a word scan, not a walk
|
|
10
|
+
# of the channel table. AMQ::IntAllocator itself is not used because it
|
|
11
|
+
# cannot reserve a specific id, which Channel#reopen needs to take its old id
|
|
12
|
+
# back after a broker-initiated close.
|
|
13
|
+
class ChannelIdAllocator
|
|
14
|
+
attr_reader :limit
|
|
15
|
+
|
|
16
|
+
def initialize(limit)
|
|
17
|
+
raise ArgumentError, "channel limit must be positive (got #{limit.inspect})" unless limit.to_i.positive?
|
|
18
|
+
|
|
19
|
+
@limit = limit
|
|
20
|
+
@bits = AMQ::BitSet.new(limit) # bit i stands for channel id i + 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The lowest free id, or nil when all +limit+ ids are in use. BitSet
|
|
24
|
+
# answers -1 when every word is full, and can point into the unused tail of
|
|
25
|
+
# the last word, which is why both bounds are checked.
|
|
26
|
+
def allocate
|
|
27
|
+
index = @bits.next_clear_bit
|
|
28
|
+
return nil if index.nil? || index.negative? || index >= @limit
|
|
29
|
+
|
|
30
|
+
@bits.set(index)
|
|
31
|
+
index + 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Take a specific id. Returns false if it is already in use.
|
|
35
|
+
def reserve(id)
|
|
36
|
+
return false unless id.between?(1, @limit)
|
|
37
|
+
return false if @bits.get(id - 1)
|
|
38
|
+
|
|
39
|
+
@bits.set(id - 1)
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def release(id)
|
|
44
|
+
@bits.unset(id - 1) if id.between?(1, @limit)
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def allocated?(id)
|
|
49
|
+
id.between?(1, @limit) && @bits.get(id - 1)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reset
|
|
53
|
+
@bits.clear
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "../async_rabbitmq"
|
|
6
|
+
|
|
7
|
+
module AsyncRabbitMQ
|
|
8
|
+
# The `async-rabbitmq` command: publish, consume, inspect and purge from a
|
|
9
|
+
# terminal, written on the gem's own API.
|
|
10
|
+
#
|
|
11
|
+
# Everything goes through CLI.start, which returns an exit status instead of
|
|
12
|
+
# calling exit, so the specs can drive it like any other object.
|
|
13
|
+
class CLI
|
|
14
|
+
COMMANDS = %w[publish consume inspect purge].freeze
|
|
15
|
+
DEFAULT_URL = "amqp://guest:guest@localhost:5672"
|
|
16
|
+
|
|
17
|
+
Options = Struct.new(:url, :exchange, :routing_key, :persistent, :count, :timeout,
|
|
18
|
+
:peek, :prefetch, :file, :quiet, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
def self.start(argv, out: $stdout, err: $stderr)
|
|
21
|
+
new(out: out, err: err).run(argv)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(out: $stdout, err: $stderr)
|
|
25
|
+
@out = out
|
|
26
|
+
@err = err
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run(argv)
|
|
30
|
+
argv = argv.dup
|
|
31
|
+
command = argv.shift
|
|
32
|
+
|
|
33
|
+
case command
|
|
34
|
+
when "publish" then publish(argv)
|
|
35
|
+
when "consume" then consume(argv)
|
|
36
|
+
when "inspect" then inspect_queue(argv)
|
|
37
|
+
when "purge" then purge(argv)
|
|
38
|
+
when "--version", "-v"
|
|
39
|
+
@out.puts(AsyncRabbitMQ::VERSION)
|
|
40
|
+
0
|
|
41
|
+
when nil, "help", "--help", "-h"
|
|
42
|
+
@out.puts(usage)
|
|
43
|
+
command.nil? ? 1 : 0
|
|
44
|
+
when /\A-/
|
|
45
|
+
@err.puts("Options come after the command: async-rabbitmq <command> QUEUE #{command} ...")
|
|
46
|
+
@err.puts(usage)
|
|
47
|
+
1
|
|
48
|
+
else
|
|
49
|
+
@err.puts("Unknown command #{command.inspect}")
|
|
50
|
+
@err.puts(usage)
|
|
51
|
+
1
|
|
52
|
+
end
|
|
53
|
+
rescue AsyncRabbitMQ::ChannelError => e
|
|
54
|
+
@err.puts("#{e.code} #{e.text}")
|
|
55
|
+
1
|
|
56
|
+
rescue AsyncRabbitMQ::AuthenticationError => e
|
|
57
|
+
@err.puts("Authentication failed: #{e.message}")
|
|
58
|
+
1
|
|
59
|
+
rescue AsyncRabbitMQ::Error, SystemCallError, SocketError => e
|
|
60
|
+
@err.puts("#{e.class.name.split('::').last}: #{e.message}")
|
|
61
|
+
1
|
|
62
|
+
rescue OptionParser::ParseError => e
|
|
63
|
+
@err.puts(e.message)
|
|
64
|
+
1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def usage
|
|
68
|
+
<<~TEXT
|
|
69
|
+
Usage: async-rabbitmq <command> [options]
|
|
70
|
+
|
|
71
|
+
Commands:
|
|
72
|
+
publish QUEUE [BODY] publish a message (BODY defaults to stdin)
|
|
73
|
+
consume QUEUE print messages as they arrive
|
|
74
|
+
inspect QUEUE message and consumer counts
|
|
75
|
+
purge QUEUE discard everything on a queue
|
|
76
|
+
|
|
77
|
+
Options go after the command and its queue:
|
|
78
|
+
--url URL broker URL (env RABBITMQ_URL, default #{DEFAULT_URL})
|
|
79
|
+
--quiet print only the messages, not the status lines
|
|
80
|
+
-h, --help this text, or per-command help after a command
|
|
81
|
+
--version print the gem version
|
|
82
|
+
|
|
83
|
+
Run "async-rabbitmq <command> --help" for the options of one command.
|
|
84
|
+
TEXT
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------------
|
|
90
|
+
# Commands
|
|
91
|
+
# ------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def publish(argv)
|
|
94
|
+
options = parse(argv, "publish QUEUE [BODY]") do |parser, opts|
|
|
95
|
+
parser.on("--exchange NAME", "publish to this exchange; QUEUE becomes the routing key") { |v| opts.exchange = v }
|
|
96
|
+
parser.on("--persistent", "mark the message persistent (delivery mode 2)") { opts.persistent = true }
|
|
97
|
+
parser.on("--count N", Integer, "publish the same body N times (default 1)") { |v| opts.count = v }
|
|
98
|
+
parser.on("--file PATH", "read the body from a file") { |v| opts.file = v }
|
|
99
|
+
end
|
|
100
|
+
return 1 unless options
|
|
101
|
+
|
|
102
|
+
target = argv.shift
|
|
103
|
+
return missing("QUEUE") unless target
|
|
104
|
+
|
|
105
|
+
body = body_for(argv, options)
|
|
106
|
+
return 1 unless body
|
|
107
|
+
|
|
108
|
+
count = options.count || 1
|
|
109
|
+
exchange = options.exchange || ""
|
|
110
|
+
routing_key = target
|
|
111
|
+
|
|
112
|
+
with_session(options) do |session|
|
|
113
|
+
channel = session.open_channel
|
|
114
|
+
returned = 0
|
|
115
|
+
# Registered before publishing: a return can arrive before its ack.
|
|
116
|
+
channel.on_return { |_ret, _header, _body| returned += 1 }
|
|
117
|
+
channel.confirm_select
|
|
118
|
+
count.times do
|
|
119
|
+
channel.basic_publish(body, exchange: exchange, routing_key: routing_key,
|
|
120
|
+
persistent: options.persistent, mandatory: true)
|
|
121
|
+
end
|
|
122
|
+
confirmed = channel.wait_for_confirms
|
|
123
|
+
sleep 0.1 # a basic.return arrives before its ack, but give the reader a tick
|
|
124
|
+
|
|
125
|
+
destination = exchange.empty? ? "queue #{routing_key}" : "#{exchange} with routing key #{routing_key}"
|
|
126
|
+
if !confirmed
|
|
127
|
+
@err.puts("Broker rejected #{channel.nacked_tags.size} of #{count} message(s)")
|
|
128
|
+
1
|
|
129
|
+
elsif returned.positive?
|
|
130
|
+
@err.puts("#{returned} of #{count} message(s) came back unroutable from #{destination}")
|
|
131
|
+
1
|
|
132
|
+
else
|
|
133
|
+
say("Published #{count} message#{'s' if count != 1} to #{destination}, all confirmed")
|
|
134
|
+
0
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def consume(argv)
|
|
140
|
+
options = parse(argv, "consume QUEUE") do |parser, opts|
|
|
141
|
+
parser.on("--count N", Integer, "stop after N messages") { |v| opts.count = v }
|
|
142
|
+
parser.on("--timeout SECONDS", Float, "stop after this long without a message") { |v| opts.timeout = v }
|
|
143
|
+
parser.on("--peek", "leave the messages on the queue (never acknowledge)") { opts.peek = true }
|
|
144
|
+
parser.on("--prefetch N", Integer, "unacked messages to allow (default 10)") { |v| opts.prefetch = v }
|
|
145
|
+
end
|
|
146
|
+
return 1 unless options
|
|
147
|
+
|
|
148
|
+
queue = argv.shift
|
|
149
|
+
return missing("QUEUE") unless queue
|
|
150
|
+
|
|
151
|
+
wanted = options.count
|
|
152
|
+
idle = options.timeout
|
|
153
|
+
received = 0
|
|
154
|
+
|
|
155
|
+
with_session(options) do |session|
|
|
156
|
+
channel = session.open_channel
|
|
157
|
+
# With a limit, ask for no more than that many at a time, so the broker
|
|
158
|
+
# does not push a batch we are about to walk away from.
|
|
159
|
+
channel.basic_qos(prefetch_count: options.prefetch || (wanted ? [wanted, 1].max : 10))
|
|
160
|
+
last_at = monotonic
|
|
161
|
+
|
|
162
|
+
channel.basic_consume(queue, manual_ack: true) do |delivery, header, body|
|
|
163
|
+
# Already have what was asked for: leave this one on the queue by not
|
|
164
|
+
# acknowledging it, so --count 2 takes two and no more.
|
|
165
|
+
next if wanted && received >= wanted
|
|
166
|
+
|
|
167
|
+
received += 1
|
|
168
|
+
last_at = monotonic
|
|
169
|
+
@out.puts(format_delivery(delivery, header, body))
|
|
170
|
+
channel.basic_ack(delivery.delivery_tag) unless options.peek
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
say("Consuming from #{queue}#{options.peek ? ' (peeking, nothing is acknowledged)' : ''}. Ctrl-C to stop.")
|
|
174
|
+
|
|
175
|
+
interrupted = false
|
|
176
|
+
Signal.trap("INT") { interrupted = true }
|
|
177
|
+
until interrupted
|
|
178
|
+
sleep 0.1
|
|
179
|
+
break if wanted && received >= wanted
|
|
180
|
+
break if idle && (monotonic - last_at) > idle
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
say("Received #{received} message#{'s' if received != 1}#{options.peek ? ', left on the queue' : ''}")
|
|
184
|
+
0
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def inspect_queue(argv)
|
|
189
|
+
options = parse(argv, "inspect QUEUE") { |_parser, _opts| }
|
|
190
|
+
return 1 unless options
|
|
191
|
+
|
|
192
|
+
queue = argv.shift
|
|
193
|
+
return missing("QUEUE") unless queue
|
|
194
|
+
|
|
195
|
+
with_session(options) do |session|
|
|
196
|
+
channel = session.open_channel
|
|
197
|
+
status = channel.queue(queue, passive: true)
|
|
198
|
+
@out.puts("#{queue}: #{status.message_count} message#{'s' if status.message_count != 1}, " \
|
|
199
|
+
"#{status.consumer_count} consumer#{'s' if status.consumer_count != 1}")
|
|
200
|
+
0
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def purge(argv)
|
|
205
|
+
options = parse(argv, "purge QUEUE") { |_parser, _opts| }
|
|
206
|
+
return 1 unless options
|
|
207
|
+
|
|
208
|
+
queue = argv.shift
|
|
209
|
+
return missing("QUEUE") unless queue
|
|
210
|
+
|
|
211
|
+
with_session(options) do |session|
|
|
212
|
+
channel = session.open_channel
|
|
213
|
+
purged = channel.queue_purge(queue)
|
|
214
|
+
count = purged.respond_to?(:message_count) ? purged.message_count : nil
|
|
215
|
+
@out.puts("Purged #{queue}#{count ? ": #{count} message#{'s' if count != 1} discarded" : ''}")
|
|
216
|
+
0
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------------
|
|
221
|
+
# Plumbing
|
|
222
|
+
# ------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
# Parses the shared options plus whatever the command adds. Returns nil when
|
|
225
|
+
# the parse failed or --help was asked for, having printed the reason.
|
|
226
|
+
def parse(argv, banner)
|
|
227
|
+
opts = Options.new(url: ENV["RABBITMQ_URL"] || DEFAULT_URL)
|
|
228
|
+
parser = OptionParser.new do |p|
|
|
229
|
+
p.banner = "Usage: async-rabbitmq #{banner} [options]"
|
|
230
|
+
p.on("--url URL", "broker URL (env RABBITMQ_URL)") { |v| opts.url = v }
|
|
231
|
+
p.on("--quiet", "print only the messages, no status lines") { opts.quiet = true }
|
|
232
|
+
yield(p, opts)
|
|
233
|
+
p.on("-h", "--help", "this text") do
|
|
234
|
+
@out.puts(p)
|
|
235
|
+
return nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
parser.parse!(argv)
|
|
239
|
+
@quiet = opts.quiet
|
|
240
|
+
opts
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def with_session(options)
|
|
244
|
+
status = 1
|
|
245
|
+
Sync do
|
|
246
|
+
session = AsyncRabbitMQ::Session.from_uri(options.url, connection_name: "async-rabbitmq cli",
|
|
247
|
+
logger: quiet_logger)
|
|
248
|
+
begin
|
|
249
|
+
session.connect
|
|
250
|
+
status = yield(session)
|
|
251
|
+
ensure
|
|
252
|
+
session.close rescue nil
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
status
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def body_for(argv, options)
|
|
259
|
+
if options.file
|
|
260
|
+
return File.binread(options.file) if File.exist?(options.file)
|
|
261
|
+
|
|
262
|
+
@err.puts("No such file: #{options.file}")
|
|
263
|
+
nil
|
|
264
|
+
elsif !argv.empty?
|
|
265
|
+
argv.join(" ")
|
|
266
|
+
elsif !$stdin.tty?
|
|
267
|
+
$stdin.binmode.read
|
|
268
|
+
else
|
|
269
|
+
missing("BODY")
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def format_delivery(delivery, header, body)
|
|
275
|
+
properties = header.respond_to?(:properties) ? (header.properties || {}) : {}
|
|
276
|
+
source = delivery.exchange.to_s.empty? ? delivery.routing_key : "#{delivery.exchange}/#{delivery.routing_key}"
|
|
277
|
+
details = ["#{body.to_s.bytesize} bytes"]
|
|
278
|
+
details << "redelivered" if delivery.redelivered
|
|
279
|
+
details << "id #{properties[:message_id]}" if properties[:message_id]
|
|
280
|
+
"#{source} (#{details.join(', ')}): #{body}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def missing(what)
|
|
284
|
+
@err.puts("Missing #{what}")
|
|
285
|
+
1
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def say(message)
|
|
289
|
+
@out.puts(message) unless @quiet
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def quiet_logger
|
|
293
|
+
AsyncRabbitMQ::Log.new(@err)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def monotonic
|
|
297
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|