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