connectrpc-async 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,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Async
5
+ module Internal
6
+ # Reactor-owned HTTP/2 client connections for one Transport: stream
7
+ # accounting, one shared attempt at a time, backoff, admission bytes and
8
+ # published state. Attempts run under the transport lifetime, outside any
9
+ # RPC task or lifecycle; waiting calls only observe them.
10
+ class ConnectionManager
11
+ UNAVAILABLE = "HTTP/2 transport admission is closed or full"
12
+ CLOSING = "HTTP/2 transport is closing"
13
+ TRANSIENT = "HTTP/2 connection is in transient failure"
14
+ STOPPED = "HTTP/2 connection setup stopped"
15
+ TERMINAL = %i[closing closed].freeze
16
+
17
+ # One shared attempt. Its waiters read the recorded outcome and error.
18
+ class Attempt
19
+ attr_accessor :task, :resource, :outcome, :error
20
+ attr_reader :started_at
21
+
22
+ def initialize(started_at)
23
+ @started_at = started_at
24
+ end
25
+
26
+ def failed? = !@outcome.nil? && @outcome != :ready
27
+ end
28
+ private_constant :Attempt
29
+
30
+ # An acquisition waiter. A fail-fast waiter joins at most one attempt.
31
+ Waiter = Struct.new(:wait_for_ready, :joined)
32
+ private_constant :Waiter
33
+
34
+ attr_reader :state, :pending_bytes
35
+
36
+ def initialize(policy:, observer:, connection_limit:, connect_timeout:, lifetime:, progress:, build:,
37
+ setup:, abort_setup:)
38
+ @policy = policy
39
+ @observer = observer
40
+ @connection_limit = connection_limit
41
+ @connect_timeout = connect_timeout
42
+ @lifetime = lifetime
43
+ @progress = progress
44
+ @build = build
45
+ @setup = setup
46
+ @abort_setup = abort_setup
47
+ @connections = {}.compare_by_identity
48
+ @waiters = {}.compare_by_identity
49
+ @attempt = nil
50
+ @failures = 0
51
+ @timer = nil
52
+ @retry_at = nil
53
+ @pending_bytes = 0
54
+ @state = :idle
55
+ @closing = false
56
+ @dispatching = nil
57
+ @clock = MonotonicClock
58
+ @random = ::Random
59
+ end
60
+
61
+ # Stream usage summed over every accounted connection.
62
+ def usage = @connections.each_value.sum
63
+
64
+ def connections = @connections.dup
65
+
66
+ def check_reentry!
67
+ return unless @dispatching.equal?(Fiber.current)
68
+
69
+ raise LifecycleError, "Transport calls and close are not allowed from a connection observer"
70
+ end
71
+
72
+ # Reserves request bytes for an admitted call. A full count, an
73
+ # exhausted byte budget or an oversized call fails immediately.
74
+ def admit(bytes, full:)
75
+ limit = @policy.max_pending_bytes
76
+ if full || (limit && @pending_bytes + bytes > limit)
77
+ emit(:admission_rejected)
78
+ raise Error.new(:unavailable, UNAVAILABLE), cause: nil
79
+ end
80
+ @pending_bytes += bytes
81
+ bytes
82
+ end
83
+
84
+ def release_bytes(bytes)
85
+ @pending_bytes -= bytes
86
+ end
87
+
88
+ def acquire(wait_for_ready: @policy.wait_for_ready)
89
+ waiter = Waiter.new(wait_for_ready, nil)
90
+ @waiters[waiter] = true
91
+ loop do
92
+ raise Error.new(:unavailable, CLOSING), cause: nil if @closing
93
+
94
+ if (connection = available)
95
+ @connections[connection] += 1
96
+ return connection
97
+ end
98
+ joined = waiter.joined
99
+ if joined&.failed?
100
+ # Fail-fast waiters wait for one attempt, then only for capacity.
101
+ raise joined.error unless usable_connection?
102
+ elsif @attempt
103
+ waiter.joined = @attempt unless wait_for_ready
104
+ elsif @retry_at
105
+ raise Error.new(:unavailable, TRANSIENT), cause: nil unless wait_for_ready || usable_connection?
106
+ elsif slot_free?
107
+ attempt = start_attempt
108
+ waiter.joined = attempt unless wait_for_ready
109
+ next
110
+ end
111
+ @progress.wait
112
+ end
113
+ ensure
114
+ @waiters.delete(waiter)
115
+ end
116
+
117
+ # Exactly-once lease release. A connection that stopped accepting
118
+ # streams retires when its last lease is released.
119
+ def release(connection)
120
+ usage = @connections[connection]
121
+ return unless usage
122
+ raise "Trying to release an unacquired HTTP/2 connection" if usage.zero?
123
+
124
+ usage = @connections[connection] = usage - 1
125
+ if usage.zero? && !usable?(connection)
126
+ retire(connection)
127
+ else
128
+ @progress.signal
129
+ end
130
+ nil
131
+ end
132
+
133
+ # Removes the slot from accounting and wakes waiters before the
134
+ # potentially blocking close joins the connection's retirement.
135
+ def retire(connection)
136
+ return unless @connections.key?(connection)
137
+
138
+ @connections.delete(connection)
139
+ reconcile
140
+ connection.close
141
+ nil
142
+ end
143
+
144
+ def usable?(connection)
145
+ !connection.resource&.finished? && connection.reusable?
146
+ end
147
+
148
+ def close
149
+ return if @closing
150
+
151
+ @closing = true
152
+ publish(:closing)
153
+ Internal.cancel(@timer)
154
+ @timer = @retry_at = nil
155
+ Internal.cancel(@attempt&.task)
156
+ @progress.signal
157
+ end
158
+
159
+ def finish
160
+ close
161
+ @connections.delete_if { |_, usage| usage.zero? }
162
+ publish(:closed)
163
+ end
164
+
165
+ private
166
+
167
+ def available
168
+ @connections.each do |connection, usage|
169
+ return connection if usage < connection.maximum_concurrent_streams && usable?(connection)
170
+ end
171
+ nil
172
+ end
173
+
174
+ def usable_connection? = @connections.each_key.any? { |connection| usable?(connection) }
175
+ def slot_free? = @connections.size + (@attempt ? 1 : 0) < @connection_limit
176
+ def demand? = @waiters.each_key.any? { |waiter| waiter.wait_for_ready || !waiter.joined&.failed? }
177
+
178
+ def current_state
179
+ return @state if TERMINAL.include?(@state)
180
+ return :ready if usable_connection?
181
+ return :connecting if @attempt
182
+ return :transient_failure if @retry_at
183
+
184
+ :idle
185
+ end
186
+
187
+ # Starts a demand-driven attempt when one is allowed and no connection
188
+ # has spare capacity, then republishes. Woken waiters take that capacity.
189
+ def reconcile
190
+ unless @closing
191
+ start_attempt if !@attempt && !@retry_at && slot_free? && demand? && !available
192
+ publish(current_state)
193
+ end
194
+ @progress.signal
195
+ end
196
+
197
+ def connection_unusable(resource)
198
+ connection = resource.connection
199
+ return unless connection && @connections.key?(connection)
200
+
201
+ @connections.delete(connection) if @connections[connection].zero?
202
+ reconcile
203
+ end
204
+
205
+ # Publishes the attempt before running its eager task, so a waiter
206
+ # never misses an attempt that finishes before it waits. The task
207
+ # exists first: close can cancel it while an observer yields, and an
208
+ # interrupted publisher still runs or abandons it.
209
+ def start_attempt
210
+ attempt = Attempt.new(@clock.now)
211
+ @attempt = attempt
212
+ resource = attempt.resource = @build.call
213
+ resource.on_unusable = method(:connection_unusable)
214
+ task = attempt.task = ::Async::Task.new(@lifetime, finished: false) { run_attempt(attempt) }
215
+ begin
216
+ publish(current_state)
217
+ ensure
218
+ task.finished? ? abandon(attempt) : task.run
219
+ end
220
+ attempt
221
+ end
222
+
223
+ def run_attempt(attempt)
224
+ resource = attempt.resource
225
+ outcome = ::Async::Promise.new
226
+ setup = ::Async::Task.new(@lifetime, finished: false) do
227
+ @setup.call(resource)
228
+ outcome.resolve(nil)
229
+ rescue StandardError => e
230
+ outcome.resolve(e)
231
+ ensure
232
+ outcome.resolve(IOError.new(STOPPED))
233
+ end
234
+ resource.task = setup
235
+ setup.run
236
+ error = wait_setup(outcome, Internal.now + @connect_timeout)
237
+ connection = resource.connection
238
+ # A connection that died before registration never becomes ready.
239
+ error ||= IOError.new("HTTP/2 connection closed before readiness") unless connection && usable?(connection)
240
+ return abandon(attempt) if @closing
241
+
242
+ error ? fail_attempt(attempt, error) : succeed(attempt, connection)
243
+ rescue ::Async::TimeoutError => e
244
+ @closing ? abandon(attempt) : fail_attempt(attempt, e)
245
+ rescue ::Async::Cancel
246
+ abandon(attempt)
247
+ raise
248
+ end
249
+
250
+ def wait_setup(outcome, expires)
251
+ loop do
252
+ remaining = expires - Internal.now
253
+ raise ::Async::TimeoutError, "HTTP/2 connection setup timed out" if remaining <= 0
254
+
255
+ begin
256
+ return outcome.wait(timeout: [remaining, MAX_WAIT].min)
257
+ rescue ::Async::TimeoutError
258
+ raise if Internal.now >= expires
259
+ end
260
+ end
261
+ end
262
+
263
+ def succeed(attempt, connection)
264
+ @connections[connection] = 0
265
+ @failures = 0
266
+ attempt.outcome = :ready
267
+ @attempt = nil
268
+ publish(current_state)
269
+ emit(:attempt_finished, outcome: :ready, duration: @clock.now - attempt.started_at)
270
+ ensure
271
+ @progress.signal
272
+ end
273
+
274
+ def fail_attempt(attempt, error)
275
+ # Raw-first abort; this can wait for the setup task to retire.
276
+ @abort_setup.call(attempt.resource)
277
+ return abandon(attempt) if @closing
278
+
279
+ @failures += 1
280
+ attempt.error = error
281
+ attempt.outcome = classify(error)
282
+ @attempt = nil
283
+ arm_timer if @policy.backoff
284
+ publish(current_state)
285
+ emit(:attempt_finished, outcome: attempt.outcome, duration: @clock.now - attempt.started_at)
286
+ ensure
287
+ @progress.signal
288
+ end
289
+
290
+ # Close won: no registration, timer or state transition.
291
+ def abandon(attempt)
292
+ @abort_setup.call(attempt.resource)
293
+ ensure
294
+ attempt.error ||= Error.new(:unavailable, CLOSING)
295
+ attempt.outcome ||= :closed
296
+ @attempt = nil if @attempt.equal?(attempt)
297
+ @progress.signal
298
+ end
299
+
300
+ def classify(error)
301
+ case error
302
+ when Errno::ECONNREFUSED then :refused
303
+ when ::Async::TimeoutError, Errno::ETIMEDOUT then :timeout
304
+ when OpenSSL::SSL::SSLError then :tls
305
+ when ::Protocol::HTTP2::Error then :protocol
306
+ when EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError then :closed
307
+ else :other
308
+ end
309
+ end
310
+
311
+ def arm_timer
312
+ backoff = @policy.backoff
313
+ delay = [backoff.base * (backoff.multiplier.to_f**(@failures - 1)), backoff.max].min *
314
+ (1 - (@random.rand * backoff.jitter))
315
+ @retry_at = @clock.now + delay
316
+ timer = ::Async::Task.new(@lifetime, finished: false) do
317
+ @clock.sleep(delay)
318
+ if @timer.equal?(timer)
319
+ @timer = @retry_at = nil
320
+ reconcile
321
+ end
322
+ end
323
+ @timer = timer
324
+ timer.run
325
+ end
326
+
327
+ def publish(state)
328
+ previous = @state
329
+ return if previous == state
330
+
331
+ @state = state
332
+ emit(:state_changed, previous_state: previous)
333
+ end
334
+
335
+ # Observers run synchronously after bookkeeping and must return quickly.
336
+ def emit(type, previous_state: nil, outcome: nil, duration: nil)
337
+ return unless @observer
338
+
339
+ event = ConnectionEvent.new(
340
+ type: type, state: @state, previous_state: previous_state, outcome: outcome, duration: duration,
341
+ active: @connections.each_key.count { |connection| usable?(connection) },
342
+ draining: @connections.each_key.count { |connection| !usable?(connection) },
343
+ pending_calls: @waiters.size, pending_bytes: @pending_bytes
344
+ )
345
+ dispatching = @dispatching
346
+ @dispatching = Fiber.current
347
+ begin
348
+ @observer.call(event)
349
+ rescue StandardError
350
+ nil
351
+ ensure
352
+ @dispatching = dispatching
353
+ end
354
+ end
355
+ end
356
+ end
357
+ end
358
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Async
5
+ # Capped, jittered delay between failed connection attempts. Jitter only
6
+ # shortens a delay, so `max` is a hard maximum.
7
+ class Backoff
8
+ attr_reader :base, :multiplier, :jitter, :max
9
+
10
+ def initialize(base: 1.0, multiplier: 1.6, jitter: 0.2, max: 30.0)
11
+ @base = Internal.seconds(base, "base")
12
+ @multiplier = Internal.finite(multiplier, "multiplier", "at least 1") { |value| value >= 1 }
13
+ @jitter = Internal.finite(jitter, "jitter", "in 0...1") { |value| value >= 0 && value < 1 }
14
+ @max = Internal.finite(max, "max", "at least base") { |value| value >= @base }
15
+ freeze
16
+ end
17
+
18
+ def ==(other) = other.is_a?(Backoff) && values == other.values
19
+ def eql?(other) = self == other
20
+ def hash = [Backoff, *values].hash
21
+
22
+ protected
23
+
24
+ def values = [@base, @multiplier, @jitter, @max]
25
+ end
26
+
27
+ # Connection behavior for the HTTP/2 Transport. Every feature can be turned
28
+ # off: `backoff: nil` and `max_pending_bytes: nil`.
29
+ class ConnectionPolicy
30
+ attr_reader :backoff, :wait_for_ready, :max_pending_bytes
31
+
32
+ def initialize(backoff: Backoff.new, wait_for_ready: false, max_pending_bytes: 16 * 1024 * 1024)
33
+ raise ArgumentError, "backoff must be a Backoff or nil" unless backoff.nil? || backoff.is_a?(Backoff)
34
+ raise ArgumentError, "wait_for_ready must be true or false" unless [true, false].include?(wait_for_ready)
35
+ raise ArgumentError, "wait_for_ready requires a backoff" if wait_for_ready && backoff.nil?
36
+ unless max_pending_bytes.nil? || (max_pending_bytes.is_a?(Integer) && max_pending_bytes.positive?)
37
+ raise ArgumentError, "max_pending_bytes must be a positive integer or nil"
38
+ end
39
+
40
+ @backoff = backoff
41
+ @wait_for_ready = wait_for_ready
42
+ @max_pending_bytes = max_pending_bytes
43
+ freeze
44
+ end
45
+
46
+ def ==(other) = other.is_a?(ConnectionPolicy) && values == other.values
47
+ def eql?(other) = self == other
48
+ def hash = [ConnectionPolicy, *values].hash
49
+
50
+ protected
51
+
52
+ def values = [@backoff, @wait_for_ready, @max_pending_bytes]
53
+ end
54
+
55
+ # A connection observation. It carries counts and classifications only:
56
+ # no addresses, certificates, payloads, URLs or exception text.
57
+ ConnectionEvent = Data.define(:type, :state, :previous_state, :outcome, :duration, :active, :draining,
58
+ :pending_calls, :pending_bytes)
59
+ end
60
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Async
5
+ # Strict HTTP/2 listener composing the same worker session as other hosts.
6
+ class Host
7
+ ACCEPT_RETRY_DELAYS = [0.05, 0.1, 0.2, 0.4, 0.5].freeze
8
+ ACCEPT_RETRY_ERRORS = [Errno::EMFILE, Errno::ENFILE, Errno::ENOBUFS, Errno::ENOMEM,
9
+ Errno::ECONNABORTED, Errno::EINTR].freeze
10
+ private_constant :ACCEPT_RETRY_DELAYS, :ACCEPT_RETRY_ERRORS
11
+
12
+ attr_reader :port, :addresses
13
+
14
+ def initialize(server:, host: "127.0.0.1", port: 0, tls: nil, tls_provider: nil,
15
+ max_connections: 128, max_requests: 128, handshake_timeout: 10, idle_timeout: 60,
16
+ refresh_interval: 60, refresh_jitter: 0.1, refresh_timeout: 10, on_tls_error: nil)
17
+ @host = host
18
+ @requested_port = port
19
+ @addresses = [].freeze
20
+ @owner = ::Async::Task.current?
21
+ @listeners = []
22
+ @completion = ::Async::Promise.new
23
+ application = Application.new(server: server)
24
+ @session = build_session(
25
+ application: application, lifecycle_factory: application.method(:start_call),
26
+ tls: tls, tls_provider: tls_provider,
27
+ max_connections: max_connections, max_requests: max_requests,
28
+ handshake_timeout: handshake_timeout, idle_timeout: idle_timeout,
29
+ refresh_interval: refresh_interval, refresh_jitter: refresh_jitter,
30
+ refresh_timeout: refresh_timeout, on_tls_error: on_tls_error, stop_accepting: method(:stop_accepting)
31
+ )
32
+ end
33
+
34
+ def inspect = "#<#{self.class} port=#{@port.inspect} session=#{@session.inspect}>"
35
+
36
+ def start
37
+ @session.send(:check_affinity!)
38
+ raise LifecycleError, "Server session cannot start after close" if @closing
39
+
40
+ # Caller-context rejection must not enter startup cleanup or retire a peer.
41
+ @session.send(:preflight_start)
42
+ # An overlapping caller must not rebind, reset @starting or abort the first.
43
+ raise LifecycleError, "Host startup is already in progress" if @starting
44
+
45
+ begin
46
+ @starting = true
47
+ @session.start
48
+ return self if @bound
49
+
50
+ @bound = bind
51
+ raise LifecycleError, "Host closed during startup" if @closing
52
+
53
+ @addresses = @bound.sockets.map(&:local_address).freeze
54
+ @port = @addresses.first.ip_port
55
+ @bound.sockets.each do |listener|
56
+ break if @failure || @closing
57
+
58
+ @session.schedule do |task|
59
+ @listeners << task
60
+ accept_loop(listener)
61
+ end
62
+ end
63
+ raise @failure if @failure
64
+ raise LifecycleError, "Host closed during startup" if @closing
65
+
66
+ self
67
+ rescue Exception => e # rubocop:disable Lint/RescueException -- retain the first startup cause through obligatory cleanup
68
+ cause = @failure || e
69
+ cleanup = exit_task
70
+ begin
71
+ Internal.join_complete(cleanup, failure: true) { cleanup.run if cleanup.status == :initialized }
72
+ rescue Exception # rubocop:disable Lint/RescueException -- joins retain later caller interruptions until actual finalization
73
+ raise if cleanup.failed?
74
+ end
75
+ raise cause
76
+ ensure
77
+ @starting = false
78
+ end
79
+ end
80
+
81
+ # Ordinary shutdown waits only for listener stop, not session retirement.
82
+ # Terminal failure waits for cooperative abort/join, then raises its cause.
83
+ # Owner interruption is retained through cleanup; the cause still survives.
84
+ def wait
85
+ @session.send(:check_affinity!)
86
+ raise LifecycleError, "Start or close the host before waiting" unless @bound || @closing
87
+
88
+ error = @completion.wait
89
+ if error
90
+ Internal.join_complete(@exit_task, failure: true)
91
+ raise error
92
+ end
93
+
94
+ nil
95
+ end
96
+
97
+ def close(**) = @session.close(**)
98
+ def reload_tls(**) = @session.reload_tls(**)
99
+ def refresh_tls = @session.refresh_tls
100
+ def drain_tls(**) = @session.drain_tls(**)
101
+ def tls_generation = @session.tls_generation
102
+ def tls_status = @session.tls_status
103
+ def peer_certificate = @session.peer_certificate
104
+
105
+ private
106
+
107
+ def build_session(**)
108
+ ServerSession.new(**)
109
+ end
110
+
111
+ def bind
112
+ ::IO::Endpoint.tcp(@host, @requested_port).bound
113
+ end
114
+
115
+ def accept_loop(listener)
116
+ failures = 0
117
+ loop do
118
+ begin
119
+ raw, = listener.accept
120
+ rescue *ACCEPT_RETRY_ERRORS
121
+ raise if failures == ACCEPT_RETRY_DELAYS.size
122
+ return if @closing
123
+
124
+ accept_backoff(ACCEPT_RETRY_DELAYS.fetch(failures))
125
+ failures += 1
126
+ next
127
+ end
128
+ failures = 0
129
+ accepted = @session.accept(raw)
130
+ @session.schedule { accepted.serve } if accepted
131
+ end
132
+ rescue StandardError => e
133
+ unless @closing
134
+ @failure ||= e
135
+ exit_task.run unless @starting
136
+ end
137
+ end
138
+
139
+ def accept_backoff(delay)
140
+ ::Async::Task.current.sleep(delay)
141
+ end
142
+
143
+ def exit_task
144
+ return @exit_task if @exit_task
145
+
146
+ # Prior shared close owns its peers and grace. Decide before marking our
147
+ # own teardown, so shutdown errors cannot become a new terminal cause.
148
+ force = @failure || !@closing
149
+ @closing = true
150
+ # Store identity before eager execution; callers never own this sequence.
151
+ @exit_task = ::Async::Task.new(@owner, transient: true, finished: false) do
152
+ @session.abort if force
153
+ stop_accepting
154
+ @completion.resolve(@failure)
155
+ end
156
+ end
157
+
158
+ def join_listeners
159
+ @listeners.each { |listener| Internal.join_complete(listener) }
160
+ end
161
+
162
+ def stop_accepting
163
+ @closing = true
164
+ @bound&.close
165
+ @listeners.each { |listener| Internal.cancel(listener) }
166
+ join_listeners
167
+ @completion.resolve(nil) unless @failure || @starting
168
+ end
169
+ end
170
+ end
171
+ end