pulsar-ruby 0.1.0.pre

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.
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulsar
4
+ module Internal
5
+ # Converts broker protocol error codes into typed client exceptions.
6
+ class BrokerErrorMapper
7
+ ERROR_CLASSES = {
8
+ AuthenticationError: AuthenticationError,
9
+ AuthorizationError: AuthorizationError,
10
+ TopicNotFound: TopicNotFoundError,
11
+ ProducerBusy: ProducerBusyError,
12
+ ProducerFenced: ProducerBusyError,
13
+ ConsumerBusy: ConsumerBusyError
14
+ }.freeze
15
+
16
+ def self.from(server_error, message)
17
+ error_class = ERROR_CLASSES.fetch(server_error, BrokerError)
18
+ text = error_class == BrokerError ? "#{server_error}: #{message}" : message
19
+ error_class.new(text)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulsar
4
+ module Internal
5
+ # Builds protobuf commands for the Pulsar binary protocol.
6
+ class CommandFactory
7
+ def self.producer(topic:, producer_id:, request_id:)
8
+ Proto::BaseCommand.new(
9
+ type: :PRODUCER,
10
+ producer: Proto::CommandProducer.new(
11
+ topic: topic,
12
+ producer_id: producer_id,
13
+ request_id: request_id
14
+ )
15
+ )
16
+ end
17
+
18
+ def self.send_message(producer_id:, sequence_id:, producer_name:, publish_time:, properties: {}, key: nil,
19
+ event_time: nil)
20
+ command = Proto::BaseCommand.new(
21
+ type: :SEND,
22
+ send: Proto::CommandSend.new(
23
+ producer_id: producer_id,
24
+ sequence_id: sequence_id
25
+ )
26
+ )
27
+ metadata = Proto::MessageMetadata.new(
28
+ producer_name: producer_name,
29
+ sequence_id: sequence_id,
30
+ publish_time: publish_time,
31
+ properties: properties.map { |key_value, value| Proto::KeyValue.new(key: key_value.to_s, value: value.to_s) }
32
+ )
33
+ metadata.partition_key = key if key
34
+ metadata.event_time = event_time if event_time
35
+
36
+ [command, metadata]
37
+ end
38
+
39
+ def self.subscribe(topic:, subscription:, consumer_id:, request_id:, subscription_type: :Exclusive)
40
+ Proto::BaseCommand.new(
41
+ type: :SUBSCRIBE,
42
+ subscribe: Proto::CommandSubscribe.new(
43
+ topic: topic,
44
+ subscription: subscription,
45
+ subType: subscription_type,
46
+ consumer_id: consumer_id,
47
+ request_id: request_id
48
+ )
49
+ )
50
+ end
51
+
52
+ def self.flow(consumer_id:, permits:)
53
+ Proto::BaseCommand.new(
54
+ type: :FLOW,
55
+ flow: Proto::CommandFlow.new(
56
+ consumer_id: consumer_id,
57
+ messagePermits: permits
58
+ )
59
+ )
60
+ end
61
+
62
+ def self.ack(consumer_id:, message_id:)
63
+ Proto::BaseCommand.new(
64
+ type: :ACK,
65
+ ack: Proto::CommandAck.new(
66
+ consumer_id: consumer_id,
67
+ ack_type: :Individual,
68
+ message_id: [
69
+ Proto::MessageIdData.new(
70
+ ledgerId: message_id.ledger_id,
71
+ entryId: message_id.entry_id,
72
+ partition: message_id.partition_index,
73
+ batch_index: message_id.batch_index
74
+ )
75
+ ]
76
+ )
77
+ )
78
+ end
79
+
80
+ def self.lookup(topic:, request_id:)
81
+ Proto::BaseCommand.new(
82
+ type: :LOOKUP,
83
+ lookupTopic: Proto::CommandLookupTopic.new(
84
+ topic: topic,
85
+ request_id: request_id
86
+ )
87
+ )
88
+ end
89
+
90
+ def self.close_producer(producer_id:, request_id:)
91
+ Proto::BaseCommand.new(
92
+ type: :CLOSE_PRODUCER,
93
+ close_producer: Proto::CommandCloseProducer.new(
94
+ producer_id: producer_id,
95
+ request_id: request_id
96
+ )
97
+ )
98
+ end
99
+
100
+ def self.close_consumer(consumer_id:, request_id:)
101
+ Proto::BaseCommand.new(
102
+ type: :CLOSE_CONSUMER,
103
+ close_consumer: Proto::CommandCloseConsumer.new(
104
+ consumer_id: consumer_id,
105
+ request_id: request_id
106
+ )
107
+ )
108
+ end
109
+
110
+ def self.pong
111
+ Proto::BaseCommand.new(
112
+ type: :PONG,
113
+ pong: Proto::CommandPong.new
114
+ )
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulsar
4
+ module Internal
5
+ # Owns a broker socket, request correlation, and reader thread.
6
+ class Connection
7
+ PROTOCOL_VERSION = 21
8
+
9
+ attr_reader :server_version, :protocol_version, :max_message_size
10
+
11
+ def self.connect(host:, port:, connection_timeout:, operation_timeout:, client_version:)
12
+ transport = TcpTransport.connect(host: host, port: port, connection_timeout: connection_timeout)
13
+ new(
14
+ transport: transport,
15
+ operation_timeout: operation_timeout,
16
+ client_version: client_version
17
+ ).tap(&:connect)
18
+ end
19
+
20
+ def initialize(transport:, operation_timeout:, client_version:)
21
+ @transport = transport
22
+ @operation_timeout = operation_timeout
23
+ @client_version = client_version
24
+ @connected = false
25
+ @closed = false
26
+ @state_mutex = Mutex.new
27
+ @write_mutex = Mutex.new
28
+ @request_id = 0
29
+ @pending_requests = {}
30
+ @pending_sends = {}
31
+ @consumers = {}
32
+ end
33
+
34
+ def connect
35
+ write_connect_command
36
+ command = read_command(timeout: @operation_timeout)
37
+ raise ProtocolError, "expected CONNECTED response, got #{command.type}" unless command.type == :CONNECTED
38
+
39
+ @server_version = command.connected.server_version
40
+ @protocol_version = command.connected.protocol_version
41
+ @max_message_size = command.connected.max_message_size
42
+ @connected = true
43
+ start_reader_thread
44
+ self
45
+ rescue Error
46
+ close
47
+ raise
48
+ end
49
+
50
+ def connected?
51
+ @connected && !closed?
52
+ end
53
+
54
+ def close
55
+ return nil if closed?
56
+
57
+ @closed = true
58
+ @connected = false
59
+ @transport.close
60
+ reject_pending(ClosedError.new('connection is closed'))
61
+ @reader_thread&.join unless Thread.current == @reader_thread
62
+ nil
63
+ end
64
+
65
+ def closed?
66
+ @closed
67
+ end
68
+
69
+ def next_request_id
70
+ @state_mutex.synchronize do
71
+ @request_id += 1
72
+ end
73
+ end
74
+
75
+ def request(command, timeout: @operation_timeout)
76
+ ensure_connected!
77
+ promise = Promise.new
78
+ request_id = request_id_for(command)
79
+ add_pending_request(request_id, promise)
80
+
81
+ begin
82
+ write_frame(FrameCodec.encode_command(command))
83
+ promise.wait(timeout: timeout)
84
+ ensure
85
+ remove_pending_request(request_id)
86
+ end
87
+ end
88
+
89
+ def send_message(command, metadata, payload, timeout: @operation_timeout)
90
+ ensure_connected!
91
+ promise = Promise.new
92
+ send_key = [command['send'].producer_id, command['send'].sequence_id]
93
+ add_pending_send(send_key, promise)
94
+
95
+ begin
96
+ write_frame(FrameCodec.encode_message(command, metadata, payload))
97
+ promise.wait(timeout: timeout)
98
+ ensure
99
+ remove_pending_send(send_key)
100
+ end
101
+ end
102
+
103
+ def write_command(command)
104
+ ensure_connected!
105
+
106
+ write_frame(FrameCodec.encode_command(command))
107
+ end
108
+
109
+ def register_consumer(consumer_id, consumer)
110
+ @state_mutex.synchronize { @consumers[consumer_id] = consumer }
111
+ nil
112
+ end
113
+
114
+ def unregister_consumer(consumer_id)
115
+ @state_mutex.synchronize { @consumers.delete(consumer_id) }
116
+ nil
117
+ end
118
+
119
+ def read_frame(timeout: @operation_timeout)
120
+ ensure_connected!
121
+
122
+ read_decoded_frame(timeout: timeout)
123
+ end
124
+
125
+ private
126
+
127
+ def write_connect_command
128
+ command = Proto::BaseCommand.new(
129
+ type: :CONNECT,
130
+ connect: Proto::CommandConnect.new(
131
+ client_version: @client_version,
132
+ protocol_version: PROTOCOL_VERSION
133
+ )
134
+ )
135
+ write_frame(FrameCodec.encode_command(command))
136
+ end
137
+
138
+ def read_command(timeout:)
139
+ read_decoded_frame(timeout: timeout).command
140
+ end
141
+
142
+ def read_decoded_frame(timeout:)
143
+ size_prefix = @transport.read_exact(4, timeout: timeout)
144
+ size = size_prefix.unpack1('N')
145
+ frame = size_prefix + @transport.read_exact(size, timeout: timeout)
146
+ FrameCodec.decode_frame(frame)
147
+ end
148
+
149
+ def ensure_connected!
150
+ raise ClosedError, 'connection is closed' if closed?
151
+ raise ConnectionError, 'connection is not connected' unless connected?
152
+ end
153
+
154
+ def write_frame(frame)
155
+ @write_mutex.synchronize { @transport.write(frame) }
156
+ end
157
+
158
+ def start_reader_thread
159
+ @reader_thread = Thread.new { reader_loop }
160
+ end
161
+
162
+ def reader_loop
163
+ loop do
164
+ break if closed?
165
+
166
+ route_frame(read_decoded_frame(timeout: @operation_timeout))
167
+ end
168
+ rescue ClosedError
169
+ reject_pending(ClosedError.new('connection is closed')) unless closed?
170
+ rescue ConnectionError => e
171
+ if closed?
172
+ reject_pending(ClosedError.new('connection is closed'))
173
+ else
174
+ fail_connection(ConnectionError.new("connection lost: #{e.message}"))
175
+ end
176
+ rescue Error => e
177
+ reject_pending(e)
178
+ end
179
+
180
+ def route_frame(decoded)
181
+ command = decoded.command
182
+
183
+ case command.type
184
+ when :MESSAGE
185
+ consumer_for(command.message.consumer_id)&.handle_message(command.message, decoded.headers_and_payload)
186
+ when :PING
187
+ write_command(CommandFactory.pong)
188
+ when :SEND_RECEIPT
189
+ fulfill_pending_send(command.send_receipt.producer_id, command.send_receipt.sequence_id, command)
190
+ when :SEND_ERROR
191
+ reject_pending_send(
192
+ command.send_error.producer_id,
193
+ command.send_error.sequence_id,
194
+ BrokerErrorMapper.from(command.send_error.error, command.send_error.message)
195
+ )
196
+ when :ERROR
197
+ reject_pending_request(command.error.request_id, BrokerErrorMapper.from(command.error.error, command.error.message))
198
+ else
199
+ fulfill_pending_request(response_request_id(command), command)
200
+ end
201
+ end
202
+
203
+ def add_pending_request(request_id, promise)
204
+ @state_mutex.synchronize { @pending_requests[request_id] = promise }
205
+ end
206
+
207
+ def remove_pending_request(request_id)
208
+ @state_mutex.synchronize { @pending_requests.delete(request_id) }
209
+ end
210
+
211
+ def fulfill_pending_request(request_id, command)
212
+ promise = @state_mutex.synchronize { @pending_requests.delete(request_id) }
213
+ promise&.fulfill(command)
214
+ end
215
+
216
+ def reject_pending_request(request_id, error)
217
+ promise = @state_mutex.synchronize { @pending_requests.delete(request_id) }
218
+ promise&.reject(error)
219
+ end
220
+
221
+ def add_pending_send(send_key, promise)
222
+ @state_mutex.synchronize { @pending_sends[send_key] = promise }
223
+ end
224
+
225
+ def remove_pending_send(send_key)
226
+ @state_mutex.synchronize { @pending_sends.delete(send_key) }
227
+ end
228
+
229
+ def fulfill_pending_send(producer_id, sequence_id, command)
230
+ promise = @state_mutex.synchronize { @pending_sends.delete([producer_id, sequence_id]) }
231
+ promise&.fulfill(command)
232
+ end
233
+
234
+ def reject_pending_send(producer_id, sequence_id, error)
235
+ promise = @state_mutex.synchronize { @pending_sends.delete([producer_id, sequence_id]) }
236
+ promise&.reject(error)
237
+ end
238
+
239
+ def consumer_for(consumer_id)
240
+ @state_mutex.synchronize { @consumers[consumer_id] }
241
+ end
242
+
243
+ def reject_pending(error)
244
+ requests, sends = @state_mutex.synchronize do
245
+ [@pending_requests.values.tap { @pending_requests.clear },
246
+ @pending_sends.values.tap { @pending_sends.clear }]
247
+ end
248
+ (requests + sends).each { |promise| promise.reject(error) }
249
+ end
250
+
251
+ def fail_connection(error)
252
+ @state_mutex.synchronize { @connected = false }
253
+ reject_pending(error)
254
+ end
255
+
256
+ def request_id_for(command)
257
+ case command.type
258
+ when :PRODUCER
259
+ command.producer.request_id
260
+ when :SUBSCRIBE
261
+ command.subscribe.request_id
262
+ when :LOOKUP
263
+ command.lookupTopic.request_id
264
+ when :CLOSE_PRODUCER
265
+ command.close_producer.request_id
266
+ when :CLOSE_CONSUMER
267
+ command.close_consumer.request_id
268
+ else
269
+ raise ProtocolError, "command #{command.type} does not have a request id"
270
+ end
271
+ end
272
+
273
+ def response_request_id(command)
274
+ case command.type
275
+ when :PRODUCER_SUCCESS
276
+ command.producer_success.request_id
277
+ when :SUCCESS
278
+ command.success.request_id
279
+ when :LOOKUP_RESPONSE
280
+ command.lookupTopicResponse.request_id
281
+ else
282
+ raise ProtocolError, "unexpected broker command #{command.type}"
283
+ end
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulsar
4
+ module Internal
5
+ # Implements broker-side subscription, flow, receive, and ack behavior.
6
+ class ConsumerImpl
7
+ attr_reader :topic, :subscription, :consumer_id
8
+
9
+ def self.create(topic:, subscription:, consumer_id:, operation_timeout:, receiver_queue_size:,
10
+ connection: nil, connection_provider: nil)
11
+ connection_provider ||= -> { connection }
12
+ new(
13
+ connection_provider: connection_provider,
14
+ topic: topic,
15
+ subscription: subscription,
16
+ consumer_id: consumer_id,
17
+ operation_timeout: operation_timeout,
18
+ receiver_queue_size: receiver_queue_size
19
+ ).tap(&:attach)
20
+ end
21
+
22
+ def initialize(connection_provider:, topic:, subscription:, consumer_id:, operation_timeout:, receiver_queue_size:)
23
+ @connection_provider = connection_provider
24
+ @connection = nil
25
+ @topic = topic
26
+ @subscription = subscription
27
+ @consumer_id = consumer_id
28
+ @operation_timeout = operation_timeout
29
+ @receiver_queue_size = receiver_queue_size
30
+ @receiver_queue = BoundedQueue.new(capacity: receiver_queue_size)
31
+ @closed = false
32
+ end
33
+
34
+ def handle_message(command_message, headers_and_payload)
35
+ raise ClosedError, 'consumer is closed' if closed?
36
+
37
+ decoded = FrameCodec.decode_message_data(headers_and_payload)
38
+ @receiver_queue.push(
39
+ Message.new(
40
+ payload: decoded.payload,
41
+ message_id: message_id_from(command_message.message_id),
42
+ properties: decoded.metadata.properties.to_h { |property| [property.key, property.value] },
43
+ key: decoded.metadata.partition_key,
44
+ publish_time: decoded.metadata.publish_time,
45
+ event_time: decoded.metadata.event_time
46
+ ),
47
+ timeout: @operation_timeout
48
+ )
49
+ end
50
+
51
+ def receive(timeout: nil)
52
+ raise ClosedError, 'consumer is closed' if closed?
53
+
54
+ attach unless attached?
55
+ @receiver_queue.pop(timeout: timeout || @operation_timeout).tap do
56
+ flow(1)
57
+ end
58
+ end
59
+
60
+ def ack(message_or_message_id)
61
+ raise ClosedError, 'consumer is closed' if closed?
62
+
63
+ attach unless attached?
64
+ message_id = message_or_message_id.respond_to?(:message_id) ? message_or_message_id.message_id : message_or_message_id
65
+ @connection.write_command(CommandFactory.ack(consumer_id: consumer_id, message_id: message_id))
66
+ nil
67
+ end
68
+
69
+ def close
70
+ return nil if closed?
71
+
72
+ if attached?
73
+ request_id = @connection.next_request_id
74
+ command = CommandFactory.close_consumer(consumer_id: consumer_id, request_id: request_id)
75
+ response = @connection.request(command, timeout: @operation_timeout)
76
+ raise BrokerError, "consumer close failed: #{response.type}" unless response.type == :SUCCESS
77
+
78
+ @connection.unregister_consumer(consumer_id)
79
+ end
80
+
81
+ @receiver_queue.close
82
+ @closed = true
83
+ nil
84
+ end
85
+
86
+ def closed?
87
+ @closed
88
+ end
89
+
90
+ def flow(permits)
91
+ raise ClosedError, 'consumer is closed' if closed?
92
+
93
+ attach unless attached?
94
+ @connection.write_command(CommandFactory.flow(consumer_id: consumer_id, permits: permits))
95
+ end
96
+
97
+ private
98
+
99
+ def attach
100
+ @connection = @connection_provider.call
101
+ request_id = @connection.next_request_id
102
+ command = CommandFactory.subscribe(
103
+ topic: topic,
104
+ subscription: subscription,
105
+ consumer_id: consumer_id,
106
+ request_id: request_id
107
+ )
108
+ response = @connection.request(command, timeout: @operation_timeout)
109
+ raise BrokerError, "subscribe failed: #{response.type}" unless response.type == :SUCCESS
110
+
111
+ @connection.register_consumer(consumer_id, self)
112
+ @connection.write_command(CommandFactory.flow(consumer_id: consumer_id, permits: @receiver_queue_size))
113
+ nil
114
+ end
115
+
116
+ def attached?
117
+ @connection&.connected?
118
+ end
119
+
120
+ def message_id_from(data)
121
+ MessageId.new(
122
+ ledger_id: data.ledgerId,
123
+ entry_id: data.entryId,
124
+ partition_index: data.partition,
125
+ batch_index: data.batch_index
126
+ )
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulsar
4
+ module Internal
5
+ # Encodes and decodes Pulsar binary protocol frames.
6
+ class FrameCodec
7
+ DecodedFrame = Struct.new(:command, :headers_and_payload, keyword_init: true)
8
+ DecodedMessageData = Struct.new(:metadata, :payload, keyword_init: true)
9
+
10
+ def self.encode_command(command)
11
+ encoded_command = Proto::BaseCommand.encode(command)
12
+ [4 + encoded_command.bytesize, encoded_command.bytesize].pack('NN') + encoded_command
13
+ end
14
+
15
+ def self.encode_message(command, metadata, payload)
16
+ encoded_command = Proto::BaseCommand.encode(command)
17
+ encoded_metadata = Proto::MessageMetadata.encode(metadata)
18
+ payload = String(payload).b
19
+ total_size = 4 + encoded_command.bytesize + 4 + encoded_metadata.bytesize + payload.bytesize
20
+
21
+ [total_size, encoded_command.bytesize].pack('NN') +
22
+ encoded_command + [encoded_metadata.bytesize].pack('N') + encoded_metadata + payload
23
+ end
24
+
25
+ def self.decode_frame(frame)
26
+ frame = String(frame).b
27
+ raise ProtocolError, 'frame size prefix is incomplete' if frame.bytesize < 4
28
+
29
+ total_size = frame.byteslice(0, 4).unpack1('N')
30
+ raise ProtocolError, 'frame is incomplete' if frame.bytesize < 4 + total_size
31
+ raise ProtocolError, 'command size prefix is incomplete' if total_size < 4
32
+
33
+ command_size = frame.byteslice(4, 4).unpack1('N')
34
+ raise ProtocolError, 'command exceeds frame size' if command_size > total_size - 4
35
+
36
+ command_bytes = frame.byteslice(8, command_size)
37
+ headers_and_payload = frame.byteslice(8 + command_size, total_size - 4 - command_size) || +''
38
+
39
+ DecodedFrame.new(
40
+ command: Proto::BaseCommand.decode(command_bytes),
41
+ headers_and_payload: headers_and_payload.b
42
+ )
43
+ end
44
+
45
+ def self.decode_message_data(headers_and_payload)
46
+ headers_and_payload = String(headers_and_payload).b
47
+ raise ProtocolError, 'metadata size prefix is incomplete' if headers_and_payload.bytesize < 4
48
+
49
+ metadata_size = headers_and_payload.byteslice(0, 4).unpack1('N')
50
+ raise ProtocolError, 'metadata exceeds message data size' if metadata_size > headers_and_payload.bytesize - 4
51
+
52
+ metadata_bytes = headers_and_payload.byteslice(4, metadata_size)
53
+ payload = headers_and_payload.byteslice(4 + metadata_size, headers_and_payload.bytesize - 4 - metadata_size) || +''
54
+
55
+ DecodedMessageData.new(
56
+ metadata: Proto::MessageMetadata.decode(metadata_bytes),
57
+ payload: payload.b
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulsar
4
+ module Internal
5
+ # Resolves the broker service URL that owns a topic.
6
+ class LookupService
7
+ def initialize(connection:, operation_timeout:)
8
+ @connection = connection
9
+ @operation_timeout = operation_timeout
10
+ end
11
+
12
+ def lookup(topic)
13
+ request_id = @connection.next_request_id
14
+ response = @connection.request(
15
+ CommandFactory.lookup(topic: topic, request_id: request_id),
16
+ timeout: @operation_timeout
17
+ )
18
+
19
+ raise BrokerError, "lookup failed: #{response.type}" unless response.type == :LOOKUP_RESPONSE
20
+
21
+ lookup = response.lookupTopicResponse
22
+ raise BrokerError, "lookup failed: #{lookup.message}" unless lookup.response == :Connect
23
+
24
+ lookup.brokerServiceUrl
25
+ end
26
+ end
27
+ end
28
+ end