async-nats 0.1.0

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,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2025 Tinco Andringa
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ require "nats/io/client"
17
+ require "async"
18
+ require "async/queue"
19
+ require_relative "socket"
20
+
21
+ module Async
22
+ module NATS
23
+ # Async-based NATS client that inherits from NATS::IO::Client
24
+ # and overrides connection and threading methods to use async operations
25
+ class Client < ::NATS::IO::Client
26
+ # Override create_socket to use Async::NATS::Socket
27
+ def create_socket
28
+ socket_class = case @uri.scheme
29
+ when "nats", "tls"
30
+ Async::NATS::Socket
31
+ when "ws", "wss"
32
+ # TODO: Implement async websocket support
33
+ raise NotImplementedError, "WebSocket support not yet implemented for async-nats"
34
+ else
35
+ raise NotImplementedError, "#{@uri.scheme} protocol is not supported"
36
+ end
37
+
38
+ socket_class.new(
39
+ uri: @uri,
40
+ tls: {context: tls_context, hostname: @hostname},
41
+ connect_timeout: ::NATS::IO::DEFAULT_CONNECT_TIMEOUT
42
+ )
43
+ end
44
+
45
+ # Override establish_connection! to use async operations
46
+ private def establish_connection!
47
+ @ruby_pid = Process.pid # For fork detection
48
+
49
+ srv = nil
50
+ begin
51
+ srv = select_next_server
52
+
53
+ # Use the hostname from the server for TLS hostname verification.
54
+ if client_using_secure_connection? && single_url_connect_used?
55
+ # Always reuse the original hostname used to connect.
56
+ @hostname ||= srv[:hostname]
57
+ else
58
+ @hostname = srv[:hostname]
59
+ end
60
+
61
+ # Create TCP socket connection to NATS using async socket
62
+ @io = create_socket
63
+ @io.connect
64
+
65
+ # Capture state that we have had a TCP connection established against
66
+ # this server and could potentially be used for reconnecting.
67
+ srv[:was_connected] = true
68
+
69
+ # Connection established and now in process of sending CONNECT to NATS
70
+ @status = CONNECTING
71
+
72
+ # Established TCP connection successfully so can start connect
73
+ process_connect_init
74
+
75
+ # Reset reconnection attempts if connection is valid
76
+ srv[:reconnect_attempts] = 0
77
+ srv[:auth_required] ||= true if @server_info[:auth_required]
78
+
79
+ # Add back to rotation since successfully connected
80
+ server_pool << srv
81
+ rescue ::NATS::IO::NoServersError => e
82
+ @disconnect_cb.call(e) if @disconnect_cb
83
+ raise @last_err || e
84
+ rescue => e
85
+ # Capture sticky error
86
+ synchronize do
87
+ @last_err = e
88
+ srv[:auth_required] ||= true if @server_info[:auth_required]
89
+ server_pool << srv if can_reuse_server?(srv)
90
+ end
91
+
92
+ err_cb_call(self, e, nil) if @err_cb
93
+
94
+ if should_not_reconnect?
95
+ @disconnect_cb.call(e) if @disconnect_cb
96
+ raise e
97
+ end
98
+
99
+ # Clean up any connecting state and close connection without
100
+ # triggering the disconnection/closed callbacks.
101
+ close_connection(DISCONNECTED, false)
102
+
103
+ # Always sleep here to safe guard against errors before current[:was_connected]
104
+ # is set for the first time.
105
+ if @options[:reconnect_time_wait]
106
+ Async::Task.current.sleep(@options[:reconnect_time_wait])
107
+ end
108
+
109
+ # Continue retrying until there are no options left in the server pool
110
+ retry
111
+ end
112
+
113
+ # Initialize queues using async-compatible queues
114
+ @flush_queue = create_async_queue(::NATS::IO::MAX_FLUSH_KICK_SIZE)
115
+ @pending_queue = create_async_queue(::NATS::IO::MAX_PENDING_SIZE)
116
+ @pings_outstanding = 0
117
+ @pongs_received = 0
118
+ @pending_size = 0
119
+
120
+ # Server roundtrip went ok so consider to be connected at this point
121
+ @status = CONNECTED
122
+
123
+ # Connected to NATS so Ready to start parser loop, flusher and ping interval
124
+ start_async_tasks!
125
+
126
+ self
127
+ end
128
+
129
+ # Create an async-compatible queue
130
+ private def create_async_queue(size)
131
+ # Use a simple array with mutex for now, can be optimized with Async::Queue
132
+ queue = SizedQueue.new(size)
133
+ queue
134
+ end
135
+
136
+ # Override start_threads! to use async tasks instead of threads
137
+ private def start_async_tasks!
138
+ # Start the read loop as an async task
139
+ @read_loop_task = Async::Task.current.async do
140
+ read_loop
141
+ end
142
+
143
+ # Start the flusher as an async task
144
+ @flusher_task = Async::Task.current.async do
145
+ flusher_loop
146
+ end
147
+
148
+ # Start the ping interval as an async task
149
+ @ping_interval_task = Async::Task.current.async do
150
+ ping_interval_loop
151
+ end
152
+
153
+ # Use async-based executor instead of Concurrent::ThreadPoolExecutor
154
+ @subscription_executor = Async::NATS::Executor.new(
155
+ name: "nats:subscription",
156
+ max_threads: ::NATS::IO::DEFAULT_TOTAL_SUB_CONCURRENCY,
157
+ min_threads: defined?(JRUBY_VERSION) ? 2 : 0
158
+ )
159
+ end
160
+
161
+ # Override read_loop to use async operations
162
+ private def read_loop
163
+ loop do
164
+ should_bail = synchronize do
165
+ @status == CLOSED or @status == RECONNECTING
166
+ end
167
+ if !@io || @io.closed? || should_bail
168
+ return
169
+ end
170
+
171
+ # Read data from socket with async I/O
172
+ data = @io.read(::NATS::IO::MAX_SOCKET_READ_BYTES)
173
+ @parser.parse(data) if data
174
+ rescue Errno::ETIMEDOUT
175
+ # Retry on timeout
176
+ retry
177
+ rescue => e
178
+ # In case of reading/parser errors, trigger reconnection logic
179
+ process_op_error(e)
180
+ end
181
+ end
182
+
183
+ # Override flusher_loop to use async operations
184
+ private def flusher_loop
185
+ loop do
186
+ # Blocks waiting for the flusher to be kicked...
187
+ @flush_queue.pop
188
+
189
+ should_bail = synchronize do
190
+ (@status != CONNECTED && !draining?) || @status == CONNECTING
191
+ end
192
+ return if should_bail
193
+
194
+ # Skip in case nothing remains pending already.
195
+ next if @pending_queue.empty?
196
+
197
+ force_flush!
198
+
199
+ synchronize do
200
+ @pending_size = 0
201
+ end
202
+ end
203
+ end
204
+
205
+ # Override ping_interval_loop to use async operations
206
+ private def ping_interval_loop
207
+ loop do
208
+ Async::Task.current.sleep(@options[:ping_interval])
209
+
210
+ # Skip ping interval until connected
211
+ next if !connected?
212
+
213
+ if @pings_outstanding >= @options[:max_outstanding_pings]
214
+ process_op_error(::NATS::IO::StaleConnectionError.new("nats: stale connection"))
215
+ return
216
+ end
217
+
218
+ @pings_outstanding += 1
219
+ send_command(PING_REQUEST)
220
+ @flush_queue << :ping
221
+ end
222
+ rescue => e
223
+ process_op_error(e)
224
+ end
225
+
226
+ # Override drain to use async primitives when in async context
227
+ def drain
228
+ return if draining?
229
+
230
+ # Check if we're in an async context
231
+ if Async::Task.current?
232
+ # Async version - returns a task that can be awaited
233
+ synchronize do
234
+ @drain_task ||= Async::Task.current.async do
235
+ do_async_drain
236
+ end
237
+ end
238
+ else
239
+ # Fall back to parent thread-based implementation
240
+ super
241
+ end
242
+ end
243
+
244
+ # Override close to stop async tasks
245
+ def close
246
+ # Stop async tasks if they exist
247
+ # Use safe stop that works from both async and thread contexts
248
+ begin
249
+ @read_loop_task&.stop if @read_loop_task&.alive?
250
+ rescue => e
251
+ # Ignore errors during task stop
252
+ end
253
+
254
+ begin
255
+ @flusher_task&.stop if @flusher_task&.alive?
256
+ rescue => e
257
+ # Ignore errors during task stop
258
+ end
259
+
260
+ begin
261
+ @ping_interval_task&.stop if @ping_interval_task&.alive?
262
+ rescue => e
263
+ # Ignore errors during task stop
264
+ end
265
+
266
+ # Call parent close
267
+ super
268
+ end
269
+
270
+ private
271
+
272
+ # Async-aware drain implementation using Async::Barrier
273
+ def do_async_drain
274
+ synchronize { @status = DRAINING_SUBS }
275
+
276
+ # Create a barrier for all subscriptions
277
+ barrier = Async::Barrier.new
278
+ subs_to_drain = []
279
+
280
+ # Send UNSUB for all subscriptions and add barrier tasks
281
+ @subs.each do |_, sub|
282
+ next if sub == @resp_sub
283
+
284
+ drain_sub(sub)
285
+ subs_to_drain << sub
286
+
287
+ # Add a task to the barrier that waits for this sub to drain
288
+ barrier.async do
289
+ wait_for_sub_drained(sub)
290
+ end
291
+ end
292
+
293
+ force_flush!
294
+
295
+ # Wait for all subscriptions to drain with timeout
296
+ drain_timeout = @options[:drain_timeout] || 30
297
+ begin
298
+ Async::Task.current.with_timeout(drain_timeout) do
299
+ barrier.wait # Waits for all tasks in the barrier
300
+ end
301
+ rescue Async::TimeoutError
302
+ barrier.stop # Stop all waiting tasks
303
+ e = ::NATS::IO::DrainTimeoutError.new("nats: draining connection timed out")
304
+ err_cb_call(self, e, nil) if @err_cb
305
+ ensure
306
+ barrier.stop rescue nil
307
+ end
308
+
309
+ # Shutdown subscription executor
310
+ @subscription_executor.shutdown
311
+ @subscription_executor.wait_for_termination(@options[:drain_timeout] || 30)
312
+
313
+ # Set status to DRAINING_PUBS and close
314
+ synchronize { @status = DRAINING_PUBS }
315
+ unsubscribe(@resp_sub) if @resp_sub
316
+ close
317
+ end
318
+
319
+ # Wait for a single subscription to drain (all messages processed)
320
+ def wait_for_sub_drained(sub)
321
+ loop do
322
+ # Check if subscription queue is empty
323
+ queue_size = sub.synchronize { sub.pending_queue.size }
324
+ break if queue_size == 0
325
+
326
+ # Yield to other tasks briefly
327
+ Async::Task.current.sleep(0.01)
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module NATS
5
+ # Async-based executor using a worker pool pattern with Async::Semaphore
6
+ # This provides bounded concurrency without creating unlimited nested tasks
7
+ class Executor
8
+ def initialize(name:, max_threads:, min_threads: 0)
9
+ @name = name
10
+ @max_concurrency = max_threads
11
+ @running = true
12
+ @work_queue = Thread::Queue.new # Thread-safe queue for work items
13
+ @workers = []
14
+ @mutex = Mutex.new
15
+
16
+ # Start worker tasks if we're in an async context
17
+ if Async::Task.current?
18
+ start_workers
19
+ end
20
+ end
21
+
22
+ # Post a block to be executed asynchronously
23
+ def post(&block)
24
+ return unless @running
25
+
26
+ # Add work to the queue
27
+ @work_queue << block
28
+ end
29
+
30
+ # Shutdown the executor
31
+ def shutdown
32
+ @running = false
33
+
34
+ # Signal workers to stop by adding nil items
35
+ @max_concurrency.times { @work_queue << nil }
36
+
37
+ # Stop all worker tasks
38
+ @mutex.synchronize do
39
+ @workers.each do |worker|
40
+ worker.stop rescue nil
41
+ end
42
+ @workers.clear
43
+ end
44
+ end
45
+
46
+ # Wait for termination (for compatibility with ThreadPoolExecutor)
47
+ def wait_for_termination(timeout = nil)
48
+ deadline = timeout ? Time.now + timeout : nil
49
+
50
+ loop do
51
+ @mutex.synchronize do
52
+ return true if @workers.empty? || @workers.all? { |w| !w.alive? }
53
+ end
54
+
55
+ if deadline && Time.now > deadline
56
+ return false
57
+ end
58
+
59
+ sleep(0.01)
60
+ end
61
+
62
+ true
63
+ end
64
+
65
+ private
66
+
67
+ def start_workers
68
+ # Create a pool of worker tasks
69
+ @max_concurrency.times do |i|
70
+ worker = Async::Task.current.async do
71
+ worker_loop
72
+ end
73
+
74
+ @mutex.synchronize do
75
+ @workers << worker
76
+ end
77
+ end
78
+ end
79
+
80
+ def worker_loop
81
+ while @running
82
+ # Get work from the queue (blocks until work is available)
83
+ work = @work_queue.pop
84
+
85
+ # nil signals shutdown
86
+ break if work.nil?
87
+
88
+ begin
89
+ work.call
90
+ rescue => e
91
+ # Log error but don't crash the worker
92
+ warn "Async::NATS::Executor worker error: #{e.class}: #{e.message}"
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2025 Tinco Andringa
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ require "async"
17
+ require "io/endpoint"
18
+ require "io/endpoint/host_endpoint"
19
+ require "io/stream"
20
+ require "openssl"
21
+
22
+ module Async
23
+ module NATS
24
+ # Async-based socket implementation for NATS connections
25
+ # This replaces the blocking I/O operations in NATS::IO::Socket with async operations
26
+ class Socket
27
+ attr_accessor :socket
28
+
29
+ def initialize(options = {})
30
+ @uri = options[:uri]
31
+ @connect_timeout = options[:connect_timeout]
32
+ @write_timeout = options[:write_timeout]
33
+ @read_timeout = options[:read_timeout]
34
+ @socket = nil
35
+ @stream = nil
36
+ @tls = options[:tls]
37
+
38
+ # Cache async task reference to avoid repeated context checks
39
+ # This is checked once at initialization to avoid overhead on every I/O operation
40
+ @async_task = Async::Task.current? rescue nil
41
+ end
42
+
43
+ def connect
44
+ # Create endpoint for the connection
45
+ endpoint = IO::Endpoint.tcp(@uri.hostname, @uri.port)
46
+
47
+ # Connect with timeout
48
+ task = Async::Task.current
49
+ task.with_timeout(@connect_timeout) do
50
+ @socket = endpoint.connect
51
+ end
52
+
53
+ # Set TCP no delay by default
54
+ @socket.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY, 1) if @socket.respond_to?(:setsockopt)
55
+
56
+ # Wrap in IO::Stream for buffered I/O
57
+ @stream = IO::Stream(@socket)
58
+ rescue Async::TimeoutError
59
+ raise NATS::IO::SocketTimeoutError
60
+ end
61
+
62
+ # (Re-)connect using secure connection if server and client agreed on using it.
63
+ def setup_tls!
64
+ # Setup TLS connection by rewrapping the socket
65
+ tls_context = @tls.fetch(:context)
66
+
67
+ # Create SSL socket
68
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(@socket, tls_context)
69
+ ssl_socket.sync_close = true
70
+ ssl_socket.hostname = @tls[:hostname]
71
+
72
+ # Connect with async
73
+ task = Async::Task.current
74
+ task.async do
75
+ ssl_socket.connect
76
+ end.wait
77
+
78
+ @socket = ssl_socket
79
+ @stream = IO::Stream(@socket)
80
+ end
81
+
82
+ def read_line(deadline = nil)
83
+ # deadline is a timeout duration in seconds, not an absolute time
84
+ # NATS protocol uses \r\n line endings
85
+
86
+ # Use cached task reference (checked once at initialization)
87
+ if @async_task && deadline
88
+ @async_task.with_timeout(deadline) do
89
+ @stream.read_until("\r\n") + "\r\n"
90
+ end
91
+ else
92
+ @stream.read_until("\r\n") + "\r\n"
93
+ end
94
+ rescue Async::TimeoutError
95
+ raise NATS::IO::SocketTimeoutError
96
+ rescue EOFError
97
+ raise Errno::ECONNRESET
98
+ end
99
+
100
+ def read(max_bytes, deadline = nil)
101
+ # deadline is a timeout duration in seconds, not an absolute time
102
+
103
+ # Use cached task reference (checked once at initialization)
104
+ if @async_task && deadline
105
+ @async_task.with_timeout(deadline) do
106
+ @stream.read_partial(max_bytes)
107
+ end
108
+ else
109
+ @stream.read_partial(max_bytes)
110
+ end
111
+ rescue Async::TimeoutError
112
+ raise NATS::IO::SocketTimeoutError
113
+ rescue EOFError => e
114
+ if (RUBY_ENGINE == "jruby") && (e.message == "No message available")
115
+ # FIXME: <EOFError: No message available> can happen in jruby
116
+ return nil
117
+ end
118
+ raise Errno::ECONNRESET
119
+ end
120
+
121
+ def write(data, deadline = nil)
122
+ # deadline is a timeout duration in seconds, not an absolute time
123
+
124
+ # Use cached task reference (checked once at initialization)
125
+ if @async_task && deadline
126
+ @async_task.with_timeout(deadline) do
127
+ @stream.write(data)
128
+ @stream.flush
129
+ end
130
+ else
131
+ @stream.write(data)
132
+ @stream.flush
133
+ end
134
+
135
+ data.bytesize
136
+ rescue Async::TimeoutError
137
+ raise NATS::IO::SocketTimeoutError
138
+ rescue EOFError
139
+ raise Errno::ECONNRESET
140
+ end
141
+
142
+ def close
143
+ @stream&.close
144
+ @socket&.close
145
+ end
146
+
147
+ def closed?
148
+ @socket.nil? || @socket.closed?
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module NATS
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/async/nats.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "nats/version"
4
+ require_relative "nats/executor"
5
+ require_relative "nats/socket"
6
+ require_relative "nats/client"
7
+
8
+ module Async
9
+ module NATS
10
+ class Error < StandardError; end
11
+ end
12
+ end
@@ -0,0 +1,6 @@
1
+ module Async
2
+ module Nats
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end