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,309 @@
|
|
|
1
|
+
require "async"
|
|
2
|
+
require "async/queue"
|
|
3
|
+
require "async/limited_queue"
|
|
4
|
+
require "async/semaphore"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "amq/protocol"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module AsyncRabbitMQ
|
|
10
|
+
# Handles low-level AMQP frame reading and writing over a native Ruby socket.
|
|
11
|
+
# async 2.x's fiber scheduler makes blocking socket reads yield automatically.
|
|
12
|
+
#
|
|
13
|
+
# Architecture:
|
|
14
|
+
# Reader Task — reads frames, routes to per-channel Async::Queue.
|
|
15
|
+
# Writer Task — drains a bounded Async::LimitedQueue, serializes all writes.
|
|
16
|
+
# Heartbeat — writes directly via Async::Semaphore (bypasses write queue).
|
|
17
|
+
#
|
|
18
|
+
# AMQ::Protocol::Frame.decode raises NotImplementedError (abstract stub), so
|
|
19
|
+
# frames are read in three steps: 7-byte header (decoded by
|
|
20
|
+
# AMQ::Protocol::Frame.decode_header), payload, 0xCE terminator.
|
|
21
|
+
class FrameIO
|
|
22
|
+
FRAME_HEADER_SIZE = 7
|
|
23
|
+
FRAME_TERMINATOR = "\xCE".b.freeze
|
|
24
|
+
WRITE_QUEUE_LIMIT = 1024
|
|
25
|
+
|
|
26
|
+
# AMQP soft-error codes: broker closes the channel, connection stays alive.
|
|
27
|
+
# 311 content-too-large, 312 no-route, 313 no-consumers are channel-level errors.
|
|
28
|
+
# 403 access-refused, 404 not-found, 405 resource-locked, 406 precondition-failed.
|
|
29
|
+
SOFT_ERROR_CODES = [311, 312, 313, 403, 404, 405, 406].freeze
|
|
30
|
+
|
|
31
|
+
def initialize(socket, logger: Log.new)
|
|
32
|
+
@socket = socket
|
|
33
|
+
@logger = logger
|
|
34
|
+
@channels = {}
|
|
35
|
+
@channel_sem = Async::Semaphore.new(1)
|
|
36
|
+
@write_queue = Async::LimitedQueue.new(WRITE_QUEUE_LIMIT)
|
|
37
|
+
@socket_sem = Async::Semaphore.new(1)
|
|
38
|
+
@running = false
|
|
39
|
+
@reader_task = nil
|
|
40
|
+
@writer_task = nil
|
|
41
|
+
@content_state = {}
|
|
42
|
+
@on_frame = nil # optional proc called each time a frame is read
|
|
43
|
+
@blocked = false
|
|
44
|
+
@unblocked_condition = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Set a callback invoked immediately after each frame is successfully read.
|
|
48
|
+
# Used by Session to update its heartbeat timestamp.
|
|
49
|
+
def on_frame=(proc)
|
|
50
|
+
@on_frame = proc
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def register_channel(channel_id)
|
|
54
|
+
@channel_sem.acquire do
|
|
55
|
+
q = @channels[channel_id] = Async::Queue.new
|
|
56
|
+
q
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def unregister_channel(channel_id)
|
|
61
|
+
@channel_sem.acquire do
|
|
62
|
+
@channels.delete(channel_id)
|
|
63
|
+
@content_state.delete(channel_id)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def channel_queue(channel_id)
|
|
68
|
+
@channels[channel_id]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# +spawn+ makes the reader and writer tasks. It defaults to the calling
|
|
72
|
+
# task, which is only right for a throwaway connection: the session passes
|
|
73
|
+
# one that parents them at the reactor, so a short-lived task that happened
|
|
74
|
+
# to call connect cannot take the connection's IO down with it.
|
|
75
|
+
def start(task: Async::Task.current, spawn: ->(&block) { task.async(&block) })
|
|
76
|
+
@running = true
|
|
77
|
+
@writer_task = spawn.call { writer_loop }
|
|
78
|
+
@reader_task = spawn.call { reader_loop }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def stop
|
|
82
|
+
@running = false
|
|
83
|
+
@socket.close rescue nil
|
|
84
|
+
# Publishers parked on the connection.blocked gate can never proceed now.
|
|
85
|
+
@blocked = false
|
|
86
|
+
release_blocked_writers(ConnectionError.new(code: 0, text: "Connection closed while blocked by the broker"))
|
|
87
|
+
# Push nil sentinel to every channel queue FIRST so blocked poppers
|
|
88
|
+
# (e.g. wait_channel0_method) are unblocked before we cancel tasks.
|
|
89
|
+
# Cancelling the CURRENT task raises Async::Cancel (< Exception, not
|
|
90
|
+
# StandardError), which bypasses inline `rescue nil` and would skip
|
|
91
|
+
# the push if we did it after.
|
|
92
|
+
@channels.each_value { |q| q.push(nil) rescue nil }
|
|
93
|
+
@writer_task&.cancel rescue nil
|
|
94
|
+
@reader_task&.cancel rescue nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Enqueue raw frame bytes for writing. Yields the fiber when the queue is
|
|
98
|
+
# full. Content publishes (+publish: true+) additionally wait while the
|
|
99
|
+
# broker has the connection blocked (connection.blocked). Control frames
|
|
100
|
+
# (close, close-ok, acks, ...) are never gated, so shutdown cannot hang on
|
|
101
|
+
# a resource alarm. Raises ConnectionError if the connection is closed
|
|
102
|
+
# while a publish is parked.
|
|
103
|
+
def write_frame(data, publish: false)
|
|
104
|
+
if publish
|
|
105
|
+
while @blocked
|
|
106
|
+
@unblocked_condition ||= Async::Condition.new
|
|
107
|
+
outcome = @unblocked_condition.wait
|
|
108
|
+
raise outcome if outcome.is_a?(Exception)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
@write_queue.push(data)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Called by the Session channel-0 monitor when connection.blocked is received.
|
|
115
|
+
def set_blocked(reason)
|
|
116
|
+
@blocked = true
|
|
117
|
+
@logger.warn("Connection blocked: #{reason}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Called by the Session channel-0 monitor when connection.unblocked is received.
|
|
121
|
+
def set_unblocked
|
|
122
|
+
@blocked = false
|
|
123
|
+
release_blocked_writers
|
|
124
|
+
@logger.info("Connection unblocked")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def blocked?
|
|
128
|
+
@blocked
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# How many writes are queued for the writer fiber but not yet on the socket.
|
|
132
|
+
# One entry is one write_frame call, so a batch published with
|
|
133
|
+
# basic_publish_batch counts once however many messages it carries. Used at
|
|
134
|
+
# close time to report what is about to be thrown away.
|
|
135
|
+
def pending_writes
|
|
136
|
+
@write_queue.size
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Write heartbeat directly to socket, bypassing the write queue so
|
|
140
|
+
# heartbeats are never starved by backpressure.
|
|
141
|
+
# Silently swallows IO errors — the reader_loop will detect and recover.
|
|
142
|
+
def write_heartbeat
|
|
143
|
+
heartbeat = AMQ::Protocol::HeartbeatFrame.encode
|
|
144
|
+
@socket_sem.acquire { @socket.write(heartbeat) }
|
|
145
|
+
rescue IOError, Errno::EPIPE, Errno::EBADF
|
|
146
|
+
# Socket is closed; reader_loop will raise and call trigger_recovery.
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Wake every publisher waiting on the blocked gate. With +outcome+ nil they
|
|
152
|
+
# re-check the gate; with an exception they raise it.
|
|
153
|
+
def release_blocked_writers(outcome = nil)
|
|
154
|
+
cond = @unblocked_condition
|
|
155
|
+
@unblocked_condition = nil
|
|
156
|
+
cond&.signal(outcome)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def writer_loop
|
|
160
|
+
while @running
|
|
161
|
+
data = @write_queue.pop
|
|
162
|
+
@socket_sem.acquire { @socket.write(data) }
|
|
163
|
+
end
|
|
164
|
+
rescue IOError, Errno::EPIPE, Errno::EBADF, Errno::ECONNRESET
|
|
165
|
+
# Socket closed — reader_loop will detect and call trigger_recovery.
|
|
166
|
+
rescue => e
|
|
167
|
+
# The writer is gone either way; without recovery every publisher would
|
|
168
|
+
# block forever once the write queue fills, with nothing to drain it.
|
|
169
|
+
@logger.error("FrameIO writer: #{e.class}: #{e.message}")
|
|
170
|
+
trigger_recovery(e) if @running
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def reader_loop
|
|
174
|
+
while @running
|
|
175
|
+
frame = read_frame
|
|
176
|
+
@on_frame.call if @on_frame
|
|
177
|
+
dispatch(frame)
|
|
178
|
+
end
|
|
179
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, Errno::EBADF => e
|
|
180
|
+
if @running
|
|
181
|
+
@logger.warn("FrameIO reader closed: #{e.class}")
|
|
182
|
+
trigger_recovery(e)
|
|
183
|
+
else
|
|
184
|
+
# #stop closed the socket under us: an orderly shutdown, not a failure.
|
|
185
|
+
@logger.debug("FrameIO reader stopped (#{e.class})")
|
|
186
|
+
end
|
|
187
|
+
rescue AMQ::Protocol::Error => e
|
|
188
|
+
@logger.error("FrameIO AMQP error: #{e.class}: #{e.message}")
|
|
189
|
+
trigger_recovery(e)
|
|
190
|
+
rescue => e
|
|
191
|
+
@logger.error("FrameIO reader error: #{e.class}: #{e.message}")
|
|
192
|
+
trigger_recovery(e)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Read one AMQP frame via manual 3-step IO.
|
|
196
|
+
# Returns an AMQ::Protocol::MethodFrame / HeaderFrame / BodyFrame / HeartbeatFrame.
|
|
197
|
+
def read_frame
|
|
198
|
+
header_bytes = read_exactly(FRAME_HEADER_SIZE)
|
|
199
|
+
# Raises AMQ::Protocol::FrameTypeError (an AMQ::Protocol::Error) on an
|
|
200
|
+
# unknown frame type, which reader_loop turns into recovery.
|
|
201
|
+
type, channel_id, payload_size = AMQ::Protocol::Frame.decode_header(header_bytes)
|
|
202
|
+
payload = read_exactly(payload_size)
|
|
203
|
+
terminator = read_exactly(1)
|
|
204
|
+
|
|
205
|
+
unless terminator == FRAME_TERMINATOR
|
|
206
|
+
raise AMQ::Protocol::Error,
|
|
207
|
+
"Invalid frame terminator: #{terminator.inspect} (expected 0xCE)"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
AMQ::Protocol::Frame::CLASSES[AMQ::Protocol::Frame::TYPES[type]].new(payload, channel_id)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Read exactly n bytes, yielding the fiber at each blocking read.
|
|
214
|
+
def read_exactly(n)
|
|
215
|
+
return "".b if n == 0
|
|
216
|
+
buf = "".b
|
|
217
|
+
while buf.bytesize < n
|
|
218
|
+
chunk = @socket.read(n - buf.bytesize)
|
|
219
|
+
raise EOFError, "Socket closed mid-frame" if chunk.nil? || chunk.empty?
|
|
220
|
+
buf << chunk
|
|
221
|
+
end
|
|
222
|
+
buf
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def dispatch(frame_obj)
|
|
226
|
+
channel_id = frame_obj.channel
|
|
227
|
+
|
|
228
|
+
case frame_obj
|
|
229
|
+
when AMQ::Protocol::MethodFrame
|
|
230
|
+
method = frame_obj.decode_payload
|
|
231
|
+
|
|
232
|
+
# Broker-initiated Connection::Close must be acknowledged immediately.
|
|
233
|
+
# Without a timely CloseOk the broker waits (up to ~30 s) before
|
|
234
|
+
# force-closing the TCP connection, e.g. when a vhost is deleted while
|
|
235
|
+
# a connection is still open. We send CloseOk synchronously here,
|
|
236
|
+
# then route the method to channel 0 (so wait_channel0_method can also
|
|
237
|
+
# see it and raise a proper error if it is active), and finally trigger
|
|
238
|
+
# recovery so the session reconnects.
|
|
239
|
+
if channel_id == 0 && method.is_a?(AMQ::Protocol::Connection::Close)
|
|
240
|
+
begin
|
|
241
|
+
@socket_sem.acquire do
|
|
242
|
+
@socket.write(AMQ::Protocol::Connection::CloseOk.encode.encode)
|
|
243
|
+
end
|
|
244
|
+
rescue => e
|
|
245
|
+
@logger.warn("FrameIO: could not send Connection::CloseOk — #{e.message}")
|
|
246
|
+
end
|
|
247
|
+
route_to_channel(0, [:method, method])
|
|
248
|
+
trigger_recovery(
|
|
249
|
+
ConnectionError.new(code: method.reply_code, text: method.reply_text)
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
route_to_channel(channel_id, [:method, method])
|
|
255
|
+
|
|
256
|
+
when AMQ::Protocol::HeaderFrame
|
|
257
|
+
# A zero-length body has no body frames at all: neither RabbitMQ nor
|
|
258
|
+
# amq-protocol emits one. Route now, or the content would sit here
|
|
259
|
+
# forever and the next method frame would be taken as its body.
|
|
260
|
+
if frame_obj.body_size == 0
|
|
261
|
+
route_to_channel(channel_id, [:content, frame_obj, "".b])
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
@content_state[channel_id] = {
|
|
266
|
+
header: frame_obj,
|
|
267
|
+
body_parts: [],
|
|
268
|
+
remaining: frame_obj.body_size,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
when AMQ::Protocol::BodyFrame
|
|
272
|
+
state = @content_state[channel_id]
|
|
273
|
+
unless state
|
|
274
|
+
@logger.warn("Body frame on channel #{channel_id} without content header — dropped")
|
|
275
|
+
return
|
|
276
|
+
end
|
|
277
|
+
chunk = frame_obj.payload
|
|
278
|
+
state[:body_parts] << chunk
|
|
279
|
+
state[:remaining] -= chunk.bytesize
|
|
280
|
+
|
|
281
|
+
if state[:remaining] <= 0
|
|
282
|
+
body = state[:body_parts].join.b
|
|
283
|
+
@content_state.delete(channel_id)
|
|
284
|
+
route_to_channel(channel_id, [:content, state[:header], body])
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
when AMQ::Protocol::HeartbeatFrame
|
|
288
|
+
route_to_channel(0, [:heartbeat])
|
|
289
|
+
|
|
290
|
+
else
|
|
291
|
+
@logger.warn("Unknown frame type #{frame_obj.class} on channel #{channel_id} — dropped")
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def route_to_channel(channel_id, message)
|
|
296
|
+
queue = @channels[channel_id]
|
|
297
|
+
if queue
|
|
298
|
+
queue.push(message)
|
|
299
|
+
else
|
|
300
|
+
@logger.debug("No queue for channel #{channel_id} — frame dropped (#{message.first})")
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Overridden by Session to initiate reconnect.
|
|
305
|
+
def trigger_recovery(error)
|
|
306
|
+
@running = false
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsyncRabbitMQ
|
|
4
|
+
# The default logger: warnings and errors, one line each, to $stderr.
|
|
5
|
+
#
|
|
6
|
+
# Anything that responds to +debug+, +info+, +warn+ and +error+ can be passed
|
|
7
|
+
# as +logger:+ instead, and that is the expected thing to do in an
|
|
8
|
+
# application: Ruby's Logger, Rails.logger, a Console wrapper, your own
|
|
9
|
+
# object. The gem deliberately does not depend on the logger gem, which
|
|
10
|
+
# stopped being a default gem in Ruby 4.0, so the choice of logging stack
|
|
11
|
+
# stays with the application.
|
|
12
|
+
#
|
|
13
|
+
# AsyncRabbitMQ::Session.new(logger: Rails.logger)
|
|
14
|
+
# AsyncRabbitMQ::Session.new(logger: Logger.new($stdout, level: Logger::INFO))
|
|
15
|
+
# AsyncRabbitMQ::Session.new(logger: AsyncRabbitMQ::Log.silent)
|
|
16
|
+
# AsyncRabbitMQ::Session.new(logger: AsyncRabbitMQ::Log.new($stdout, level: :debug))
|
|
17
|
+
class Log
|
|
18
|
+
LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
|
|
19
|
+
DEFAULT_LEVEL = :warn
|
|
20
|
+
|
|
21
|
+
attr_reader :level
|
|
22
|
+
|
|
23
|
+
# +io+ nil discards everything.
|
|
24
|
+
def initialize(io = $stderr, level: DEFAULT_LEVEL)
|
|
25
|
+
@io = io
|
|
26
|
+
self.level = level
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Accepts a symbol (:debug) or Logger's integer constants (Logger::DEBUG).
|
|
30
|
+
def level=(value)
|
|
31
|
+
@level = value.is_a?(Integer) ? (LEVELS.key(value) || DEFAULT_LEVEL) : value.to_sym
|
|
32
|
+
@threshold = LEVELS.fetch(@level, LEVELS[DEFAULT_LEVEL])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Discards everything. Useful in tests and in one-shot scripts.
|
|
36
|
+
def self.silent
|
|
37
|
+
new(nil)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
LEVELS.each_key do |name|
|
|
41
|
+
define_method(name) do |message = nil, &block|
|
|
42
|
+
return nil if @io.nil? || LEVELS.fetch(name) < @threshold
|
|
43
|
+
|
|
44
|
+
text = message.nil? && block ? block.call : message
|
|
45
|
+
@io.puts("#{name.to_s.upcase.ljust(5)} async-rabbitmq: #{text}")
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
define_method(:"#{name}?") { !@io.nil? && LEVELS.fetch(name) >= @threshold }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsyncRabbitMQ
|
|
4
|
+
# Structured events for metrics, tracing and debugging.
|
|
5
|
+
#
|
|
6
|
+
# Nothing is emitted until something subscribes. Every call site goes through
|
|
7
|
+
# Instrumented#instrument, which checks for subscribers before running the
|
|
8
|
+
# block that builds the payload, so an unwatched session pays one method call
|
|
9
|
+
# and one array check per event and allocates nothing.
|
|
10
|
+
#
|
|
11
|
+
# session.on_event { |name, payload| logger.info("#{name} #{payload}") }
|
|
12
|
+
# session.on_event("message.") { |name, payload| statsd.increment(name) }
|
|
13
|
+
# session.on_event("channel.rpc") { |_, p| histogram.record(p[:duration]) }
|
|
14
|
+
# session.on_event(/recovery/) { |name, _| pager.note(name) }
|
|
15
|
+
#
|
|
16
|
+
# A pattern is nil for everything, a String for one event name or, ending in
|
|
17
|
+
# a dot, a prefix, or a Regexp. Subscribe returns a handle for #unsubscribe.
|
|
18
|
+
#
|
|
19
|
+
# A subscriber that raises is logged and skipped: instrumentation must never
|
|
20
|
+
# take the connection down with it.
|
|
21
|
+
class Notifier
|
|
22
|
+
# The events the client emits. Names are API: they do not change without a
|
|
23
|
+
# major version. Payload keys are documented in README.md.
|
|
24
|
+
EVENTS = %w[
|
|
25
|
+
connection.open
|
|
26
|
+
connection.closed
|
|
27
|
+
connection.lost
|
|
28
|
+
connection.blocked
|
|
29
|
+
connection.unblocked
|
|
30
|
+
recovery.attempt
|
|
31
|
+
recovery.succeeded
|
|
32
|
+
recovery.exhausted
|
|
33
|
+
heartbeat.sent
|
|
34
|
+
channel.open
|
|
35
|
+
channel.closed
|
|
36
|
+
channel.rpc
|
|
37
|
+
consumer.registered
|
|
38
|
+
consumer.cancelled
|
|
39
|
+
message.published
|
|
40
|
+
message.confirmed
|
|
41
|
+
message.returned
|
|
42
|
+
message.consumed
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
def initialize(logger: nil)
|
|
46
|
+
@subscribers = []
|
|
47
|
+
@logger = logger
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def subscribe(pattern = nil, &block)
|
|
51
|
+
raise ArgumentError, "a subscriber block is required" unless block
|
|
52
|
+
|
|
53
|
+
@subscribers << [pattern, block]
|
|
54
|
+
block
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Remove a subscriber using the handle subscribe returned.
|
|
58
|
+
def unsubscribe(handle)
|
|
59
|
+
@subscribers.reject! { |(_, block)| block.equal?(handle) }
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def subscribed?
|
|
64
|
+
!@subscribers.empty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def subscriber_count
|
|
68
|
+
@subscribers.size
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Called by Instrumented#instrument once the payload exists.
|
|
72
|
+
def publish(name, payload)
|
|
73
|
+
# Snapshot: a subscriber that unsubscribes itself (or another) mutates
|
|
74
|
+
# @subscribers under the iterator, which skips the next one along.
|
|
75
|
+
@subscribers.dup.each do |(pattern, block)|
|
|
76
|
+
next unless match?(pattern, name)
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
block.call(name, payload)
|
|
80
|
+
rescue => e
|
|
81
|
+
@logger&.error("Event subscriber for #{name} raised #{e.class}: #{e.message}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def match?(pattern, name)
|
|
90
|
+
case pattern
|
|
91
|
+
when nil then true
|
|
92
|
+
when String then pattern.end_with?(".") ? name.start_with?(pattern) : name == pattern
|
|
93
|
+
when Regexp then pattern.match?(name)
|
|
94
|
+
else pattern === name
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Mixed into Session and Channel, both of which hold a @notifier.
|
|
100
|
+
module Instrumented
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Emit +name+ with the payload the block returns. The block runs only when
|
|
104
|
+
# something is subscribed, so building the payload costs nothing otherwise.
|
|
105
|
+
def instrument(name)
|
|
106
|
+
notifier = @notifier
|
|
107
|
+
return unless notifier&.subscribed?
|
|
108
|
+
|
|
109
|
+
notifier.publish(name, yield)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# For call sites that need to time the work: returns nil when nobody is
|
|
113
|
+
# listening, so the caller can skip the clock reads too.
|
|
114
|
+
def instrument_clock
|
|
115
|
+
@notifier&.subscribed? ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def instrument_elapsed(started)
|
|
119
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "async/pool"
|
|
3
|
+
rescue LoadError
|
|
4
|
+
raise LoadError, "AsyncRabbitMQ::Pool needs the async-pool gem, which this gem does not " \
|
|
5
|
+
"depend on: add gem \"async-pool\" to your Gemfile"
|
|
6
|
+
end
|
|
7
|
+
require_relative "session"
|
|
8
|
+
|
|
9
|
+
module AsyncRabbitMQ
|
|
10
|
+
# Opt-in connection pool backed by Async::Pool::Controller.
|
|
11
|
+
#
|
|
12
|
+
# Each pooled resource is a connected Session. A Session advertises its
|
|
13
|
+
# negotiated channel_max as its +concurrency+, so the controller hands the
|
|
14
|
+
# same Session to many fibers at once and only opens another connection
|
|
15
|
+
# when every existing one is saturated or unusable.
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# pool = AsyncRabbitMQ::Pool.new(max: 5, host: "localhost")
|
|
19
|
+
# pool.acquire { |session| session.with_channel { |ch| ch.basic_publish(...) } }
|
|
20
|
+
# pool.close
|
|
21
|
+
class Pool
|
|
22
|
+
def initialize(max: 4, **session_opts)
|
|
23
|
+
@session_opts = session_opts
|
|
24
|
+
constructor = -> { Session.new(**@session_opts).tap(&:connect) }
|
|
25
|
+
@pool = Async::Pool::Controller.new(constructor, limit: max)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Acquire a connected Session. With a block, the Session is released when
|
|
29
|
+
# the block returns; without one, the caller must call #release.
|
|
30
|
+
def acquire(&block)
|
|
31
|
+
@pool.acquire(&block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def release(session)
|
|
35
|
+
@pool.release(session)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Close every pooled Session.
|
|
39
|
+
def close
|
|
40
|
+
@pool.close
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module AsyncRabbitMQ
|
|
2
|
+
# Represents an AMQP queue bound to a channel.
|
|
3
|
+
# Created via channel.queue("name", durable: true).
|
|
4
|
+
class Queue
|
|
5
|
+
# Queue types RabbitMQ knows, for Channel#durable_queue(name, type).
|
|
6
|
+
module Types
|
|
7
|
+
CLASSIC = "classic"
|
|
8
|
+
QUORUM = "quorum"
|
|
9
|
+
STREAM = "stream"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :name, :message_count, :consumer_count
|
|
13
|
+
|
|
14
|
+
def initialize(name, message_count, consumer_count, channel, durable: false, exclusive: false, auto_delete: false)
|
|
15
|
+
@name = name
|
|
16
|
+
@message_count = message_count
|
|
17
|
+
@consumer_count = consumer_count
|
|
18
|
+
@channel = channel
|
|
19
|
+
@durable = durable
|
|
20
|
+
@exclusive = exclusive
|
|
21
|
+
@auto_delete = auto_delete
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def durable?
|
|
25
|
+
@durable
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def exclusive?
|
|
29
|
+
@exclusive
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def auto_delete?
|
|
33
|
+
@auto_delete
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Internal: topology recovery re-declared this server-named queue under a
|
|
37
|
+
# new broker-generated name.
|
|
38
|
+
def update_name_to(new_name)
|
|
39
|
+
@name = new_name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def server_named?
|
|
43
|
+
@name.start_with?("amq.gen-")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Re-declare with passive: true to refresh message_count and consumer_count.
|
|
47
|
+
def status
|
|
48
|
+
q = @channel.queue(@name, passive: true)
|
|
49
|
+
@message_count = q.message_count
|
|
50
|
+
@consumer_count = q.consumer_count
|
|
51
|
+
{ message_count: @message_count, consumer_count: @consumer_count }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Synchronously fetch one message: [delivery_info, header, body], or nil if
|
|
55
|
+
# the queue is empty. Defaults to manual acknowledgement like Bunny: a
|
|
56
|
+
# message fetched and then dropped by the caller is requeued, not lost.
|
|
57
|
+
def pop(manual_ack: true)
|
|
58
|
+
@channel.basic_get(@name, manual_ack: manual_ack)
|
|
59
|
+
end
|
|
60
|
+
alias get pop
|
|
61
|
+
|
|
62
|
+
def subscribe(manual_ack: false, **opts, &block)
|
|
63
|
+
@channel.basic_consume(@name, manual_ack: manual_ack, **opts, &block)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def publish(payload, routing_key: @name, **opts)
|
|
67
|
+
@channel.basic_publish(payload, routing_key: routing_key, **opts)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def bind(exchange:, routing_key: "", arguments: {})
|
|
71
|
+
@channel.queue_bind(@name, exchange: exchange, routing_key: routing_key, arguments: arguments)
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unbind(exchange:, routing_key: "", arguments: {})
|
|
76
|
+
@channel.queue_unbind(@name, exchange: exchange, routing_key: routing_key, arguments: arguments)
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def delete(if_unused: false, if_empty: false)
|
|
81
|
+
@channel.queue_delete(@name, if_unused: if_unused, if_empty: if_empty)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def purge
|
|
85
|
+
@channel.queue_purge(@name)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|