kubemq 1.0.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 +30 -0
- data/LICENSE +201 -0
- data/README.md +237 -0
- data/lib/kubemq/base_client.rb +180 -0
- data/lib/kubemq/cancellation_token.rb +63 -0
- data/lib/kubemq/channel_info.rb +84 -0
- data/lib/kubemq/configuration.rb +247 -0
- data/lib/kubemq/cq/client.rb +446 -0
- data/lib/kubemq/cq/command_message.rb +59 -0
- data/lib/kubemq/cq/command_received.rb +52 -0
- data/lib/kubemq/cq/command_response.rb +44 -0
- data/lib/kubemq/cq/command_response_message.rb +58 -0
- data/lib/kubemq/cq/commands_subscription.rb +45 -0
- data/lib/kubemq/cq/queries_subscription.rb +45 -0
- data/lib/kubemq/cq/query_message.rb +70 -0
- data/lib/kubemq/cq/query_received.rb +52 -0
- data/lib/kubemq/cq/query_response.rb +59 -0
- data/lib/kubemq/cq/query_response_message.rb +67 -0
- data/lib/kubemq/error_codes.rb +181 -0
- data/lib/kubemq/errors/error_mapper.rb +134 -0
- data/lib/kubemq/errors.rb +276 -0
- data/lib/kubemq/interceptors/auth_interceptor.rb +78 -0
- data/lib/kubemq/interceptors/error_mapping_interceptor.rb +75 -0
- data/lib/kubemq/interceptors/metrics_interceptor.rb +95 -0
- data/lib/kubemq/interceptors/retry_interceptor.rb +119 -0
- data/lib/kubemq/proto/kubemq_pb.rb +43 -0
- data/lib/kubemq/proto/kubemq_services_pb.rb +35 -0
- data/lib/kubemq/pubsub/client.rb +475 -0
- data/lib/kubemq/pubsub/event_message.rb +52 -0
- data/lib/kubemq/pubsub/event_received.rb +48 -0
- data/lib/kubemq/pubsub/event_send_result.rb +31 -0
- data/lib/kubemq/pubsub/event_sender.rb +112 -0
- data/lib/kubemq/pubsub/event_store_message.rb +53 -0
- data/lib/kubemq/pubsub/event_store_received.rb +47 -0
- data/lib/kubemq/pubsub/event_store_result.rb +33 -0
- data/lib/kubemq/pubsub/event_store_sender.rb +164 -0
- data/lib/kubemq/pubsub/events_store_subscription.rb +81 -0
- data/lib/kubemq/pubsub/events_subscription.rb +43 -0
- data/lib/kubemq/queues/client.rb +366 -0
- data/lib/kubemq/queues/downstream_receiver.rb +247 -0
- data/lib/kubemq/queues/queue_message.rb +99 -0
- data/lib/kubemq/queues/queue_message_received.rb +148 -0
- data/lib/kubemq/queues/queue_poll_request.rb +77 -0
- data/lib/kubemq/queues/queue_poll_response.rb +138 -0
- data/lib/kubemq/queues/queue_send_result.rb +49 -0
- data/lib/kubemq/queues/upstream_sender.rb +180 -0
- data/lib/kubemq/server_info.rb +57 -0
- data/lib/kubemq/subscription.rb +98 -0
- data/lib/kubemq/telemetry/otel.rb +64 -0
- data/lib/kubemq/telemetry/semconv.rb +51 -0
- data/lib/kubemq/transport/channel_manager.rb +212 -0
- data/lib/kubemq/transport/converter.rb +287 -0
- data/lib/kubemq/transport/grpc_transport.rb +411 -0
- data/lib/kubemq/transport/message_buffer.rb +105 -0
- data/lib/kubemq/transport/reconnect_manager.rb +111 -0
- data/lib/kubemq/transport/state_machine.rb +150 -0
- data/lib/kubemq/types.rb +80 -0
- data/lib/kubemq/validation/validator.rb +216 -0
- data/lib/kubemq/version.rb +6 -0
- data/lib/kubemq.rb +118 -0
- metadata +138 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module KubeMQ
|
|
6
|
+
# Client for KubeMQ message queues — guaranteed delivery with acknowledgement.
|
|
7
|
+
#
|
|
8
|
+
# Provides two APIs: a **stream API** (primary, recommended) using persistent
|
|
9
|
+
# gRPC bidirectional streams for high throughput, and a **simple API**
|
|
10
|
+
# (secondary) using unary RPCs for low-volume use cases.
|
|
11
|
+
#
|
|
12
|
+
# Inherits connection management and channel CRUD from {BaseClient}.
|
|
13
|
+
#
|
|
14
|
+
# @example Stream API — send and poll
|
|
15
|
+
# client = KubeMQ::QueuesClient.new(address: "localhost:50000")
|
|
16
|
+
#
|
|
17
|
+
# msg = KubeMQ::Queues::QueueMessage.new(channel: "tasks", body: "work-item")
|
|
18
|
+
# client.send_queue_message_stream(msg)
|
|
19
|
+
#
|
|
20
|
+
# request = KubeMQ::Queues::QueuePollRequest.new(
|
|
21
|
+
# channel: "tasks", max_items: 10, wait_timeout: 5
|
|
22
|
+
# )
|
|
23
|
+
# response = client.poll(request)
|
|
24
|
+
# response.messages.each do |m|
|
|
25
|
+
# process(m.body)
|
|
26
|
+
# m.ack
|
|
27
|
+
# end
|
|
28
|
+
# client.close
|
|
29
|
+
#
|
|
30
|
+
# @see Queues::QueueMessage
|
|
31
|
+
# @see Queues::QueuePollRequest
|
|
32
|
+
# @see Queues::QueuePollResponse
|
|
33
|
+
class QueuesClient < BaseClient
|
|
34
|
+
def initialize(address: nil, client_id: nil, auth_token: nil, config: nil, **options)
|
|
35
|
+
super
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
@closed = false
|
|
38
|
+
@stream_mutex = Mutex.new
|
|
39
|
+
@upstream_sender = nil
|
|
40
|
+
@downstream_receiver = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# --- Stream API (Primary) ---
|
|
44
|
+
|
|
45
|
+
# Creates a new upstream sender for publishing queue messages over a
|
|
46
|
+
# persistent gRPC stream.
|
|
47
|
+
#
|
|
48
|
+
# @return [Queues::UpstreamSender] streaming sender bound to this client
|
|
49
|
+
#
|
|
50
|
+
# @raise [ClientClosedError] if the client has been closed
|
|
51
|
+
# @raise [ConnectionError] if unable to reach the broker
|
|
52
|
+
#
|
|
53
|
+
# @see Queues::UpstreamSender
|
|
54
|
+
def create_upstream_sender
|
|
55
|
+
ensure_connected!
|
|
56
|
+
Queues::UpstreamSender.new(transport: @transport, client_id: @config.client_id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Creates a new downstream receiver for polling queue messages over a
|
|
60
|
+
# persistent gRPC stream.
|
|
61
|
+
#
|
|
62
|
+
# @return [Queues::DownstreamReceiver] streaming receiver bound to this client
|
|
63
|
+
#
|
|
64
|
+
# @raise [ClientClosedError] if the client has been closed
|
|
65
|
+
# @raise [ConnectionError] if unable to reach the broker
|
|
66
|
+
#
|
|
67
|
+
# @see Queues::DownstreamReceiver
|
|
68
|
+
def create_downstream_receiver
|
|
69
|
+
ensure_connected!
|
|
70
|
+
Queues::DownstreamReceiver.new(transport: @transport, client_id: @config.client_id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Sends a single queue message via the stream API.
|
|
74
|
+
#
|
|
75
|
+
# Lazily creates an internal {Queues::UpstreamSender} on first call.
|
|
76
|
+
# If the stream breaks, the sender is reset and a {StreamBrokenError}
|
|
77
|
+
# is raised — retry the call to establish a new stream.
|
|
78
|
+
#
|
|
79
|
+
# @param message [Queues::QueueMessage] message to enqueue
|
|
80
|
+
#
|
|
81
|
+
# @return [Queues::QueueSendResult] send confirmation with timestamp
|
|
82
|
+
#
|
|
83
|
+
# @raise [ValidationError] if the channel name is invalid
|
|
84
|
+
# @raise [StreamBrokenError] if the upstream gRPC stream broke
|
|
85
|
+
#
|
|
86
|
+
# @note Auto-creates an upstream sender on first call.
|
|
87
|
+
#
|
|
88
|
+
# @see Queues::QueueMessage
|
|
89
|
+
# @see Queues::QueueSendResult
|
|
90
|
+
def send_queue_message_stream(message)
|
|
91
|
+
Validator.validate_channel!(message.channel, allow_wildcards: false)
|
|
92
|
+
sender = @stream_mutex.synchronize do
|
|
93
|
+
@upstream_sender ||= create_upstream_sender
|
|
94
|
+
end
|
|
95
|
+
results = sender.publish([message])
|
|
96
|
+
results.first
|
|
97
|
+
rescue StreamBrokenError
|
|
98
|
+
@stream_mutex.synchronize { @upstream_sender = nil }
|
|
99
|
+
raise
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Polls for queue messages using the stream API.
|
|
103
|
+
#
|
|
104
|
+
# Lazily creates an internal {Queues::DownstreamReceiver} on first call.
|
|
105
|
+
# If the stream breaks, the receiver is reset and a {StreamBrokenError}
|
|
106
|
+
# is raised — retry the call to establish a new stream.
|
|
107
|
+
#
|
|
108
|
+
# @param request [Queues::QueuePollRequest] poll parameters (channel,
|
|
109
|
+
# max_items, wait_timeout, auto_ack)
|
|
110
|
+
#
|
|
111
|
+
# @return [Queues::QueuePollResponse] response containing messages and
|
|
112
|
+
# batch-level action methods
|
|
113
|
+
#
|
|
114
|
+
# @raise [StreamBrokenError] if the downstream gRPC stream broke
|
|
115
|
+
# @raise [TimeoutError] if the poll wait timeout expires with no messages
|
|
116
|
+
#
|
|
117
|
+
# @example
|
|
118
|
+
# request = KubeMQ::Queues::QueuePollRequest.new(
|
|
119
|
+
# channel: "tasks", max_items: 10, wait_timeout: 5
|
|
120
|
+
# )
|
|
121
|
+
# response = client.poll(request)
|
|
122
|
+
# response.messages.each { |m| m.ack }
|
|
123
|
+
#
|
|
124
|
+
# @see Queues::QueuePollRequest
|
|
125
|
+
# @see Queues::QueuePollResponse
|
|
126
|
+
def poll(request)
|
|
127
|
+
receiver = @stream_mutex.synchronize do
|
|
128
|
+
@downstream_receiver ||= create_downstream_receiver
|
|
129
|
+
end
|
|
130
|
+
receiver.poll(request)
|
|
131
|
+
rescue StreamBrokenError
|
|
132
|
+
@stream_mutex.synchronize { @downstream_receiver = nil }
|
|
133
|
+
raise
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- Simple API (Secondary) ---
|
|
137
|
+
|
|
138
|
+
# Sends a single queue message via a unary RPC call.
|
|
139
|
+
#
|
|
140
|
+
# For higher throughput, prefer {#send_queue_message_stream} or
|
|
141
|
+
# {#create_upstream_sender} instead.
|
|
142
|
+
#
|
|
143
|
+
# @param message [Queues::QueueMessage] message to enqueue
|
|
144
|
+
#
|
|
145
|
+
# @return [Queues::QueueSendResult] send confirmation with timestamp
|
|
146
|
+
#
|
|
147
|
+
# @raise [ValidationError] if the channel name is invalid
|
|
148
|
+
# @raise [ClientClosedError] if the client has been closed
|
|
149
|
+
# @raise [ConnectionError] if unable to reach the broker
|
|
150
|
+
#
|
|
151
|
+
# @see Queues::QueueMessage
|
|
152
|
+
# @see Queues::QueueSendResult
|
|
153
|
+
def send_queue_message(message)
|
|
154
|
+
Validator.validate_channel!(message.channel, allow_wildcards: false)
|
|
155
|
+
ensure_connected!
|
|
156
|
+
|
|
157
|
+
proto = Transport::Converter.queue_message_to_proto(message, @config.client_id)
|
|
158
|
+
result = @transport.kubemq_client.send_queue_message(proto)
|
|
159
|
+
|
|
160
|
+
Queues::QueueSendResult.new(
|
|
161
|
+
id: result.MessageID,
|
|
162
|
+
sent_at: result.SentAt,
|
|
163
|
+
expiration_at: result.ExpirationAt,
|
|
164
|
+
delayed_to: result.DelayedTo,
|
|
165
|
+
error: result.IsError ? result.Error : nil
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Sends multiple queue messages in a single batch via a unary RPC call.
|
|
170
|
+
#
|
|
171
|
+
# @param messages [Array<Queues::QueueMessage>] messages to enqueue
|
|
172
|
+
# @param batch_id [String, nil] identifier for this batch
|
|
173
|
+
# (auto-generated UUID if nil)
|
|
174
|
+
#
|
|
175
|
+
# @return [Array<Queues::QueueSendResult>] per-message send results
|
|
176
|
+
#
|
|
177
|
+
# @raise [ValidationError] if +messages+ is empty or any channel is invalid
|
|
178
|
+
# @raise [ClientClosedError] if the client has been closed
|
|
179
|
+
# @raise [ConnectionError] if unable to reach the broker
|
|
180
|
+
#
|
|
181
|
+
# @see Queues::QueueMessage
|
|
182
|
+
# @see Queues::QueueSendResult
|
|
183
|
+
def send_queue_messages_batch(messages, batch_id: nil)
|
|
184
|
+
raise ValidationError, 'messages cannot be empty' if messages.nil? || messages.empty?
|
|
185
|
+
|
|
186
|
+
ensure_connected!
|
|
187
|
+
|
|
188
|
+
proto_messages = messages.map do |msg|
|
|
189
|
+
Validator.validate_channel!(msg.channel, allow_wildcards: false)
|
|
190
|
+
Transport::Converter.queue_message_to_proto(msg, @config.client_id)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
batch_request = ::Kubemq::QueueMessagesBatchRequest.new(
|
|
194
|
+
BatchID: batch_id || SecureRandom.uuid,
|
|
195
|
+
Messages: proto_messages
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
response = @transport.kubemq_client.send_queue_messages_batch(batch_request)
|
|
199
|
+
response.Results.map do |r|
|
|
200
|
+
Queues::QueueSendResult.new(
|
|
201
|
+
id: r.MessageID,
|
|
202
|
+
sent_at: r.SentAt,
|
|
203
|
+
expiration_at: r.ExpirationAt,
|
|
204
|
+
delayed_to: r.DelayedTo,
|
|
205
|
+
error: r.IsError ? r.Error : nil
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Receives queue messages via a unary RPC call (simple API).
|
|
211
|
+
#
|
|
212
|
+
# For higher throughput and transactional ack/nack/requeue, prefer the
|
|
213
|
+
# stream-based {#poll} method instead.
|
|
214
|
+
#
|
|
215
|
+
# @param channel [String] queue channel to receive from
|
|
216
|
+
# @param max_messages [Integer] maximum number of messages to return
|
|
217
|
+
# (default: +1+)
|
|
218
|
+
# @param wait_timeout_seconds [Integer] seconds to wait for messages
|
|
219
|
+
# before returning empty (default: +1+)
|
|
220
|
+
# @param peek [Boolean] if +true+, messages are not removed from the queue
|
|
221
|
+
# (default: +false+)
|
|
222
|
+
#
|
|
223
|
+
# @return [Array<Queues::QueueMessageReceived>] received messages
|
|
224
|
+
#
|
|
225
|
+
# @raise [ValidationError] if the channel name or parameters are invalid
|
|
226
|
+
# @raise [MessageError] if the broker returns an error
|
|
227
|
+
# @raise [ClientClosedError] if the client has been closed
|
|
228
|
+
# @raise [ConnectionError] if unable to reach the broker
|
|
229
|
+
#
|
|
230
|
+
# @note +wait_timeout_seconds+ is in seconds.
|
|
231
|
+
#
|
|
232
|
+
# @example
|
|
233
|
+
# messages = client.receive_queue_messages(
|
|
234
|
+
# channel: "tasks",
|
|
235
|
+
# max_messages: 5,
|
|
236
|
+
# wait_timeout_seconds: 10
|
|
237
|
+
# )
|
|
238
|
+
# messages.each { |m| puts m.body }
|
|
239
|
+
#
|
|
240
|
+
# @see Queues::QueueMessageReceived
|
|
241
|
+
def receive_queue_messages(channel:, max_messages: 1, wait_timeout_seconds: 1, peek: false)
|
|
242
|
+
Validator.validate_channel!(channel, allow_wildcards: false)
|
|
243
|
+
Validator.validate_queue_receive!(max_messages, wait_timeout_seconds)
|
|
244
|
+
ensure_connected!
|
|
245
|
+
|
|
246
|
+
request = ::Kubemq::ReceiveQueueMessagesRequest.new(
|
|
247
|
+
RequestID: SecureRandom.uuid,
|
|
248
|
+
ClientID: @config.client_id,
|
|
249
|
+
Channel: channel,
|
|
250
|
+
MaxNumberOfMessages: max_messages,
|
|
251
|
+
WaitTimeSeconds: wait_timeout_seconds,
|
|
252
|
+
IsPeak: peek
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
response = @transport.kubemq_client.receive_queue_messages(request)
|
|
256
|
+
|
|
257
|
+
if response.IsError && !response.Error.empty?
|
|
258
|
+
raise MessageError.new(response.Error, operation: 'receive_queue_messages', channel: channel)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
(response.Messages || []).map do |msg|
|
|
262
|
+
hash = Transport::Converter.proto_to_queue_message_received(msg)
|
|
263
|
+
attrs = (Queues::QueueMessageAttributes.new(**hash[:attributes]) if hash[:attributes])
|
|
264
|
+
Queues::QueueMessageReceived.new(
|
|
265
|
+
id: hash[:id],
|
|
266
|
+
channel: hash[:channel],
|
|
267
|
+
metadata: hash[:metadata],
|
|
268
|
+
body: hash[:body],
|
|
269
|
+
tags: hash[:tags],
|
|
270
|
+
attributes: attrs
|
|
271
|
+
)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Acknowledges all pending messages in a queue channel.
|
|
276
|
+
#
|
|
277
|
+
# @param channel [String] queue channel to acknowledge
|
|
278
|
+
# @param wait_timeout_seconds [Integer] seconds to wait for the operation
|
|
279
|
+
# to complete (default: +1+)
|
|
280
|
+
#
|
|
281
|
+
# @return [Integer] number of affected messages
|
|
282
|
+
#
|
|
283
|
+
# @raise [ValidationError] if the channel name is invalid
|
|
284
|
+
# @raise [MessageError] if the broker returns an error
|
|
285
|
+
# @raise [ClientClosedError] if the client has been closed
|
|
286
|
+
# @raise [ConnectionError] if unable to reach the broker
|
|
287
|
+
#
|
|
288
|
+
# @note +wait_timeout_seconds+ is in seconds.
|
|
289
|
+
def ack_all_queue_messages(channel:, wait_timeout_seconds: 1)
|
|
290
|
+
Validator.validate_channel!(channel, allow_wildcards: false)
|
|
291
|
+
ensure_connected!
|
|
292
|
+
|
|
293
|
+
request = ::Kubemq::AckAllQueueMessagesRequest.new(
|
|
294
|
+
RequestID: SecureRandom.uuid,
|
|
295
|
+
ClientID: @config.client_id,
|
|
296
|
+
Channel: channel,
|
|
297
|
+
WaitTimeSeconds: wait_timeout_seconds
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
response = @transport.kubemq_client.ack_all_queue_messages(request)
|
|
301
|
+
|
|
302
|
+
if response.IsError && !response.Error.empty?
|
|
303
|
+
raise MessageError.new(response.Error, operation: 'ack_all_queue_messages', channel: channel)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
response.AffectedMessages
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# --- Channel Management Convenience ---
|
|
310
|
+
|
|
311
|
+
# Creates a queues channel on the broker.
|
|
312
|
+
#
|
|
313
|
+
# @param channel_name [String] name for the new channel
|
|
314
|
+
# @return [Boolean] +true+ on success
|
|
315
|
+
# @raise [ClientClosedError] if the client has been closed
|
|
316
|
+
# @raise [ChannelError] if the broker rejects the operation
|
|
317
|
+
# @see BaseClient#create_channel
|
|
318
|
+
def create_queues_channel(channel_name:)
|
|
319
|
+
create_channel(channel_name: channel_name, channel_type: ChannelType::QUEUES)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Deletes a queues channel from the broker.
|
|
323
|
+
#
|
|
324
|
+
# @param channel_name [String] name of the channel to delete
|
|
325
|
+
# @return [Boolean] +true+ on success
|
|
326
|
+
# @raise [ClientClosedError] if the client has been closed
|
|
327
|
+
# @raise [ChannelError] if the broker rejects the operation
|
|
328
|
+
# @see BaseClient#delete_channel
|
|
329
|
+
def delete_queues_channel(channel_name:)
|
|
330
|
+
delete_channel(channel_name: channel_name, channel_type: ChannelType::QUEUES)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Lists queues channels, with optional name filtering.
|
|
334
|
+
#
|
|
335
|
+
# @param search [String, nil] substring filter for channel names
|
|
336
|
+
# @return [Array<ChannelInfo>] matching channels with metadata
|
|
337
|
+
# @raise [ClientClosedError] if the client has been closed
|
|
338
|
+
# @see BaseClient#list_channels
|
|
339
|
+
def list_queues_channels(search: nil)
|
|
340
|
+
list_channels(channel_type: ChannelType::QUEUES, search: search)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Closes the client, all open stream senders/receivers, and releases resources.
|
|
344
|
+
#
|
|
345
|
+
# Closes any internal {Queues::UpstreamSender} and
|
|
346
|
+
# {Queues::DownstreamReceiver} before closing the transport.
|
|
347
|
+
# This method is idempotent.
|
|
348
|
+
#
|
|
349
|
+
# @return [void]
|
|
350
|
+
def close
|
|
351
|
+
@mutex.synchronize do
|
|
352
|
+
return if @closed
|
|
353
|
+
|
|
354
|
+
@closed = true
|
|
355
|
+
end
|
|
356
|
+
@stream_mutex.synchronize do
|
|
357
|
+
begin; @upstream_sender&.close; rescue StandardError; end
|
|
358
|
+
@upstream_sender = nil
|
|
359
|
+
begin; @downstream_receiver&.close; rescue StandardError; end
|
|
360
|
+
@downstream_receiver = nil
|
|
361
|
+
end
|
|
362
|
+
ensure
|
|
363
|
+
super
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module KubeMQ
|
|
6
|
+
module Queues
|
|
7
|
+
# Streaming receiver for queue messages over a persistent gRPC downstream.
|
|
8
|
+
#
|
|
9
|
+
# Created via {QueuesClient#create_downstream_receiver}. Keeps a
|
|
10
|
+
# bidirectional stream open for polling with transactional
|
|
11
|
+
# acknowledgement. Call {#poll} to receive messages and use the
|
|
12
|
+
# returned {QueuePollResponse} for batch or per-message actions.
|
|
13
|
+
#
|
|
14
|
+
# @note This class is thread-safe. Multiple threads may call {#poll}
|
|
15
|
+
# concurrently; each waits independently for its response.
|
|
16
|
+
#
|
|
17
|
+
# @see QueuesClient#create_downstream_receiver
|
|
18
|
+
# @see QueuePollRequest
|
|
19
|
+
# @see QueuePollResponse
|
|
20
|
+
class DownstreamReceiver
|
|
21
|
+
# Maximum seconds to wait for a downstream response.
|
|
22
|
+
RESPONSE_TIMEOUT = 60
|
|
23
|
+
|
|
24
|
+
# @param transport [Transport::GrpcTransport] the gRPC transport
|
|
25
|
+
# @param client_id [String] client identifier for the stream
|
|
26
|
+
# @api private
|
|
27
|
+
def initialize(transport:, client_id:)
|
|
28
|
+
@transport = transport
|
|
29
|
+
@client_id = client_id
|
|
30
|
+
@request_queue = Queue.new
|
|
31
|
+
@pending = {}
|
|
32
|
+
@pending_mutex = Mutex.new
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
@closed = false
|
|
35
|
+
@stream_alive = true
|
|
36
|
+
@stream_error = nil
|
|
37
|
+
start_stream!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Polls the queue channel for messages.
|
|
41
|
+
#
|
|
42
|
+
# Blocks until messages arrive or the request's +wait_timeout+ elapses.
|
|
43
|
+
# Returns a {QueuePollResponse} with received messages and transaction
|
|
44
|
+
# action methods.
|
|
45
|
+
#
|
|
46
|
+
# @param request [QueuePollRequest] poll configuration
|
|
47
|
+
# @return [QueuePollResponse] received messages and transaction handle
|
|
48
|
+
#
|
|
49
|
+
# @raise [ClientClosedError] if the receiver has been closed
|
|
50
|
+
# @raise [StreamBrokenError] if the downstream gRPC stream has broken
|
|
51
|
+
# @raise [ValidationError] if the channel name or poll parameters are invalid
|
|
52
|
+
# @raise [TimeoutError] if no response is received within the timeout
|
|
53
|
+
#
|
|
54
|
+
# @see QueuePollRequest
|
|
55
|
+
# @see QueuePollResponse
|
|
56
|
+
def poll(request)
|
|
57
|
+
raise ClientClosedError if @mutex.synchronize { @closed }
|
|
58
|
+
unless @mutex.synchronize { @stream_alive }
|
|
59
|
+
raise StreamBrokenError.new(
|
|
60
|
+
'Queue downstream stream is broken',
|
|
61
|
+
cause: @mutex.synchronize { @stream_error }
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Validator.validate_channel!(request.channel, allow_wildcards: false)
|
|
66
|
+
Validator.validate_queue_poll!(request.max_items, request.wait_timeout)
|
|
67
|
+
|
|
68
|
+
request_id = SecureRandom.uuid
|
|
69
|
+
proto_request = ::Kubemq::QueuesDownstreamRequest.new(
|
|
70
|
+
RequestID: request_id,
|
|
71
|
+
ClientID: @client_id,
|
|
72
|
+
RequestTypeData: :Get,
|
|
73
|
+
Channel: request.channel,
|
|
74
|
+
MaxItems: request.max_items,
|
|
75
|
+
WaitTimeout: request.wait_timeout * 1000,
|
|
76
|
+
AutoAck: request.auto_ack
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
timeout = [request.wait_timeout + 5, RESPONSE_TIMEOUT].min
|
|
80
|
+
response = send_and_wait(request_id, proto_request, timeout)
|
|
81
|
+
raise TimeoutError, 'Queue poll timed out' unless response
|
|
82
|
+
|
|
83
|
+
build_poll_response(response)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Sends a transaction action over the downstream stream.
|
|
87
|
+
#
|
|
88
|
+
# @param transaction_id [String] the transaction to act upon
|
|
89
|
+
# @param type [Symbol] action type (e.g., +:AckAll+, +:NAckAll+, +:ReQueueAll+)
|
|
90
|
+
# @param channel [String, nil] destination channel for requeue actions
|
|
91
|
+
# @param sequence_range [Array<Integer>] message sequences for range actions
|
|
92
|
+
# @return [Object, nil] the broker response, if any
|
|
93
|
+
#
|
|
94
|
+
# @raise [ClientClosedError] if the receiver has been closed
|
|
95
|
+
# @api private
|
|
96
|
+
def send_action(transaction_id:, type:, channel: nil, sequence_range: [])
|
|
97
|
+
raise ClientClosedError, 'Downstream receiver is closed' if @mutex.synchronize { @closed }
|
|
98
|
+
|
|
99
|
+
request_id = SecureRandom.uuid
|
|
100
|
+
proto_request = ::Kubemq::QueuesDownstreamRequest.new(
|
|
101
|
+
RequestID: request_id,
|
|
102
|
+
ClientID: @client_id,
|
|
103
|
+
RequestTypeData: type,
|
|
104
|
+
RefTransactionId: transaction_id,
|
|
105
|
+
ReQueueChannel: channel || '',
|
|
106
|
+
SequenceRange: sequence_range
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
send_and_wait(request_id, proto_request, 10)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Closes the receiver and its underlying gRPC stream.
|
|
113
|
+
#
|
|
114
|
+
# Sends a close-by-client request to the broker and shuts down the
|
|
115
|
+
# stream. This method is idempotent — calling it multiple times is safe.
|
|
116
|
+
#
|
|
117
|
+
# @return [void]
|
|
118
|
+
def close
|
|
119
|
+
@mutex.synchronize do
|
|
120
|
+
return if @closed
|
|
121
|
+
|
|
122
|
+
@closed = true
|
|
123
|
+
end
|
|
124
|
+
wake_all_pending!
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
close_request = ::Kubemq::QueuesDownstreamRequest.new(
|
|
128
|
+
RequestID: SecureRandom.uuid,
|
|
129
|
+
ClientID: @client_id,
|
|
130
|
+
RequestTypeData: :CloseByClient
|
|
131
|
+
)
|
|
132
|
+
@request_queue.push(close_request)
|
|
133
|
+
sleep(0.5)
|
|
134
|
+
rescue StandardError
|
|
135
|
+
# best-effort
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@request_queue.push(:close)
|
|
139
|
+
@stream_thread&.join(5)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def send_and_wait(request_id, proto_request, timeout)
|
|
145
|
+
waiter = { mutex: Mutex.new, cv: ConditionVariable.new, result: nil }
|
|
146
|
+
@pending_mutex.synchronize { @pending[request_id] = waiter }
|
|
147
|
+
@request_queue.push(proto_request)
|
|
148
|
+
|
|
149
|
+
waiter[:mutex].synchronize do
|
|
150
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
151
|
+
while waiter[:result].nil?
|
|
152
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
153
|
+
break if remaining <= 0
|
|
154
|
+
|
|
155
|
+
waiter[:cv].wait(waiter[:mutex], remaining)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
@pending_mutex.synchronize { @pending.delete(request_id) }
|
|
159
|
+
|
|
160
|
+
waiter[:result]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def start_stream!
|
|
164
|
+
input_enum = Enumerator.new do |y|
|
|
165
|
+
loop do
|
|
166
|
+
msg = @request_queue.pop
|
|
167
|
+
break if msg == :close
|
|
168
|
+
|
|
169
|
+
y.yield(msg)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
@stream_thread = Thread.new do
|
|
174
|
+
responses = @transport.kubemq_client.queues_downstream(input_enum)
|
|
175
|
+
responses.each do |response|
|
|
176
|
+
request_id = response.RefRequestId
|
|
177
|
+
@pending_mutex.synchronize do
|
|
178
|
+
if (waiter = @pending[request_id])
|
|
179
|
+
waiter[:mutex].synchronize do
|
|
180
|
+
waiter[:result] = response
|
|
181
|
+
waiter[:cv].signal
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
rescue GRPC::BadStatus, StandardError => e
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
@stream_error = e
|
|
189
|
+
@stream_alive = false
|
|
190
|
+
end
|
|
191
|
+
@transport.on_disconnect! if e.is_a?(GRPC::Unavailable) || e.is_a?(GRPC::DeadlineExceeded)
|
|
192
|
+
wake_all_pending!
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# rubocop:disable Metrics/MethodLength -- builds poll response + per-message action_proc
|
|
197
|
+
def build_poll_response(response)
|
|
198
|
+
transaction_id = response.TransactionId
|
|
199
|
+
|
|
200
|
+
action_proc = lambda { |type, channel: nil, sequence_range: []|
|
|
201
|
+
send_action(
|
|
202
|
+
transaction_id: transaction_id,
|
|
203
|
+
type: type,
|
|
204
|
+
channel: channel,
|
|
205
|
+
sequence_range: sequence_range
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
messages = response.Messages.map do |msg|
|
|
210
|
+
hash = Transport::Converter.proto_to_queue_message_received(msg)
|
|
211
|
+
attrs = (QueueMessageAttributes.new(**hash[:attributes]) if hash[:attributes])
|
|
212
|
+
seq = attrs&.sequence || 0
|
|
213
|
+
QueueMessageReceived.new(
|
|
214
|
+
id: hash[:id],
|
|
215
|
+
channel: hash[:channel],
|
|
216
|
+
metadata: hash[:metadata],
|
|
217
|
+
body: hash[:body],
|
|
218
|
+
tags: hash[:tags],
|
|
219
|
+
attributes: attrs,
|
|
220
|
+
action_proc: action_proc,
|
|
221
|
+
sequence: seq
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
QueuePollResponse.new(
|
|
226
|
+
transaction_id: transaction_id,
|
|
227
|
+
messages: messages,
|
|
228
|
+
error: response.IsError ? response.Error : nil,
|
|
229
|
+
active_offsets: response.ActiveOffsets.to_a,
|
|
230
|
+
transaction_complete: response.TransactionComplete,
|
|
231
|
+
action_proc: action_proc
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
# rubocop:enable Metrics/MethodLength
|
|
235
|
+
|
|
236
|
+
def wake_all_pending!
|
|
237
|
+
@pending_mutex.synchronize do
|
|
238
|
+
@pending.each_value do |waiter|
|
|
239
|
+
waiter[:mutex].synchronize do
|
|
240
|
+
waiter[:cv].signal
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|