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,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
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubeMQ
4
+ # Current version of the kubemq-ruby SDK, following {https://semver.org Semantic Versioning}.
5
+ VERSION = '1.0.0'
6
+ end