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.
@@ -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