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.
- checksums.yaml +7 -0
- data/LICENSE +204 -0
- data/README.md +198 -0
- data/lib/pulsar/client.rb +136 -0
- data/lib/pulsar/consumer.rb +47 -0
- data/lib/pulsar/errors.rb +18 -0
- data/lib/pulsar/internal/bounded_queue.rb +78 -0
- data/lib/pulsar/internal/broker_error_mapper.rb +23 -0
- data/lib/pulsar/internal/command_factory.rb +118 -0
- data/lib/pulsar/internal/connection.rb +287 -0
- data/lib/pulsar/internal/consumer_impl.rb +130 -0
- data/lib/pulsar/internal/frame_codec.rb +62 -0
- data/lib/pulsar/internal/lookup_service.rb +28 -0
- data/lib/pulsar/internal/producer_impl.rb +146 -0
- data/lib/pulsar/internal/promise.rb +53 -0
- data/lib/pulsar/internal/tcp_transport.rb +76 -0
- data/lib/pulsar/internal/thread_runtime.rb +54 -0
- data/lib/pulsar/internal.rb +7 -0
- data/lib/pulsar/message.rb +21 -0
- data/lib/pulsar/message_id.rb +43 -0
- data/lib/pulsar/producer.rb +39 -0
- data/lib/pulsar/proto/PulsarApi_pb.rb +1002 -0
- data/lib/pulsar/proto/pulsar_api_pb.rb +3 -0
- data/lib/pulsar/version.rb +5 -0
- data/lib/pulsar.rb +26 -0
- data/proto/PulsarApi.proto +1360 -0
- metadata +84 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pulsar
|
|
4
|
+
module Internal
|
|
5
|
+
# Implements broker-side producer creation and unbatched sends.
|
|
6
|
+
class ProducerImpl
|
|
7
|
+
attr_reader :topic, :producer_id, :producer_name
|
|
8
|
+
|
|
9
|
+
def self.create(topic:, producer_id:, operation_timeout:, max_pending_messages: 1000,
|
|
10
|
+
connection: nil, connection_provider: nil)
|
|
11
|
+
connection_provider ||= -> { connection }
|
|
12
|
+
new(
|
|
13
|
+
connection_provider: connection_provider,
|
|
14
|
+
topic: topic,
|
|
15
|
+
producer_id: producer_id,
|
|
16
|
+
operation_timeout: operation_timeout,
|
|
17
|
+
max_pending_messages: max_pending_messages
|
|
18
|
+
).tap(&:attach)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(connection_provider:, topic:, producer_id:, operation_timeout:, max_pending_messages:)
|
|
22
|
+
@connection_provider = connection_provider
|
|
23
|
+
@connection = nil
|
|
24
|
+
@topic = topic
|
|
25
|
+
@producer_id = producer_id
|
|
26
|
+
@producer_name = nil
|
|
27
|
+
@operation_timeout = operation_timeout
|
|
28
|
+
@max_pending_messages = max_pending_messages
|
|
29
|
+
@pending_sends = 0
|
|
30
|
+
@pending_condition = ConditionVariable.new
|
|
31
|
+
@sequence_id = -1
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@closed = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def send(payload, properties: {}, key: nil, event_time: nil, timeout: nil)
|
|
37
|
+
raise ClosedError, 'producer is closed' if closed?
|
|
38
|
+
|
|
39
|
+
send_timeout = timeout || @operation_timeout
|
|
40
|
+
acquire_pending_send(timeout: send_timeout)
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
attach unless attached?
|
|
44
|
+
sequence_id = next_sequence_id
|
|
45
|
+
command, metadata = CommandFactory.send_message(
|
|
46
|
+
producer_id: producer_id,
|
|
47
|
+
sequence_id: sequence_id,
|
|
48
|
+
producer_name: producer_name,
|
|
49
|
+
properties: properties,
|
|
50
|
+
key: key,
|
|
51
|
+
event_time: event_time,
|
|
52
|
+
publish_time: current_time_millis
|
|
53
|
+
)
|
|
54
|
+
response = @connection.send_message(command, metadata, String(payload).b, timeout: send_timeout)
|
|
55
|
+
|
|
56
|
+
raise BrokerError, "send failed: #{response.type}" unless response.type == :SEND_RECEIPT
|
|
57
|
+
|
|
58
|
+
message_id_from(response.send_receipt.message_id)
|
|
59
|
+
ensure
|
|
60
|
+
release_pending_send
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def close
|
|
65
|
+
return nil if closed?
|
|
66
|
+
|
|
67
|
+
if attached?
|
|
68
|
+
request_id = @connection.next_request_id
|
|
69
|
+
command = CommandFactory.close_producer(producer_id: producer_id, request_id: request_id)
|
|
70
|
+
response = @connection.request(command, timeout: @operation_timeout)
|
|
71
|
+
raise BrokerError, "producer close failed: #{response.type}" unless response.type == :SUCCESS
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@closed = true
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def closed?
|
|
79
|
+
@closed
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def attach
|
|
85
|
+
@connection = @connection_provider.call
|
|
86
|
+
request_id = @connection.next_request_id
|
|
87
|
+
command = CommandFactory.producer(topic: topic, producer_id: producer_id, request_id: request_id)
|
|
88
|
+
response = @connection.request(command, timeout: @operation_timeout)
|
|
89
|
+
|
|
90
|
+
raise BrokerError, "producer creation failed: #{response.type}" unless response.type == :PRODUCER_SUCCESS
|
|
91
|
+
|
|
92
|
+
@producer_name = response.producer_success.producer_name
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def attached?
|
|
97
|
+
@connection&.connected?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def next_sequence_id
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
@sequence_id += 1
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def acquire_pending_send(timeout:)
|
|
107
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
loop do
|
|
110
|
+
raise ClosedError, 'producer is closed' if @closed
|
|
111
|
+
|
|
112
|
+
if @pending_sends < @max_pending_messages
|
|
113
|
+
@pending_sends += 1
|
|
114
|
+
return nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
118
|
+
raise TimeoutError, 'operation timed out' if remaining <= 0
|
|
119
|
+
|
|
120
|
+
@pending_condition.wait(@mutex, remaining)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def release_pending_send
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
@pending_sends -= 1
|
|
128
|
+
@pending_condition.broadcast
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def current_time_millis
|
|
133
|
+
(Time.now.to_f * 1000).to_i
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def message_id_from(data)
|
|
137
|
+
MessageId.new(
|
|
138
|
+
ledger_id: data.ledgerId,
|
|
139
|
+
entry_id: data.entryId,
|
|
140
|
+
partition_index: data.partition,
|
|
141
|
+
batch_index: data.batch_index
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pulsar
|
|
4
|
+
module Internal
|
|
5
|
+
# Thread-safe one-shot completion primitive for async broker responses.
|
|
6
|
+
class Promise
|
|
7
|
+
def initialize
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
@condition = ConditionVariable.new
|
|
10
|
+
@completed = false
|
|
11
|
+
@value = nil
|
|
12
|
+
@error = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fulfill(value)
|
|
16
|
+
complete(value, nil)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reject(error)
|
|
20
|
+
complete(nil, error)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def wait(timeout:)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
@condition.wait(@mutex, timeout) unless @completed
|
|
26
|
+
raise TimeoutError, 'operation timed out' unless @completed
|
|
27
|
+
raise @error if @error
|
|
28
|
+
|
|
29
|
+
@value
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def completed?
|
|
34
|
+
@mutex.synchronize { @completed }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def complete(value, error)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
return if @completed
|
|
42
|
+
|
|
43
|
+
@completed = true
|
|
44
|
+
@value = value
|
|
45
|
+
@error = error
|
|
46
|
+
@condition.broadcast
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
module Pulsar
|
|
7
|
+
module Internal
|
|
8
|
+
# Plain TCP transport for reading and writing broker frames.
|
|
9
|
+
class TcpTransport
|
|
10
|
+
def self.connect(host:, port:, connection_timeout:)
|
|
11
|
+
socket = Socket.tcp(host, port, connect_timeout: connection_timeout)
|
|
12
|
+
new(socket)
|
|
13
|
+
rescue SystemCallError, SocketError, IOError => e
|
|
14
|
+
raise ConnectionError, "failed to connect to #{host}:#{port}: #{e.message}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(socket)
|
|
18
|
+
@socket = socket
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@closed = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write(bytes)
|
|
24
|
+
ensure_open!
|
|
25
|
+
@socket.write(String(bytes).b)
|
|
26
|
+
nil
|
|
27
|
+
rescue SystemCallError, IOError => e
|
|
28
|
+
raise ConnectionError, "failed to write to socket: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def read_exact(size, timeout:)
|
|
32
|
+
ensure_open!
|
|
33
|
+
|
|
34
|
+
Timeout.timeout(timeout, TimeoutError) do
|
|
35
|
+
buffer = +''
|
|
36
|
+
buffer.force_encoding(Encoding::BINARY)
|
|
37
|
+
|
|
38
|
+
while buffer.bytesize < size
|
|
39
|
+
chunk = @socket.read(size - buffer.bytesize)
|
|
40
|
+
raise ConnectionError, 'socket closed while reading' if chunk.nil?
|
|
41
|
+
|
|
42
|
+
buffer << chunk
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
buffer
|
|
46
|
+
end
|
|
47
|
+
rescue TimeoutError
|
|
48
|
+
raise TimeoutError, 'operation timed out'
|
|
49
|
+
rescue SystemCallError, IOError => e
|
|
50
|
+
raise ConnectionError, "failed to read from socket: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def close
|
|
54
|
+
socket = @mutex.synchronize do
|
|
55
|
+
return nil if @closed
|
|
56
|
+
|
|
57
|
+
@closed = true
|
|
58
|
+
@socket
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
socket.close unless socket.closed?
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def closed?
|
|
66
|
+
@mutex.synchronize { @closed }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def ensure_open!
|
|
72
|
+
raise ClosedError, 'transport is closed' if closed?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pulsar
|
|
4
|
+
module Internal
|
|
5
|
+
# Tracks background threads and queues for coordinated shutdown.
|
|
6
|
+
class ThreadRuntime
|
|
7
|
+
def initialize
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
@threads = []
|
|
10
|
+
@queues = []
|
|
11
|
+
@shutdown = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def promise
|
|
15
|
+
Promise.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def queue(capacity:)
|
|
19
|
+
raise ClosedError, 'runtime is shut down' if shutdown?
|
|
20
|
+
|
|
21
|
+
BoundedQueue.new(capacity: capacity).tap do |queue|
|
|
22
|
+
@mutex.synchronize { @queues << queue }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def spawn(&block)
|
|
27
|
+
raise ClosedError, 'runtime is shut down' if shutdown?
|
|
28
|
+
|
|
29
|
+
thread = Thread.new(&block)
|
|
30
|
+
@mutex.synchronize { @threads << thread }
|
|
31
|
+
thread
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def shutdown
|
|
35
|
+
threads, queues = @mutex.synchronize do
|
|
36
|
+
@shutdown = true
|
|
37
|
+
[@threads.dup, @queues.dup].tap do
|
|
38
|
+
@threads.clear
|
|
39
|
+
@queues.clear
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
queues.each(&:close)
|
|
44
|
+
threads.each(&:kill)
|
|
45
|
+
threads.each { |thread| thread.join(0.1) }
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def shutdown?
|
|
50
|
+
@mutex.synchronize { @shutdown }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pulsar
|
|
4
|
+
# Immutable message value returned by consumers.
|
|
5
|
+
class Message
|
|
6
|
+
attr_reader :payload, :message_id, :properties, :key, :topic, :publish_time, :event_time
|
|
7
|
+
|
|
8
|
+
def initialize(payload:, message_id:, properties: {}, key: nil, topic: nil, publish_time: nil, event_time: nil)
|
|
9
|
+
raise ArgumentError, 'message_id must be a Pulsar::MessageId' unless message_id.is_a?(MessageId)
|
|
10
|
+
|
|
11
|
+
@payload = String(payload).b.freeze
|
|
12
|
+
@message_id = message_id
|
|
13
|
+
@properties = properties.transform_keys(&:to_s).transform_values(&:to_s).freeze
|
|
14
|
+
@key = key
|
|
15
|
+
@topic = topic
|
|
16
|
+
@publish_time = publish_time
|
|
17
|
+
@event_time = event_time
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pulsar
|
|
4
|
+
# Comparable Pulsar message identifier.
|
|
5
|
+
class MessageId
|
|
6
|
+
include Comparable
|
|
7
|
+
|
|
8
|
+
attr_reader :ledger_id, :entry_id, :partition_index, :batch_index
|
|
9
|
+
|
|
10
|
+
def initialize(ledger_id:, entry_id:, partition_index: -1, batch_index: -1)
|
|
11
|
+
@ledger_id = Integer(ledger_id)
|
|
12
|
+
@entry_id = Integer(entry_id)
|
|
13
|
+
@partition_index = Integer(partition_index)
|
|
14
|
+
@batch_index = Integer(batch_index)
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def <=>(other)
|
|
19
|
+
return nil unless other.is_a?(MessageId)
|
|
20
|
+
|
|
21
|
+
to_a <=> other.to_a
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def eql?(other)
|
|
25
|
+
other.is_a?(MessageId) && to_a == other.to_a
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def hash
|
|
29
|
+
to_a.hash
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def inspect
|
|
33
|
+
"#<#{self.class.name} ledger_id=#{ledger_id} entry_id=#{entry_id} " \
|
|
34
|
+
"partition_index=#{partition_index} batch_index=#{batch_index}>"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
protected
|
|
38
|
+
|
|
39
|
+
def to_a
|
|
40
|
+
[ledger_id, entry_id, partition_index, batch_index]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pulsar
|
|
4
|
+
# Public producer API for sending messages to one Pulsar topic.
|
|
5
|
+
class Producer
|
|
6
|
+
attr_reader :topic
|
|
7
|
+
|
|
8
|
+
def initialize(topic:, impl: nil)
|
|
9
|
+
@topic = String(topic)
|
|
10
|
+
@impl = impl
|
|
11
|
+
@closed = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def send(payload, properties: {}, key: nil, event_time: nil, timeout: nil)
|
|
15
|
+
ensure_open!
|
|
16
|
+
raise UnsupportedFeatureError, 'producer send is not implemented yet' unless @impl
|
|
17
|
+
|
|
18
|
+
@impl.send(payload, properties: properties, key: key, event_time: event_time, timeout: timeout)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def close
|
|
22
|
+
return if closed?
|
|
23
|
+
|
|
24
|
+
@impl&.close
|
|
25
|
+
@closed = true
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def closed?
|
|
30
|
+
@closed
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def ensure_open!
|
|
36
|
+
raise ClosedError, 'producer is closed' if closed?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|