raptor 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,411 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "nio"
5
+ require "red-black-tree"
6
+
7
+ module Raptor
8
+ # High-performance I/O reactor for managing client connections and timeouts.
9
+ #
10
+ # Reactor uses NIO selectors for efficient I/O multiplexing and implements
11
+ # client timeouts using a red-black tree for O(log n) timeout management.
12
+ # It coordinates between thread pools for blocking operations and ractor
13
+ # pools for CPU-intensive HTTP parsing, and provides backlog metrics
14
+ # that the server uses for backpressure control to prevent overload.
15
+ #
16
+ # @example
17
+ # reactor = Reactor.new(thread_pool, ractor_pool, client_options: {
18
+ # first_data_timeout: 30,
19
+ # chunk_data_timeout: 10
20
+ # })
21
+ # reactor.run
22
+ # reactor.add(id: client.object_id, socket: client)
23
+ # # ... later
24
+ # reactor.shutdown
25
+ #
26
+ class Reactor
27
+ # Red-black tree node representing a client connection with timeout tracking.
28
+ #
29
+ # TimeoutClient extends RedBlackTree::Node to enable efficient timeout
30
+ # management using the tree's ordering properties.
31
+ #
32
+ class TimeoutClient < RedBlackTree::Node
33
+ # @rbs attr_accessor timeout_at: Float
34
+ attr_accessor :timeout_at
35
+
36
+ # Returns the client data stored in this timeout node.
37
+ #
38
+ # @return [Hash] the client connection state data
39
+ #
40
+ # @rbs () -> Hash[Symbol, untyped]
41
+ def client_data
42
+ data
43
+ end
44
+
45
+ # Calculates remaining timeout duration from the current time.
46
+ #
47
+ # @param now [Float] current monotonic timestamp
48
+ # @return [Float] remaining timeout in seconds, minimum 0
49
+ #
50
+ # @rbs (Float now) -> Float
51
+ def timeout(now)
52
+ [timeout_at - now, 0].max
53
+ end
54
+
55
+ # Compares timeout nodes by their timeout_at values for tree ordering.
56
+ #
57
+ # @param other [TimeoutClient] another timeout client to compare
58
+ # @return [Integer] -1, 0, or 1 for ordering
59
+ #
60
+ # @rbs (TimeoutClient other) -> Integer
61
+ def <=>(other)
62
+ timeout_at <=> other.timeout_at
63
+ end
64
+ end
65
+
66
+ CHUNK_SIZE = 64 * 1024
67
+ TIMEOUT_RESPONSE = "HTTP/1.1 408 Request Timeout\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
68
+
69
+ # @rbs @thread_pool: untyped
70
+ # @rbs @ractor_pool: untyped
71
+ # @rbs @client_options: Hash[Symbol, Integer]
72
+ # @rbs @selector: NIO::Selector
73
+ # @rbs @queue: Queue[TCPSocket]
74
+ # @rbs @timeouts: RedBlackTree[TimeoutClient]
75
+ # @rbs @id_to_socket: Hash[Integer, TCPSocket]
76
+ # @rbs @socket_to_state: Hash[TCPSocket, Hash[Symbol, untyped]]
77
+ # @rbs @id_to_timeout: Hash[Integer, TimeoutClient]
78
+ # @rbs @id_to_mutex: Hash[Integer, Mutex]
79
+
80
+ # Creates a new Reactor instance.
81
+ #
82
+ # @param thread_pool [AtomicThreadPool] thread pool for application processing
83
+ # @param ractor_pool [RactorPool] ractor pool for HTTP parsing
84
+ # @param client_options [Hash] timeout configuration options
85
+ # @option client_options [Integer] :first_data_timeout timeout for initial data
86
+ # @option client_options [Integer] :chunk_data_timeout timeout for subsequent chunks
87
+ # @option client_options [Integer] :persistent_data_timeout timeout for keep-alive connections
88
+ # @return [void]
89
+ #
90
+ # @rbs (untyped thread_pool, untyped ractor_pool, client_options: Hash[Symbol, Integer]) -> void
91
+ def initialize(thread_pool, ractor_pool, client_options:)
92
+ @thread_pool = thread_pool
93
+ @ractor_pool = ractor_pool
94
+ @client_options = client_options
95
+
96
+ @selector = NIO::Selector.new
97
+ @queue = Queue.new
98
+ @timeouts = RedBlackTree.new
99
+
100
+ @id_to_socket = {}
101
+ @socket_to_state = {}
102
+ @id_to_timeout = {}
103
+ @id_to_mutex = {}
104
+ end
105
+
106
+ # Starts the reactor's main event loop in a new thread.
107
+ #
108
+ # The event loop handles I/O events, processes timeouts, manages
109
+ # the registration queue, and controls server connection acceptance.
110
+ # It continues until the queue is closed and emptied.
111
+ #
112
+ # @return [Thread] the thread running the reactor event loop
113
+ #
114
+ # @rbs () -> Thread
115
+ def run
116
+ Thread.new do
117
+ Thread.current.name = self.class.name
118
+
119
+ until @queue.closed? && @queue.empty?
120
+ timeout = @timeouts.min&.timeout(Process.clock_gettime(Process::CLOCK_MONOTONIC))
121
+ @selector.select(timeout) do |monitor|
122
+ wakeup!(monitor.value)
123
+ end
124
+
125
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
126
+ expired = []
127
+ @timeouts.traverse do |to_client|
128
+ break unless to_client.timeout(now) == 0
129
+
130
+ expired << to_client
131
+ end
132
+
133
+ expired.each do |to_client|
134
+ @timeouts.delete!(to_client)
135
+ id = to_client.client_data[:id]
136
+ @id_to_timeout.delete(id)
137
+ socket = @id_to_socket[id]
138
+ next unless socket
139
+
140
+ @selector.deregister(socket)
141
+ socket.write(TIMEOUT_RESPONSE) rescue nil
142
+ cleanup(socket)
143
+ end
144
+
145
+ until @queue.empty?
146
+ register(@queue.pop)
147
+ end
148
+ end
149
+
150
+ @selector.close
151
+ end
152
+ end
153
+
154
+ # Adds a new client connection to the reactor.
155
+ #
156
+ # @param state [Hash] client connection state including socket and ID
157
+ # @option state [TCPSocket] :socket the client socket
158
+ # @option state [Integer] :id unique identifier for the client
159
+ # @return [void]
160
+ #
161
+ # @rbs (Hash[Symbol, untyped] state) -> void
162
+ def add(state)
163
+ socket = state[:socket]
164
+ state.delete(:socket)
165
+ @id_to_socket[state[:id]] = socket
166
+ @socket_to_state[socket] = state
167
+
168
+ if state[:protocol] == :http2
169
+ @id_to_mutex[state[:id]] = Mutex.new
170
+ end
171
+
172
+ read_and_queue_for_parse(socket, state)
173
+ end
174
+
175
+ # Updates the state of an existing client connection.
176
+ #
177
+ # Called when an incomplete HTTP request needs to be
178
+ # re-registered with the reactor for further processing.
179
+ #
180
+ # @param state [Hash] updated client connection state
181
+ # @option state [Integer] :id client identifier
182
+ # @return [void]
183
+ #
184
+ # @rbs (Hash[Symbol, untyped] state) -> void
185
+ def update_state(state)
186
+ socket = @id_to_socket[state[:id]]
187
+ return unless socket
188
+
189
+ @socket_to_state[socket] = state
190
+ @queue << socket
191
+ @selector.wakeup
192
+ rescue ClosedQueueError
193
+ socket.close
194
+ end
195
+
196
+ # Removes a client connection from the reactor.
197
+ #
198
+ # Called when an HTTP request is complete and ready for application
199
+ # processing. Triggers server accept re-enabling if system capacity allows.
200
+ #
201
+ # @param id [Integer] unique client identifier
202
+ # @return [TCPSocket, nil] the removed socket, if found
203
+ #
204
+ # @rbs (Integer id) -> TCPSocket?
205
+ def remove(id)
206
+ @id_to_socket.delete(id).tap do |socket|
207
+ @socket_to_state.delete(socket)
208
+ end
209
+ end
210
+
211
+ # Re-registers a kept-alive connection for the next request cycle.
212
+ #
213
+ # Called after successfully writing a response when keep-alive is active.
214
+ # Resets the connection state and re-queues the socket in the selector
215
+ # using the persistent data timeout.
216
+ #
217
+ # @param socket [TCPSocket] the kept-alive client socket
218
+ # @param id [Integer] the unique client identifier
219
+ # @param request_count [Integer] number of requests handled on this connection
220
+ # @param remote_addr [String] the client's remote IP address
221
+ # @param url_scheme [String] "http" or "https"
222
+ # @return [void]
223
+ #
224
+ # @rbs (TCPSocket socket, Integer id, Integer request_count, remote_addr: String, url_scheme: String) -> void
225
+ def persist(socket, id, request_count, remote_addr:, url_scheme:)
226
+ state = {
227
+ id: id,
228
+ request_count: request_count,
229
+ remote_addr: remote_addr,
230
+ url_scheme: url_scheme,
231
+ persisted: true
232
+ }
233
+
234
+ @id_to_socket[id] = socket
235
+ @socket_to_state[socket] = state
236
+ @queue << socket
237
+ @selector.wakeup
238
+ rescue ClosedQueueError
239
+ socket.close
240
+ end
241
+
242
+ # Returns the socket for a given client identifier without removing it.
243
+ #
244
+ # Used by HTTP/2 connections where the socket remains registered across
245
+ # multiple stream requests.
246
+ #
247
+ # @param id [Integer] unique client identifier
248
+ # @return [TCPSocket, nil] the socket, if found
249
+ #
250
+ # @rbs (Integer id) -> TCPSocket?
251
+ def socket_for(id)
252
+ @id_to_socket[id]
253
+ end
254
+
255
+ # Returns the mutex for a given HTTP/2 connection.
256
+ #
257
+ # @param id [Integer] unique client identifier
258
+ # @return [Mutex, nil] the mutex, if found
259
+ #
260
+ # @rbs (Integer id) -> Mutex?
261
+ def mutex_for(id)
262
+ @id_to_mutex[id]
263
+ end
264
+
265
+ # Updates connection state for an HTTP/2 connection after frame processing.
266
+ #
267
+ # Re-registers the socket with the selector for further reads and stores
268
+ # the updated HPACK table and stream states.
269
+ #
270
+ # @param state [Hash] updated connection state from the ractor pool
271
+ # @return [void]
272
+ #
273
+ # @rbs (Hash[Symbol, untyped] state) -> void
274
+ def update_http2_state(state)
275
+ socket = @id_to_socket[state[:id]]
276
+ return unless socket
277
+
278
+ @socket_to_state[socket] = state
279
+ @queue << socket
280
+ @selector.wakeup
281
+ rescue ClosedQueueError
282
+ socket.close
283
+ end
284
+
285
+ # Initiates reactor shutdown.
286
+ #
287
+ # Closes the registration queue and wakes up the selector to begin
288
+ # graceful shutdown process.
289
+ #
290
+ # @return [void]
291
+ #
292
+ # @rbs () -> void
293
+ def shutdown
294
+ @queue.close
295
+ @selector.wakeup
296
+ end
297
+
298
+ # Returns the number of complete requests either being processed
299
+ # or awaiting processing.
300
+ #
301
+ # @return [Integer] number of complete requests
302
+ #
303
+ # @rbs () -> Integer
304
+ def backlog
305
+ @thread_pool.queue_size + @thread_pool.active_count
306
+ end
307
+
308
+ private
309
+
310
+ # Registers a socket with the NIO selector and sets up timeout tracking.
311
+ #
312
+ # @param socket [TCPSocket] the socket to register
313
+ # @return [void]
314
+ #
315
+ # @rbs (TCPSocket socket) -> void
316
+ def register(socket)
317
+ @selector.register(socket, :r).value = socket
318
+
319
+ state = @socket_to_state[socket]
320
+ client = TimeoutClient.new(state)
321
+ timeout = if state[:persisted]
322
+ @client_options[:persistent_data_timeout]
323
+ elsif first_data_received?(state)
324
+ @client_options[:chunk_data_timeout]
325
+ else
326
+ @client_options[:first_data_timeout]
327
+ end
328
+ client.timeout_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
329
+ @timeouts << client
330
+ @id_to_timeout[state[:id]] = client
331
+ end
332
+
333
+ # Handles socket wakeup by deregistering and queuing for processing.
334
+ #
335
+ # @param socket [TCPSocket] the socket that became ready
336
+ # @return [void]
337
+ #
338
+ # @rbs (TCPSocket socket) -> void
339
+ def wakeup!(socket)
340
+ @selector.deregister(socket)
341
+ state = @socket_to_state[socket]
342
+ to_client = @id_to_timeout.delete(state[:id])
343
+ @timeouts.delete!(to_client)
344
+ read_and_queue_for_parse(socket, state)
345
+ end
346
+
347
+ # Reads data from a socket and either queues it for parsing,
348
+ # or for selector registration.
349
+ #
350
+ # @param socket [TCPSocket] the socket to read from and queue
351
+ # @param state [Hash] current connection state
352
+ # @return [Hash, nil] updated state, if successful
353
+ #
354
+ # @rbs (TCPSocket socket, Hash[Symbol, untyped] state) -> Hash[Symbol, untyped]?
355
+ def read_and_queue_for_parse(socket, state)
356
+ data = begin
357
+ socket.read_nonblock(CHUNK_SIZE)
358
+ rescue IO::WaitReadable
359
+ @queue << socket
360
+ @selector.wakeup
361
+ return
362
+ rescue EOFError
363
+ cleanup(socket)
364
+ return
365
+ end
366
+
367
+ buffer = state[:buffer] ? state[:buffer].dup : String.new
368
+ buffer << data
369
+
370
+ while socket.respond_to?(:pending) && socket.pending > 0
371
+ buffer << socket.read_nonblock(socket.pending)
372
+ end
373
+
374
+ state = state.frozen? ? state.merge(buffer: buffer) : state.merge!(buffer: buffer)
375
+ @ractor_pool << Ractor.make_shareable(state)
376
+ end
377
+
378
+ # Cleans up a client connection by removing it from tracking and closing the socket.
379
+ #
380
+ # @param socket [TCPSocket] the socket to clean up
381
+ # @return [void]
382
+ #
383
+ # @rbs (TCPSocket socket) -> void
384
+ def cleanup(socket)
385
+ state = @socket_to_state.delete(socket)
386
+ @id_to_socket.delete(state[:id])
387
+ @id_to_mutex.delete(state[:id])
388
+ socket.close
389
+ end
390
+
391
+ # Checks if a request is complete i.e., processable.
392
+ #
393
+ # @param state [Hash] connection state
394
+ # @return [Boolean] true if the request is complete
395
+ #
396
+ # @rbs (Hash[Symbol, untyped] state) -> bool
397
+ def complete?(state)
398
+ state[:complete]
399
+ end
400
+
401
+ # Checks if any data has been received for this connection.
402
+ #
403
+ # @param state [Hash] connection state
404
+ # @return [Boolean] true if first data has been received
405
+ #
406
+ # @rbs (Hash[Symbol, untyped] state) -> bool
407
+ def first_data_received?(state)
408
+ complete?(state) || (state.dig(:parse_data, :parse_count) || 0) >= 1
409
+ end
410
+ end
411
+ end