natswork-client 0.0.1
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 +0 -0
- data/LICENSE +0 -0
- data/README.md +201 -0
- data/lib/active_job/queue_adapters/natswork_adapter.rb +49 -0
- data/lib/generators/natswork/install_generator.rb +38 -0
- data/lib/generators/natswork/job_generator.rb +57 -0
- data/lib/generators/natswork/templates/job.rb.erb +21 -0
- data/lib/generators/natswork/templates/job_spec.rb.erb +49 -0
- data/lib/generators/natswork/templates/natswork.rb.erb +51 -0
- data/lib/natswork/circuit_breaker.rb +229 -0
- data/lib/natswork/client/version.rb +7 -0
- data/lib/natswork/client.rb +397 -0
- data/lib/natswork/compression.rb +58 -0
- data/lib/natswork/configuration.rb +117 -0
- data/lib/natswork/connection.rb +214 -0
- data/lib/natswork/connection_pool.rb +153 -0
- data/lib/natswork/errors.rb +28 -0
- data/lib/natswork/jetstream_manager.rb +243 -0
- data/lib/natswork/job.rb +100 -0
- data/lib/natswork/logging.rb +245 -0
- data/lib/natswork/message.rb +131 -0
- data/lib/natswork/rails/console_helpers.rb +208 -0
- data/lib/natswork/rails/generators/job_generator.rb +39 -0
- data/lib/natswork/rails/generators/templates/job.rb.erb +19 -0
- data/lib/natswork/rails/generators/templates/job_spec.rb.erb +27 -0
- data/lib/natswork/rails/generators/templates/job_test.rb.erb +28 -0
- data/lib/natswork/railtie.rb +37 -0
- data/lib/natswork/registry.rb +133 -0
- data/lib/natswork/serializer.rb +68 -0
- data/lib/natswork/version.rb +5 -0
- data/lib/natswork-client.rb +4 -0
- data/lib/natswork.rb +43 -0
- metadata +159 -0
@@ -0,0 +1,214 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'nats/io/client'
|
4
|
+
require 'json'
|
5
|
+
require 'natswork/errors'
|
6
|
+
|
7
|
+
module NatsWork
|
8
|
+
class Connection
|
9
|
+
attr_reader :servers, :max_reconnect_attempts, :reconnect_time_wait, :user, :password, :connection
|
10
|
+
|
11
|
+
def initialize(options = {})
|
12
|
+
@servers = options[:servers] || ['nats://localhost:4222']
|
13
|
+
@max_reconnect_attempts = options[:max_reconnect_attempts] || 10
|
14
|
+
@reconnect_time_wait = options[:reconnect_time_wait] || 2
|
15
|
+
@user = options[:user]
|
16
|
+
@password = options[:password]
|
17
|
+
@tls = options[:tls]
|
18
|
+
@client = NATS::IO::Client.new
|
19
|
+
@connection = @client # Alias for backward compatibility
|
20
|
+
@connected = false
|
21
|
+
@jetstream_context = nil
|
22
|
+
@mutex = Mutex.new
|
23
|
+
@reconnect_callbacks = []
|
24
|
+
@disconnect_callbacks = []
|
25
|
+
@error_callbacks = []
|
26
|
+
@last_ping_time = nil
|
27
|
+
@last_error = nil
|
28
|
+
end
|
29
|
+
|
30
|
+
def connect
|
31
|
+
return true if @connected && @client.connected?
|
32
|
+
|
33
|
+
options = {
|
34
|
+
servers: @servers,
|
35
|
+
max_reconnect_attempts: @max_reconnect_attempts,
|
36
|
+
reconnect_time_wait: @reconnect_time_wait
|
37
|
+
}
|
38
|
+
|
39
|
+
options[:user] = @user if @user
|
40
|
+
options[:password] = @password if @password
|
41
|
+
options[:tls] = @tls if @tls
|
42
|
+
|
43
|
+
setup_handlers
|
44
|
+
|
45
|
+
begin
|
46
|
+
@client.connect(options)
|
47
|
+
@connected = true
|
48
|
+
@client.connected?
|
49
|
+
rescue NATS::IO::ConnectError => e
|
50
|
+
raise ConnectionError, "Failed to connect to NATS: #{e.message}"
|
51
|
+
rescue StandardError => e
|
52
|
+
raise ConnectionError, "Connection error: #{e.message}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def disconnect
|
57
|
+
return unless @connected
|
58
|
+
|
59
|
+
@mutex.synchronize do
|
60
|
+
@client.close if @client.connected?
|
61
|
+
@connected = false
|
62
|
+
@jetstream_context = nil
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def connected?
|
67
|
+
@connected && @client.connected?
|
68
|
+
end
|
69
|
+
|
70
|
+
def publish(subject, payload)
|
71
|
+
raise ConnectionError, 'Not connected to NATS' unless connected?
|
72
|
+
|
73
|
+
data = payload.is_a?(String) ? payload : JSON.generate(payload)
|
74
|
+
@client.publish(subject, data)
|
75
|
+
end
|
76
|
+
|
77
|
+
def subscribe(subject, opts = {}, &block)
|
78
|
+
raise ConnectionError, 'Not connected to NATS' unless connected?
|
79
|
+
|
80
|
+
wrapped_callback = proc do |msg, reply, subject, sid|
|
81
|
+
parsed_msg = begin
|
82
|
+
JSON.parse(msg, symbolize_names: true)
|
83
|
+
rescue JSON::ParserError
|
84
|
+
msg
|
85
|
+
end
|
86
|
+
block.call(parsed_msg, reply, subject, sid)
|
87
|
+
end
|
88
|
+
|
89
|
+
if opts[:queue]
|
90
|
+
@client.subscribe(subject, queue: opts[:queue], &wrapped_callback)
|
91
|
+
else
|
92
|
+
@client.subscribe(subject, &wrapped_callback)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def request(subject, payload, opts = {})
|
97
|
+
raise ConnectionError, 'Not connected to NATS' unless connected?
|
98
|
+
|
99
|
+
timeout = opts[:timeout] || 5
|
100
|
+
data = payload.is_a?(String) ? payload : JSON.generate(payload)
|
101
|
+
|
102
|
+
begin
|
103
|
+
response = @client.request(subject, data, timeout: timeout)
|
104
|
+
JSON.parse(response.data)
|
105
|
+
rescue NATS::IO::Timeout
|
106
|
+
raise TimeoutError, "Request timed out after #{timeout} seconds"
|
107
|
+
rescue JSON::ParserError
|
108
|
+
response.data
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def unsubscribe(sid)
|
113
|
+
return unless connected?
|
114
|
+
|
115
|
+
begin
|
116
|
+
@client.send(:unsubscribe, sid)
|
117
|
+
rescue StandardError
|
118
|
+
# Ignore errors for invalid SIDs
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def with_connection
|
123
|
+
connect unless connected?
|
124
|
+
yield(self)
|
125
|
+
end
|
126
|
+
|
127
|
+
def jetstream
|
128
|
+
raise ConnectionError, 'Not connected to NATS' unless connected?
|
129
|
+
|
130
|
+
@jetstream_context ||= @client.jetstream
|
131
|
+
end
|
132
|
+
|
133
|
+
def stats
|
134
|
+
return {} unless @client
|
135
|
+
|
136
|
+
stats = @client.stats || {}
|
137
|
+
stats[:last_ping_time] = @last_ping_time
|
138
|
+
stats[:healthy] = healthy?
|
139
|
+
stats[:last_error] = @last_error
|
140
|
+
stats
|
141
|
+
end
|
142
|
+
|
143
|
+
def healthy?
|
144
|
+
connected? && ping
|
145
|
+
end
|
146
|
+
|
147
|
+
def ping
|
148
|
+
return false unless connected?
|
149
|
+
|
150
|
+
begin
|
151
|
+
# Send a ping by doing a simple request to a non-existent subject with short timeout
|
152
|
+
@client.flush(1)
|
153
|
+
@last_ping_time = Time.now
|
154
|
+
true
|
155
|
+
rescue StandardError => e
|
156
|
+
@last_error = e.message
|
157
|
+
false
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def on_reconnect(&block)
|
162
|
+
@reconnect_callbacks << block if block_given?
|
163
|
+
end
|
164
|
+
|
165
|
+
def on_disconnect(&block)
|
166
|
+
@disconnect_callbacks << block if block_given?
|
167
|
+
end
|
168
|
+
|
169
|
+
def on_error(&block)
|
170
|
+
@error_callbacks << block if block_given?
|
171
|
+
end
|
172
|
+
|
173
|
+
private
|
174
|
+
|
175
|
+
def setup_handlers
|
176
|
+
@client.on_error do |error|
|
177
|
+
@last_error = error.message
|
178
|
+
@error_callbacks.each do |cb|
|
179
|
+
cb.call(error)
|
180
|
+
rescue StandardError
|
181
|
+
nil
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
@client.on_disconnect do
|
186
|
+
@connected = false
|
187
|
+
@disconnect_callbacks.each do |cb|
|
188
|
+
cb.call
|
189
|
+
rescue StandardError
|
190
|
+
nil
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
@client.on_reconnect do
|
195
|
+
@connected = true
|
196
|
+
@jetstream_context = nil # Reset JetStream context on reconnect
|
197
|
+
@reconnect_callbacks.each do |cb|
|
198
|
+
cb.call
|
199
|
+
rescue StandardError
|
200
|
+
nil
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
@client.on_close do
|
205
|
+
@connected = false
|
206
|
+
@disconnect_callbacks.each do |cb|
|
207
|
+
cb.call
|
208
|
+
rescue StandardError
|
209
|
+
nil
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'monitor'
|
4
|
+
require 'timeout'
|
5
|
+
require 'concurrent'
|
6
|
+
require 'natswork/connection'
|
7
|
+
require 'natswork/errors'
|
8
|
+
|
9
|
+
module NatsWork
|
10
|
+
class PoolTimeoutError < Error; end
|
11
|
+
class PoolShutdownError < Error; end
|
12
|
+
|
13
|
+
class ConnectionPool
|
14
|
+
attr_reader :size, :timeout
|
15
|
+
|
16
|
+
def initialize(options = {})
|
17
|
+
@size = options[:size] || 5
|
18
|
+
@timeout = options[:timeout] || 5
|
19
|
+
@connection_options = options[:connection_options] || {}
|
20
|
+
|
21
|
+
@available = Queue.new
|
22
|
+
@connections = []
|
23
|
+
@active = Concurrent::Array.new
|
24
|
+
@shutdown = false
|
25
|
+
@mutex = Mutex.new
|
26
|
+
@created_count = 0
|
27
|
+
@waiting_count = 0
|
28
|
+
@shutdown_condition = ConditionVariable.new
|
29
|
+
end
|
30
|
+
|
31
|
+
def with_connection(&block)
|
32
|
+
raise PoolShutdownError, 'Pool has been shutdown' if @shutdown
|
33
|
+
|
34
|
+
conn = checkout
|
35
|
+
begin
|
36
|
+
conn.with_connection(&block)
|
37
|
+
ensure
|
38
|
+
checkin(conn)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def shutdown(timeout: 30)
|
43
|
+
@mutex.synchronize do
|
44
|
+
@shutdown = true
|
45
|
+
|
46
|
+
# Wait for active connections to return
|
47
|
+
deadline = Time.now + timeout
|
48
|
+
@shutdown_condition.wait(@mutex, 0.1) while @active.size.positive? && Time.now < deadline
|
49
|
+
|
50
|
+
# Disconnect all connections
|
51
|
+
@connections.each do |conn|
|
52
|
+
conn.disconnect
|
53
|
+
rescue StandardError
|
54
|
+
nil
|
55
|
+
end
|
56
|
+
|
57
|
+
@connections.clear
|
58
|
+
@available.clear
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def available_connections
|
63
|
+
@available.size
|
64
|
+
end
|
65
|
+
|
66
|
+
def active_connections
|
67
|
+
@active.size
|
68
|
+
end
|
69
|
+
|
70
|
+
def stats
|
71
|
+
{
|
72
|
+
size: @size,
|
73
|
+
available: available_connections,
|
74
|
+
active: active_connections,
|
75
|
+
waiting: @waiting_count,
|
76
|
+
created: @created_count
|
77
|
+
}
|
78
|
+
end
|
79
|
+
|
80
|
+
def healthy?
|
81
|
+
!@shutdown
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def checkout
|
87
|
+
raise PoolShutdownError, 'Pool has been shutdown' if @shutdown
|
88
|
+
|
89
|
+
conn = nil
|
90
|
+
deadline = Time.now + @timeout
|
91
|
+
|
92
|
+
begin
|
93
|
+
@mutex.synchronize { @waiting_count += 1 }
|
94
|
+
|
95
|
+
# First try non-blocking pop
|
96
|
+
begin
|
97
|
+
conn = @available.pop(true)
|
98
|
+
rescue ThreadError
|
99
|
+
# Queue is empty, try to create new connection
|
100
|
+
@mutex.synchronize do
|
101
|
+
conn = create_connection if @created_count < @size
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# If we still don't have a connection, wait for one
|
106
|
+
if conn.nil?
|
107
|
+
remaining = deadline - Time.now
|
108
|
+
raise PoolTimeoutError, "Could not obtain connection within #{@timeout} seconds" if remaining <= 0
|
109
|
+
|
110
|
+
begin
|
111
|
+
Timeout.timeout(remaining) do
|
112
|
+
conn = @available.pop
|
113
|
+
end
|
114
|
+
rescue Timeout::Error
|
115
|
+
raise PoolTimeoutError, "Could not obtain connection within #{@timeout} seconds"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
ensure
|
119
|
+
@mutex.synchronize { @waiting_count -= 1 }
|
120
|
+
end
|
121
|
+
|
122
|
+
@active << conn if conn
|
123
|
+
conn
|
124
|
+
end
|
125
|
+
|
126
|
+
def checkin(conn)
|
127
|
+
return unless conn
|
128
|
+
|
129
|
+
@active.delete(conn)
|
130
|
+
|
131
|
+
@mutex.synchronize do
|
132
|
+
if @shutdown
|
133
|
+
begin
|
134
|
+
conn.disconnect
|
135
|
+
rescue StandardError
|
136
|
+
nil
|
137
|
+
end
|
138
|
+
else
|
139
|
+
@available << conn
|
140
|
+
end
|
141
|
+
@shutdown_condition.signal
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def create_connection
|
146
|
+
conn = NatsWork::Connection.new(@connection_options)
|
147
|
+
conn.connect
|
148
|
+
@connections << conn
|
149
|
+
@created_count += 1
|
150
|
+
conn
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NatsWork
|
4
|
+
class Error < StandardError; end
|
5
|
+
|
6
|
+
class InvalidMessageError < Error; end
|
7
|
+
|
8
|
+
class ConnectionError < Error; end
|
9
|
+
|
10
|
+
class TimeoutError < Error; end
|
11
|
+
|
12
|
+
class UnknownJobError < Error; end
|
13
|
+
|
14
|
+
class JobError < Error
|
15
|
+
attr_reader :job_class, :job_id, :original_error
|
16
|
+
|
17
|
+
def initialize(message, job_class: nil, job_id: nil, original_error: nil)
|
18
|
+
super(message)
|
19
|
+
@job_class = job_class
|
20
|
+
@job_id = job_id
|
21
|
+
@original_error = original_error
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class RetryableError < JobError; end
|
26
|
+
|
27
|
+
class FatalError < JobError; end
|
28
|
+
end
|
@@ -0,0 +1,243 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'natswork/connection'
|
4
|
+
require 'natswork/errors'
|
5
|
+
|
6
|
+
module NatsWork
|
7
|
+
class JetStreamError < Error; end
|
8
|
+
|
9
|
+
class JetStreamManager
|
10
|
+
attr_reader :connection, :prefix
|
11
|
+
|
12
|
+
def initialize(connection, prefix: 'natswork')
|
13
|
+
@connection = connection
|
14
|
+
@prefix = prefix
|
15
|
+
@streams = {}
|
16
|
+
@consumers = {}
|
17
|
+
end
|
18
|
+
|
19
|
+
def create_stream(name, subjects: nil, **options)
|
20
|
+
stream_name = "#{@prefix}_#{name}".upcase
|
21
|
+
subjects ||= ["#{@prefix}.#{name}.*"]
|
22
|
+
|
23
|
+
# Start with minimal required config
|
24
|
+
config = {
|
25
|
+
name: stream_name,
|
26
|
+
subjects: subjects
|
27
|
+
}
|
28
|
+
|
29
|
+
js = @connection.jetstream
|
30
|
+
stream = js.add_stream(**config)
|
31
|
+
@streams[name] = stream
|
32
|
+
stream
|
33
|
+
rescue StandardError => e
|
34
|
+
raise JetStreamError, "Failed to create stream #{stream_name}: #{e.message}"
|
35
|
+
end
|
36
|
+
|
37
|
+
def delete_stream(name)
|
38
|
+
stream_name = "#{@prefix}_#{name}".upcase
|
39
|
+
js = @connection.jetstream
|
40
|
+
js.delete_stream(stream_name)
|
41
|
+
@streams.delete(name)
|
42
|
+
true
|
43
|
+
rescue StandardError => e
|
44
|
+
raise JetStreamError, "Failed to delete stream #{stream_name}: #{e.message}"
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_stream(name)
|
48
|
+
stream_name = "#{@prefix}_#{name}".upcase
|
49
|
+
@streams[name] ||= begin
|
50
|
+
js = @connection.jetstream
|
51
|
+
js.stream_info(stream_name)
|
52
|
+
rescue StandardError
|
53
|
+
nil
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def create_consumer(stream_name, consumer_name, **options)
|
58
|
+
stream_name_full = "#{@prefix}_#{stream_name}".upcase
|
59
|
+
consumer_name_full = "#{@prefix}_#{consumer_name}"
|
60
|
+
|
61
|
+
config = {
|
62
|
+
durable_name: consumer_name_full,
|
63
|
+
deliver_subject: options[:deliver_subject],
|
64
|
+
deliver_group: options[:deliver_group] || consumer_name_full,
|
65
|
+
ack_policy: options[:ack_policy] || :explicit,
|
66
|
+
ack_wait: options[:ack_wait] || 30_000_000_000, # 30 seconds in nanoseconds
|
67
|
+
max_deliver: options[:max_deliver] || 3,
|
68
|
+
filter_subject: options[:filter_subject],
|
69
|
+
replay_policy: options[:replay_policy] || :instant,
|
70
|
+
deliver_policy: options[:deliver_policy] || :all,
|
71
|
+
max_ack_pending: options[:max_ack_pending] || 1000
|
72
|
+
}
|
73
|
+
|
74
|
+
js = @connection.jetstream
|
75
|
+
consumer = js.add_consumer(stream_name_full, config)
|
76
|
+
|
77
|
+
consumer_key = "#{stream_name}:#{consumer_name}"
|
78
|
+
@consumers[consumer_key] = consumer
|
79
|
+
consumer
|
80
|
+
rescue StandardError => e
|
81
|
+
raise JetStreamError, "Failed to create consumer #{consumer_name_full}: #{e.message}"
|
82
|
+
end
|
83
|
+
|
84
|
+
def delete_consumer(stream_name, consumer_name)
|
85
|
+
stream_name_full = "#{@prefix}_#{stream_name}".upcase
|
86
|
+
consumer_name_full = "#{@prefix}_#{consumer_name}"
|
87
|
+
|
88
|
+
js = @connection.jetstream
|
89
|
+
js.delete_consumer(stream_name_full, consumer_name_full)
|
90
|
+
|
91
|
+
consumer_key = "#{stream_name}:#{consumer_name}"
|
92
|
+
@consumers.delete(consumer_key)
|
93
|
+
true
|
94
|
+
rescue StandardError => e
|
95
|
+
raise JetStreamError, "Failed to delete consumer: #{e.message}"
|
96
|
+
end
|
97
|
+
|
98
|
+
def subscribe(stream_name, consumer_name, &block)
|
99
|
+
get_or_create_consumer(stream_name, consumer_name)
|
100
|
+
|
101
|
+
js = @connection.jetstream
|
102
|
+
js.subscribe(
|
103
|
+
nil,
|
104
|
+
durable: "#{@prefix}_#{consumer_name}",
|
105
|
+
stream: "#{@prefix}_#{stream_name}".upcase,
|
106
|
+
manual_ack: true
|
107
|
+
) do |msg|
|
108
|
+
wrapped_msg = JetStreamMessage.new(msg, js)
|
109
|
+
block.call(wrapped_msg)
|
110
|
+
end
|
111
|
+
rescue StandardError => e
|
112
|
+
raise JetStreamError, "Failed to subscribe: #{e.message}"
|
113
|
+
end
|
114
|
+
|
115
|
+
def publish(subject, payload, **options)
|
116
|
+
js = @connection.jetstream
|
117
|
+
|
118
|
+
data = payload.is_a?(String) ? payload : JSON.generate(payload)
|
119
|
+
|
120
|
+
# Build options for publish
|
121
|
+
publish_opts = {}
|
122
|
+
publish_opts[:header] = {} unless options.empty?
|
123
|
+
publish_opts[:header]['Nats-Msg-Id'] = options[:msg_id] if options[:msg_id]
|
124
|
+
publish_opts[:timeout] = options[:timeout] if options[:timeout]
|
125
|
+
|
126
|
+
# nats-pure expects (subject, payload) or (subject, payload, opts)
|
127
|
+
ack = if publish_opts.empty?
|
128
|
+
js.publish(subject, data)
|
129
|
+
else
|
130
|
+
js.publish(subject, data, **publish_opts)
|
131
|
+
end
|
132
|
+
|
133
|
+
{
|
134
|
+
stream: ack.stream,
|
135
|
+
seq: ack.seq,
|
136
|
+
duplicate: ack.duplicate || false
|
137
|
+
}
|
138
|
+
rescue StandardError => e
|
139
|
+
raise JetStreamError, "Failed to publish to JetStream: #{e.message}"
|
140
|
+
end
|
141
|
+
|
142
|
+
def pull_subscribe(stream_name, consumer_name, batch: 1, timeout: 5)
|
143
|
+
stream_name_full = "#{@prefix}_#{stream_name}".upcase
|
144
|
+
consumer_name_full = "#{@prefix}_#{consumer_name}"
|
145
|
+
|
146
|
+
js = @connection.jetstream
|
147
|
+
|
148
|
+
subscription = js.pull_subscribe(
|
149
|
+
nil,
|
150
|
+
durable: consumer_name_full,
|
151
|
+
stream: stream_name_full
|
152
|
+
)
|
153
|
+
|
154
|
+
messages = []
|
155
|
+
subscription.fetch(batch, timeout: timeout) do |msg|
|
156
|
+
messages << JetStreamMessage.new(msg, js)
|
157
|
+
end
|
158
|
+
|
159
|
+
messages
|
160
|
+
rescue StandardError => e
|
161
|
+
raise JetStreamError, "Failed to pull messages: #{e.message}"
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
|
166
|
+
def get_or_create_consumer(stream_name, consumer_name)
|
167
|
+
consumer_key = "#{stream_name}:#{consumer_name}"
|
168
|
+
@consumers[consumer_key] || create_consumer(stream_name, consumer_name)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
class JetStreamMessage
|
173
|
+
attr_reader :data, :subject, :reply, :headers, :metadata
|
174
|
+
|
175
|
+
def initialize(nats_msg, js_context)
|
176
|
+
@nats_msg = nats_msg
|
177
|
+
@js_context = js_context
|
178
|
+
@data = parse_data(nats_msg.data)
|
179
|
+
@subject = nats_msg.subject
|
180
|
+
@reply = nats_msg.reply
|
181
|
+
@headers = nats_msg.headers if nats_msg.respond_to?(:headers)
|
182
|
+
@metadata = extract_metadata
|
183
|
+
@acked = false
|
184
|
+
end
|
185
|
+
|
186
|
+
def ack
|
187
|
+
return if @acked
|
188
|
+
|
189
|
+
@nats_msg.ack
|
190
|
+
@acked = true
|
191
|
+
end
|
192
|
+
|
193
|
+
def nak(delay: nil)
|
194
|
+
return if @acked
|
195
|
+
|
196
|
+
if delay
|
197
|
+
@nats_msg.nak(delay: delay)
|
198
|
+
else
|
199
|
+
@nats_msg.nak
|
200
|
+
end
|
201
|
+
@acked = true
|
202
|
+
end
|
203
|
+
|
204
|
+
def in_progress
|
205
|
+
@nats_msg.in_progress
|
206
|
+
end
|
207
|
+
|
208
|
+
def term
|
209
|
+
return if @acked
|
210
|
+
|
211
|
+
@nats_msg.term
|
212
|
+
@acked = true
|
213
|
+
end
|
214
|
+
|
215
|
+
def acked?
|
216
|
+
@acked
|
217
|
+
end
|
218
|
+
|
219
|
+
private
|
220
|
+
|
221
|
+
def parse_data(data)
|
222
|
+
JSON.parse(data)
|
223
|
+
rescue JSON::ParserError
|
224
|
+
data
|
225
|
+
end
|
226
|
+
|
227
|
+
def extract_metadata
|
228
|
+
return {} unless @nats_msg.respond_to?(:metadata)
|
229
|
+
|
230
|
+
meta = @nats_msg.metadata
|
231
|
+
{
|
232
|
+
sequence: meta.sequence,
|
233
|
+
num_delivered: meta.num_delivered,
|
234
|
+
num_pending: meta.num_pending,
|
235
|
+
timestamp: meta.timestamp,
|
236
|
+
stream: meta.stream,
|
237
|
+
consumer: meta.consumer
|
238
|
+
}
|
239
|
+
rescue StandardError
|
240
|
+
{}
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|