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,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KubeMQ
|
|
4
|
+
module Transport
|
|
5
|
+
# Thread-safe bounded buffer for queuing messages during reconnection.
|
|
6
|
+
#
|
|
7
|
+
# When the transport is disconnected, outbound messages are buffered here.
|
|
8
|
+
# If the buffer reaches capacity, the oldest message is dropped to make
|
|
9
|
+
# room -- consistent with other KubeMQ SDKs.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
# @note This class is thread-safe. All operations are mutex-protected.
|
|
13
|
+
#
|
|
14
|
+
# @see GrpcTransport
|
|
15
|
+
# @see ReconnectManager
|
|
16
|
+
class MessageBuffer
|
|
17
|
+
# Default buffer capacity.
|
|
18
|
+
DEFAULT_CAPACITY = 1000
|
|
19
|
+
|
|
20
|
+
# @return [Integer] maximum number of messages this buffer can hold
|
|
21
|
+
attr_reader :capacity
|
|
22
|
+
|
|
23
|
+
# @param capacity [Integer] maximum buffer size (default: {DEFAULT_CAPACITY})
|
|
24
|
+
def initialize(capacity: DEFAULT_CAPACITY)
|
|
25
|
+
@capacity = capacity
|
|
26
|
+
@queue = Thread::SizedQueue.new(capacity)
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Adds a message to the buffer. If full, the oldest message is dropped.
|
|
31
|
+
#
|
|
32
|
+
# @param message [Object] the message to buffer
|
|
33
|
+
# @return [void]
|
|
34
|
+
def push(message)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
begin
|
|
37
|
+
@queue.pop(true) if @queue.size >= @capacity
|
|
38
|
+
rescue ThreadError
|
|
39
|
+
# queue was emptied concurrently
|
|
40
|
+
end
|
|
41
|
+
begin
|
|
42
|
+
@queue.push(message, true)
|
|
43
|
+
rescue ThreadError
|
|
44
|
+
# queue full; discard
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Drains all buffered messages. If a block is given, yields each message;
|
|
50
|
+
# otherwise returns them as an array via {#to_a}.
|
|
51
|
+
#
|
|
52
|
+
# @yield [message] each buffered message in FIFO order
|
|
53
|
+
# @yieldparam message [Object] a buffered message
|
|
54
|
+
# @return [Array, nil] array of messages if no block given; +nil+ otherwise
|
|
55
|
+
def drain(&block)
|
|
56
|
+
return to_a unless block
|
|
57
|
+
|
|
58
|
+
loop do
|
|
59
|
+
msg = @queue.pop(true)
|
|
60
|
+
block.call(msg)
|
|
61
|
+
rescue ThreadError
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Drains all messages and returns them as an array.
|
|
67
|
+
#
|
|
68
|
+
# @return [Array<Object>] all buffered messages in FIFO order
|
|
69
|
+
def to_a
|
|
70
|
+
messages = []
|
|
71
|
+
drain { |m| messages << m }
|
|
72
|
+
messages
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns the current number of buffered messages.
|
|
76
|
+
#
|
|
77
|
+
# @return [Integer] number of messages in the buffer
|
|
78
|
+
def size
|
|
79
|
+
@queue.size
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns whether the buffer is empty.
|
|
83
|
+
#
|
|
84
|
+
# @return [Boolean] +true+ if no messages are buffered
|
|
85
|
+
def empty?
|
|
86
|
+
@queue.empty?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Returns whether the buffer has reached capacity.
|
|
90
|
+
#
|
|
91
|
+
# @return [Boolean] +true+ if {#size} >= {#capacity}
|
|
92
|
+
def full?
|
|
93
|
+
@queue.size >= @capacity
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Removes all messages from the buffer.
|
|
97
|
+
#
|
|
98
|
+
# @return [nil]
|
|
99
|
+
def clear
|
|
100
|
+
drain
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KubeMQ
|
|
4
|
+
module Transport
|
|
5
|
+
# Manages automatic reconnection with exponential backoff and jitter.
|
|
6
|
+
#
|
|
7
|
+
# Spawns a background thread that repeatedly invokes the reconnect procedure
|
|
8
|
+
# until the connection succeeds or the maximum number of attempts is reached.
|
|
9
|
+
# Backoff delay is computed as:
|
|
10
|
+
# delay = min(base_interval * multiplier^(attempt-1), max_delay) +/- jitter
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
#
|
|
14
|
+
# @see GrpcTransport
|
|
15
|
+
# @see ConnectionStateMachine
|
|
16
|
+
# @see ReconnectPolicy
|
|
17
|
+
class ReconnectManager
|
|
18
|
+
# @return [Integer] current reconnect attempt counter (0 before first attempt)
|
|
19
|
+
attr_reader :attempt
|
|
20
|
+
|
|
21
|
+
# @param policy [ReconnectPolicy] backoff and retry settings
|
|
22
|
+
# @param state_machine [ConnectionStateMachine] tracks connection lifecycle
|
|
23
|
+
# @param reconnect_proc [Proc] callable that performs the actual reconnection
|
|
24
|
+
# @param on_reconnected [Proc, nil] optional callback invoked after successful reconnection
|
|
25
|
+
def initialize(policy:, state_machine:, reconnect_proc:, on_reconnected: nil)
|
|
26
|
+
@policy = policy
|
|
27
|
+
@state_machine = state_machine
|
|
28
|
+
@reconnect_proc = reconnect_proc
|
|
29
|
+
@on_reconnected = on_reconnected
|
|
30
|
+
@attempt = 0
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@thread = nil
|
|
33
|
+
@stop_flag = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Starts the background reconnect loop. No-op if already running.
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
def start
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
return if @thread&.alive?
|
|
42
|
+
|
|
43
|
+
@stop_flag = false
|
|
44
|
+
@attempt = 0
|
|
45
|
+
@thread = Thread.new { reconnect_loop }
|
|
46
|
+
@thread.name = 'kubemq-reconnect'
|
|
47
|
+
@thread.abort_on_exception = false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Signals the reconnect loop to stop and waits up to 5 seconds for
|
|
52
|
+
# the background thread to finish.
|
|
53
|
+
#
|
|
54
|
+
# @return [void]
|
|
55
|
+
def stop
|
|
56
|
+
@mutex.synchronize { @stop_flag = true }
|
|
57
|
+
@thread&.join(5)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def reconnect_loop
|
|
63
|
+
loop do
|
|
64
|
+
break if stopped? || @state_machine.closed?
|
|
65
|
+
|
|
66
|
+
@mutex.synchronize { @attempt += 1 }
|
|
67
|
+
|
|
68
|
+
if @policy.max_attempts.positive? && @attempt > @policy.max_attempts
|
|
69
|
+
@state_machine.transition!(ConnectionState::CLOSED,
|
|
70
|
+
reason: 'max reconnect attempts exceeded')
|
|
71
|
+
break
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
delay = compute_delay
|
|
75
|
+
sleep_with_check(delay)
|
|
76
|
+
break if stopped? || @state_machine.closed?
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
@reconnect_proc.call
|
|
80
|
+
@on_reconnected&.call
|
|
81
|
+
break
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
Kernel.warn("[kubemq-reconnect] attempt #{@attempt} failed: #{e.message}")
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def compute_delay
|
|
90
|
+
base = [@policy.base_interval * (@policy.multiplier**(@attempt - 1)),
|
|
91
|
+
@policy.max_delay].min
|
|
92
|
+
jitter = base * @policy.jitter_percent * ((rand * 2) - 1)
|
|
93
|
+
[base + jitter, 0.1].max
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def sleep_with_check(seconds)
|
|
97
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
98
|
+
loop do
|
|
99
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
100
|
+
break if remaining <= 0 || stopped? || @state_machine.closed?
|
|
101
|
+
|
|
102
|
+
sleep([remaining, 0.5].min)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def stopped?
|
|
107
|
+
@mutex.synchronize { @stop_flag }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KubeMQ
|
|
4
|
+
module Transport
|
|
5
|
+
# Finite state machine governing the gRPC connection lifecycle.
|
|
6
|
+
#
|
|
7
|
+
# Tracks transitions between {ConnectionState} values and fires registered
|
|
8
|
+
# callbacks on state changes. Invalid transitions raise {Error}.
|
|
9
|
+
#
|
|
10
|
+
# Valid transition graph:
|
|
11
|
+
# IDLE -> CONNECTING, CLOSED
|
|
12
|
+
# CONNECTING -> READY, CLOSED, RECONNECTING, IDLE
|
|
13
|
+
# READY -> RECONNECTING, CLOSED
|
|
14
|
+
# RECONNECTING -> CONNECTING, READY, CLOSED
|
|
15
|
+
# CLOSED -> (terminal)
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
# @note This class is thread-safe. All state reads and transitions are
|
|
19
|
+
# mutex-protected.
|
|
20
|
+
#
|
|
21
|
+
# @see GrpcTransport
|
|
22
|
+
# @see ReconnectManager
|
|
23
|
+
# @see ConnectionState
|
|
24
|
+
class ConnectionStateMachine
|
|
25
|
+
# Map of each state to its permitted successor states.
|
|
26
|
+
VALID_TRANSITIONS = {
|
|
27
|
+
ConnectionState::IDLE => [ConnectionState::CONNECTING, ConnectionState::CLOSED],
|
|
28
|
+
ConnectionState::CONNECTING => [ConnectionState::READY, ConnectionState::CLOSED,
|
|
29
|
+
ConnectionState::RECONNECTING, ConnectionState::IDLE],
|
|
30
|
+
ConnectionState::READY => [ConnectionState::RECONNECTING, ConnectionState::CLOSED],
|
|
31
|
+
ConnectionState::RECONNECTING => [ConnectionState::CONNECTING, ConnectionState::READY,
|
|
32
|
+
ConnectionState::CLOSED],
|
|
33
|
+
ConnectionState::CLOSED => []
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# @return [Integer] the current {ConnectionState} value
|
|
37
|
+
attr_reader :state
|
|
38
|
+
|
|
39
|
+
# Creates a new state machine starting in {ConnectionState::IDLE}.
|
|
40
|
+
def initialize
|
|
41
|
+
@state = ConnectionState::IDLE
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@callbacks = {
|
|
44
|
+
on_connected: nil,
|
|
45
|
+
on_disconnected: nil,
|
|
46
|
+
on_reconnecting: nil,
|
|
47
|
+
on_error: nil
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Registers a callback invoked when the connection reaches READY.
|
|
52
|
+
#
|
|
53
|
+
# @yield [server_info] called with the broker's {ServerInfo}
|
|
54
|
+
# @yieldparam server_info [ServerInfo, nil] broker information
|
|
55
|
+
# @return [void]
|
|
56
|
+
def on_connected(&block)
|
|
57
|
+
@mutex.synchronize { @callbacks[:on_connected] = block }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Registers a callback invoked when the connection reaches CLOSED.
|
|
61
|
+
#
|
|
62
|
+
# @yield [reason] called with a human-readable close reason
|
|
63
|
+
# @yieldparam reason [String] reason for disconnection
|
|
64
|
+
# @return [void]
|
|
65
|
+
def on_disconnected(&block)
|
|
66
|
+
@mutex.synchronize { @callbacks[:on_disconnected] = block }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Registers a callback invoked when the connection enters RECONNECTING.
|
|
70
|
+
#
|
|
71
|
+
# @yield [attempt] called with the current reconnect attempt number
|
|
72
|
+
# @yieldparam attempt [Integer] attempt counter
|
|
73
|
+
# @return [void]
|
|
74
|
+
def on_reconnecting(&block)
|
|
75
|
+
@mutex.synchronize { @callbacks[:on_reconnecting] = block }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Registers a callback invoked on invalid state transitions.
|
|
79
|
+
#
|
|
80
|
+
# @yield [error] called with the {Error} describing the invalid transition
|
|
81
|
+
# @yieldparam error [Error] the transition error
|
|
82
|
+
# @return [void]
|
|
83
|
+
def on_error(&block)
|
|
84
|
+
@mutex.synchronize { @callbacks[:on_error] = block }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Transitions to a new state, firing the appropriate callback.
|
|
88
|
+
#
|
|
89
|
+
# @param new_state [Integer] the target {ConnectionState} value
|
|
90
|
+
# @param context [Hash] optional context passed to the callback
|
|
91
|
+
# (+:server_info+, +:attempt+, or +:reason+)
|
|
92
|
+
# @return [void]
|
|
93
|
+
# @raise [Error] if the transition is invalid per {VALID_TRANSITIONS}
|
|
94
|
+
def transition!(new_state, **context)
|
|
95
|
+
callback = nil
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
valid = VALID_TRANSITIONS.fetch(@state, [])
|
|
98
|
+
unless valid.include?(new_state)
|
|
99
|
+
error = KubeMQ::Error.new("Invalid state transition from #{@state} to #{new_state}")
|
|
100
|
+
err_cb = @callbacks[:on_error]
|
|
101
|
+
err_cb&.call(error)
|
|
102
|
+
raise error
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
old_state = @state
|
|
106
|
+
@state = new_state
|
|
107
|
+
callback = resolve_callback(new_state, old_state, context)
|
|
108
|
+
end
|
|
109
|
+
callback&.call
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns the current state with mutex synchronization.
|
|
113
|
+
#
|
|
114
|
+
# @return [Integer] the current {ConnectionState} value
|
|
115
|
+
def current_state
|
|
116
|
+
@mutex.synchronize { @state }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Returns whether the connection is in the READY state.
|
|
120
|
+
#
|
|
121
|
+
# @return [Boolean] +true+ if connected and operational
|
|
122
|
+
def ready?
|
|
123
|
+
current_state == ConnectionState::READY
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Returns whether the connection has been permanently closed.
|
|
127
|
+
#
|
|
128
|
+
# @return [Boolean] +true+ if in the terminal CLOSED state
|
|
129
|
+
def closed?
|
|
130
|
+
current_state == ConnectionState::CLOSED
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def resolve_callback(new_state, _old_state, context)
|
|
136
|
+
case new_state
|
|
137
|
+
when ConnectionState::READY
|
|
138
|
+
cb = @callbacks[:on_connected]
|
|
139
|
+
cb ? -> { cb.call(context[:server_info]) } : nil
|
|
140
|
+
when ConnectionState::RECONNECTING
|
|
141
|
+
cb = @callbacks[:on_reconnecting]
|
|
142
|
+
cb ? -> { cb.call(context[:attempt] || 0) } : nil
|
|
143
|
+
when ConnectionState::CLOSED
|
|
144
|
+
cb = @callbacks[:on_disconnected]
|
|
145
|
+
cb ? -> { cb.call(context[:reason] || 'closed') } : nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
data/lib/kubemq/types.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KubeMQ
|
|
4
|
+
# Transport connection lifecycle states.
|
|
5
|
+
#
|
|
6
|
+
# Used by the internal {Transport::ConnectionStateMachine} to track
|
|
7
|
+
# the gRPC channel state. Inspect via {BaseClient} diagnostics.
|
|
8
|
+
#
|
|
9
|
+
# @see Transport::ConnectionStateMachine
|
|
10
|
+
module ConnectionState
|
|
11
|
+
# Initial state before any connection attempt.
|
|
12
|
+
IDLE = :idle
|
|
13
|
+
# A connection attempt is in progress.
|
|
14
|
+
CONNECTING = :connecting
|
|
15
|
+
# Connected and ready to send/receive messages.
|
|
16
|
+
READY = :ready
|
|
17
|
+
# Disconnected and attempting to reconnect.
|
|
18
|
+
RECONNECTING = :reconnecting
|
|
19
|
+
# Permanently closed; no further operations are possible.
|
|
20
|
+
CLOSED = :closed
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Numeric subscription type identifiers sent over the wire.
|
|
24
|
+
#
|
|
25
|
+
# Mapped to the protobuf +Subscribe.SubscribeTypeData+ enum.
|
|
26
|
+
# Application code typically does not use these directly — pass
|
|
27
|
+
# subscription objects to +subscribe_to_*+ methods instead.
|
|
28
|
+
#
|
|
29
|
+
# @api private
|
|
30
|
+
module SubscribeType
|
|
31
|
+
# Unspecified subscription type.
|
|
32
|
+
UNDEFINED = 0
|
|
33
|
+
# Real-time fire-and-forget events.
|
|
34
|
+
EVENTS = 1
|
|
35
|
+
# Durable events with replay capability.
|
|
36
|
+
EVENTS_STORE = 2
|
|
37
|
+
# Request/reply commands.
|
|
38
|
+
COMMANDS = 3
|
|
39
|
+
# Request/reply queries.
|
|
40
|
+
QUERIES = 4
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Numeric request type identifiers for the CQ (commands/queries) pattern.
|
|
44
|
+
#
|
|
45
|
+
# Mapped to the protobuf +Request.RequestTypeData+ enum.
|
|
46
|
+
#
|
|
47
|
+
# @api private
|
|
48
|
+
module RequestType
|
|
49
|
+
# Unspecified request type.
|
|
50
|
+
UNKNOWN = 0
|
|
51
|
+
# A command (fire-and-confirm) request.
|
|
52
|
+
COMMAND = 1
|
|
53
|
+
# A query (request/response with data) request.
|
|
54
|
+
QUERY = 2
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# String channel type identifiers used by channel management operations.
|
|
58
|
+
#
|
|
59
|
+
# Pass these constants to {BaseClient#create_channel},
|
|
60
|
+
# {BaseClient#delete_channel}, and {BaseClient#list_channels}.
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# client.list_channels(channel_type: KubeMQ::ChannelType::EVENTS)
|
|
64
|
+
#
|
|
65
|
+
# @see BaseClient#create_channel
|
|
66
|
+
# @see BaseClient#delete_channel
|
|
67
|
+
# @see BaseClient#list_channels
|
|
68
|
+
module ChannelType
|
|
69
|
+
# Pub/sub fire-and-forget events.
|
|
70
|
+
EVENTS = 'events'
|
|
71
|
+
# Durable events with persistence and replay.
|
|
72
|
+
EVENTS_STORE = 'events_store'
|
|
73
|
+
# Request/reply commands.
|
|
74
|
+
COMMANDS = 'commands'
|
|
75
|
+
# Request/reply queries.
|
|
76
|
+
QUERIES = 'queries'
|
|
77
|
+
# Message queues with acknowledgement.
|
|
78
|
+
QUEUES = 'queues'
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KubeMQ
|
|
4
|
+
# Input validation helpers used by client methods before sending requests.
|
|
5
|
+
#
|
|
6
|
+
# All methods raise {ValidationError} on invalid input with an actionable
|
|
7
|
+
# +suggestion+ field. Called internally -- not typically used in application code.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
#
|
|
11
|
+
# @see ValidationError
|
|
12
|
+
# @see BaseClient
|
|
13
|
+
# rubocop:disable Metrics/ModuleLength -- validation helpers per message type
|
|
14
|
+
module Validator
|
|
15
|
+
# Permitted characters for channel names (alphanumeric, dots, hyphens,
|
|
16
|
+
# underscores, slashes, and wildcard tokens).
|
|
17
|
+
CHANNEL_NAME_REGEX = %r{\A[a-zA-Z0-9._\-/>*]+\z}
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Validates a channel name for format and optional wildcard support.
|
|
22
|
+
#
|
|
23
|
+
# @param channel [String] the channel name to validate
|
|
24
|
+
# @param allow_wildcards [Boolean] whether +*+ and +>+ are permitted
|
|
25
|
+
# @return [void]
|
|
26
|
+
# @raise [ValidationError] if the channel is nil, empty, contains invalid
|
|
27
|
+
# characters, ends with a dot, or uses wildcards when disallowed
|
|
28
|
+
def validate_channel!(channel, allow_wildcards: false)
|
|
29
|
+
if channel.nil? || channel.to_s.strip.empty?
|
|
30
|
+
raise ValidationError.new('Channel name is required',
|
|
31
|
+
suggestion: 'Provide a non-empty channel name.')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
channel = channel.to_s
|
|
35
|
+
|
|
36
|
+
unless channel.match?(CHANNEL_NAME_REGEX)
|
|
37
|
+
raise ValidationError.new(
|
|
38
|
+
"Channel name contains invalid characters: #{channel}",
|
|
39
|
+
suggestion: 'Use only alphanumeric characters, dots, hyphens, underscores, and slashes.'
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if channel.end_with?('.')
|
|
44
|
+
raise ValidationError.new("Channel name must not end with a dot: #{channel}",
|
|
45
|
+
suggestion: 'Remove the trailing dot from the channel name.')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return if allow_wildcards
|
|
49
|
+
|
|
50
|
+
return unless channel.include?('*') || channel.include?('>')
|
|
51
|
+
|
|
52
|
+
raise ValidationError.new(
|
|
53
|
+
"Wildcards are not allowed for this channel: #{channel}",
|
|
54
|
+
suggestion: 'Use an exact channel name without wildcard characters.'
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Validates that at least one of metadata or body is non-empty.
|
|
59
|
+
#
|
|
60
|
+
# @param metadata [String, nil] message metadata
|
|
61
|
+
# @param body [String, nil] message body
|
|
62
|
+
# @return [void]
|
|
63
|
+
# @raise [ValidationError] if both metadata and body are nil or empty
|
|
64
|
+
def validate_content!(metadata, body)
|
|
65
|
+
metadata_empty = metadata.nil? || (metadata.is_a?(String) && metadata.empty?)
|
|
66
|
+
body_empty = body.nil? || (body.is_a?(String) && body.empty?)
|
|
67
|
+
return unless metadata_empty && body_empty
|
|
68
|
+
|
|
69
|
+
raise ValidationError.new('Message must have non-empty metadata or body',
|
|
70
|
+
suggestion: 'Provide either metadata or body content.')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Validates that a client ID is present and non-empty.
|
|
74
|
+
#
|
|
75
|
+
# @param client_id [String, nil] the client identifier to validate
|
|
76
|
+
# @return [void]
|
|
77
|
+
# @raise [ValidationError] if the client ID is nil or blank
|
|
78
|
+
def validate_client_id!(client_id)
|
|
79
|
+
return unless client_id.nil? || client_id.to_s.strip.empty?
|
|
80
|
+
|
|
81
|
+
raise ValidationError.new('Client ID is required',
|
|
82
|
+
suggestion: 'Provide a non-empty client ID.')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Validates that a timeout value is a positive number.
|
|
86
|
+
#
|
|
87
|
+
# @param timeout [Numeric] the timeout value to validate (in milliseconds
|
|
88
|
+
# for commands/queries)
|
|
89
|
+
# @return [void]
|
|
90
|
+
# @raise [ValidationError] if the timeout is not a positive number
|
|
91
|
+
def validate_timeout!(timeout)
|
|
92
|
+
return if timeout.is_a?(Numeric) && timeout.positive?
|
|
93
|
+
|
|
94
|
+
raise ValidationError.new("Timeout must be greater than 0, got: #{timeout.inspect}",
|
|
95
|
+
suggestion: 'Provide a positive timeout value in milliseconds.')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Validates cache parameters for query messages.
|
|
99
|
+
#
|
|
100
|
+
# @param cache_key [String, nil] the cache key (validation only applies when set)
|
|
101
|
+
# @param cache_ttl [Numeric, nil] the cache TTL in seconds
|
|
102
|
+
# @return [void]
|
|
103
|
+
# @raise [ValidationError] if +cache_key+ is set but +cache_ttl+ is not positive
|
|
104
|
+
def validate_cache!(cache_key, cache_ttl)
|
|
105
|
+
return if cache_key.nil? || cache_key.empty?
|
|
106
|
+
return if cache_ttl.is_a?(Numeric) && cache_ttl.positive?
|
|
107
|
+
|
|
108
|
+
raise ValidationError.new('cache_ttl must be > 0 when cache_key is set',
|
|
109
|
+
suggestion: 'Provide a positive cache_ttl value in seconds.')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Validates that a command/query response has required fields.
|
|
113
|
+
#
|
|
114
|
+
# @param request_id [String, nil] the originating request ID
|
|
115
|
+
# @param reply_channel [String, nil] the reply channel from the request
|
|
116
|
+
# @return [void]
|
|
117
|
+
# @raise [ValidationError] if +request_id+ or +reply_channel+ is nil or blank
|
|
118
|
+
def validate_response!(request_id, reply_channel)
|
|
119
|
+
if request_id.nil? || request_id.to_s.strip.empty?
|
|
120
|
+
raise ValidationError.new('request_id is required for response',
|
|
121
|
+
suggestion: 'Use the request_id from the received request.')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
return unless reply_channel.nil? || reply_channel.to_s.strip.empty?
|
|
125
|
+
|
|
126
|
+
raise ValidationError.new('reply_channel is required for response',
|
|
127
|
+
suggestion: 'Use the reply_channel from the received request.')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Validates events store subscription start position and its associated value.
|
|
131
|
+
#
|
|
132
|
+
# @param start_position [Integer, nil] one of the EventStoreStartPosition constants
|
|
133
|
+
# @param start_position_value [Numeric, nil] sequence number, Unix timestamp,
|
|
134
|
+
# or delta seconds depending on the start position type
|
|
135
|
+
# @return [void]
|
|
136
|
+
# @raise [ValidationError] if the start position is missing or the value is
|
|
137
|
+
# invalid for the chosen position type
|
|
138
|
+
def validate_events_store_subscription!(start_position, start_position_value)
|
|
139
|
+
if start_position.nil? || start_position.zero?
|
|
140
|
+
raise ValidationError.new(
|
|
141
|
+
'Events Store subscription requires a start position',
|
|
142
|
+
suggestion: 'Set start_position to one of: StartNewOnly(1), StartFromFirst(2), ' \
|
|
143
|
+
'StartFromLast(3), StartAtSequence(4), StartAtTime(5), StartAtTimeDelta(6).'
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
case start_position
|
|
148
|
+
when 4 # StartAtSequence
|
|
149
|
+
unless start_position_value.is_a?(Numeric) && start_position_value.positive?
|
|
150
|
+
raise ValidationError.new(
|
|
151
|
+
"StartAtSequence requires a positive sequence value, got: #{start_position_value.inspect}",
|
|
152
|
+
suggestion: 'Provide a positive sequence number.'
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
when 5 # StartAtTime
|
|
156
|
+
unless start_position_value.is_a?(Numeric) && start_position_value.positive?
|
|
157
|
+
raise ValidationError.new(
|
|
158
|
+
"StartAtTime requires a positive Unix timestamp, got: #{start_position_value.inspect}",
|
|
159
|
+
suggestion: 'Provide a Unix timestamp in nanoseconds.'
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
when 6 # StartAtTimeDelta
|
|
163
|
+
unless start_position_value.is_a?(Numeric) && start_position_value.positive?
|
|
164
|
+
raise ValidationError.new(
|
|
165
|
+
"StartAtTimeDelta requires a positive delta in seconds, got: #{start_position_value.inspect}",
|
|
166
|
+
suggestion: 'Provide a positive number of seconds to look back.'
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Validates queue poll request parameters.
|
|
173
|
+
#
|
|
174
|
+
# @param max_items [Integer] maximum number of messages to poll (must be >= 1)
|
|
175
|
+
# @param wait_timeout [Numeric] wait timeout in seconds (0..3600)
|
|
176
|
+
# @return [void]
|
|
177
|
+
# @raise [ValidationError] if +max_items+ < 1 or +wait_timeout+ is out of range
|
|
178
|
+
def validate_queue_poll!(max_items, wait_timeout)
|
|
179
|
+
if !max_items.is_a?(Integer) || max_items < 1
|
|
180
|
+
raise ValidationError.new("max_items must be >= 1, got: #{max_items.inspect}",
|
|
181
|
+
suggestion: 'Provide a positive integer for max_items.')
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
return if wait_timeout.is_a?(Numeric) && wait_timeout >= 0 && wait_timeout <= 3600
|
|
185
|
+
|
|
186
|
+
raise ValidationError.new(
|
|
187
|
+
"wait_timeout must be between 0 and 3600 seconds, got: #{wait_timeout.inspect}",
|
|
188
|
+
suggestion: 'Provide a wait_timeout between 0 and 3600 seconds.'
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Validates queue receive request parameters (simple API).
|
|
193
|
+
#
|
|
194
|
+
# @param max_messages [Integer] maximum messages to receive (1..1024)
|
|
195
|
+
# @param wait_timeout_seconds [Numeric] wait timeout in seconds (0..3600)
|
|
196
|
+
# @return [void]
|
|
197
|
+
# @raise [ValidationError] if +max_messages+ is out of range or
|
|
198
|
+
# +wait_timeout_seconds+ is out of range
|
|
199
|
+
def validate_queue_receive!(max_messages, wait_timeout_seconds)
|
|
200
|
+
if !max_messages.is_a?(Integer) || max_messages < 1 || max_messages > 1024
|
|
201
|
+
raise ValidationError.new(
|
|
202
|
+
"max_messages must be between 1 and 1024, got: #{max_messages.inspect}",
|
|
203
|
+
suggestion: 'Provide max_messages between 1 and 1024.'
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
return if wait_timeout_seconds.is_a?(Numeric) && wait_timeout_seconds >= 0 && wait_timeout_seconds <= 3600
|
|
208
|
+
|
|
209
|
+
raise ValidationError.new(
|
|
210
|
+
"wait_timeout_seconds must be between 0 and 3600, got: #{wait_timeout_seconds.inspect}",
|
|
211
|
+
suggestion: 'Provide wait_timeout_seconds between 0 and 3600.'
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
# rubocop:enable Metrics/ModuleLength
|
|
216
|
+
end
|