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,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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulsar
4
+ # Internal implementation namespace; not part of the public API.
5
+ module Internal
6
+ end
7
+ 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