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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/LICENSE +201 -0
  4. data/README.md +237 -0
  5. data/lib/kubemq/base_client.rb +180 -0
  6. data/lib/kubemq/cancellation_token.rb +63 -0
  7. data/lib/kubemq/channel_info.rb +84 -0
  8. data/lib/kubemq/configuration.rb +247 -0
  9. data/lib/kubemq/cq/client.rb +446 -0
  10. data/lib/kubemq/cq/command_message.rb +59 -0
  11. data/lib/kubemq/cq/command_received.rb +52 -0
  12. data/lib/kubemq/cq/command_response.rb +44 -0
  13. data/lib/kubemq/cq/command_response_message.rb +58 -0
  14. data/lib/kubemq/cq/commands_subscription.rb +45 -0
  15. data/lib/kubemq/cq/queries_subscription.rb +45 -0
  16. data/lib/kubemq/cq/query_message.rb +70 -0
  17. data/lib/kubemq/cq/query_received.rb +52 -0
  18. data/lib/kubemq/cq/query_response.rb +59 -0
  19. data/lib/kubemq/cq/query_response_message.rb +67 -0
  20. data/lib/kubemq/error_codes.rb +181 -0
  21. data/lib/kubemq/errors/error_mapper.rb +134 -0
  22. data/lib/kubemq/errors.rb +276 -0
  23. data/lib/kubemq/interceptors/auth_interceptor.rb +78 -0
  24. data/lib/kubemq/interceptors/error_mapping_interceptor.rb +75 -0
  25. data/lib/kubemq/interceptors/metrics_interceptor.rb +95 -0
  26. data/lib/kubemq/interceptors/retry_interceptor.rb +119 -0
  27. data/lib/kubemq/proto/kubemq_pb.rb +43 -0
  28. data/lib/kubemq/proto/kubemq_services_pb.rb +35 -0
  29. data/lib/kubemq/pubsub/client.rb +475 -0
  30. data/lib/kubemq/pubsub/event_message.rb +52 -0
  31. data/lib/kubemq/pubsub/event_received.rb +48 -0
  32. data/lib/kubemq/pubsub/event_send_result.rb +31 -0
  33. data/lib/kubemq/pubsub/event_sender.rb +112 -0
  34. data/lib/kubemq/pubsub/event_store_message.rb +53 -0
  35. data/lib/kubemq/pubsub/event_store_received.rb +47 -0
  36. data/lib/kubemq/pubsub/event_store_result.rb +33 -0
  37. data/lib/kubemq/pubsub/event_store_sender.rb +164 -0
  38. data/lib/kubemq/pubsub/events_store_subscription.rb +81 -0
  39. data/lib/kubemq/pubsub/events_subscription.rb +43 -0
  40. data/lib/kubemq/queues/client.rb +366 -0
  41. data/lib/kubemq/queues/downstream_receiver.rb +247 -0
  42. data/lib/kubemq/queues/queue_message.rb +99 -0
  43. data/lib/kubemq/queues/queue_message_received.rb +148 -0
  44. data/lib/kubemq/queues/queue_poll_request.rb +77 -0
  45. data/lib/kubemq/queues/queue_poll_response.rb +138 -0
  46. data/lib/kubemq/queues/queue_send_result.rb +49 -0
  47. data/lib/kubemq/queues/upstream_sender.rb +180 -0
  48. data/lib/kubemq/server_info.rb +57 -0
  49. data/lib/kubemq/subscription.rb +98 -0
  50. data/lib/kubemq/telemetry/otel.rb +64 -0
  51. data/lib/kubemq/telemetry/semconv.rb +51 -0
  52. data/lib/kubemq/transport/channel_manager.rb +212 -0
  53. data/lib/kubemq/transport/converter.rb +287 -0
  54. data/lib/kubemq/transport/grpc_transport.rb +411 -0
  55. data/lib/kubemq/transport/message_buffer.rb +105 -0
  56. data/lib/kubemq/transport/reconnect_manager.rb +111 -0
  57. data/lib/kubemq/transport/state_machine.rb +150 -0
  58. data/lib/kubemq/types.rb +80 -0
  59. data/lib/kubemq/validation/validator.rb +216 -0
  60. data/lib/kubemq/version.rb +6 -0
  61. data/lib/kubemq.rb +118 -0
  62. 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