kino 0.5.0 → 0.7.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.
@@ -5,7 +5,12 @@ module Kino
5
5
  # Spawns worker ractors and keeps them alive. One supervisor thread per
6
6
  # ractor: it blocks in Ractor#value, and a crash (anything that kills the
7
7
  # ractor, Exception from app code included) wakes it to 500 the in-flight
8
- # requests and respawn. Clean exits (queue drained) end supervision.
8
+ # requests and respawn. Clean exits (queue drained at shutdown, or the
9
+ # worker retired by the pool scaler) end supervision.
10
+ #
11
+ # Also the :ractor-mode pool behind PoolScaler: `grow` adds a worker,
12
+ # `retire` sends one home, `groups` lists the ones that may be retired,
13
+ # and `active_count` is what the control plane reports.
9
14
  class RactorSupervisor
10
15
  def initialize(server_id, app, workers:, threads:, batch: 1, hooks: nil, on_worker_exit: nil)
11
16
  @server_id = server_id
@@ -21,6 +26,12 @@ module Kino
21
26
  @worker_slots = {}
22
27
  @slot_to_worker = {}
23
28
  @replaced = {}
29
+ # Worker index => true while its ractor runs, and => true once the
30
+ # scaler asked it to leave. Retired workers hand their slots back to
31
+ # the bank for the next worker to take over.
32
+ @live = {}
33
+ @retiring = {}
34
+ @bank = SlotBank.new(server_id)
24
35
  # The first replacement's index; `replace` increments before using it,
25
36
  # so this starts one below the first free index (@workers).
26
37
  @next_worker_index = @workers - 1
@@ -28,6 +39,7 @@ module Kino
28
39
 
29
40
  def start
30
41
  @supervisor_threads = Array.new(@workers) { |index| supervise(index) }
42
+ report_active
31
43
  self
32
44
  end
33
45
 
@@ -52,6 +64,61 @@ module Kino
52
64
  @lock.synchronize { @supervisor_threads.dup }.each(&:join)
53
65
  end
54
66
 
67
+ # Ractors cannot be force-killed; their clients were already freed by
68
+ # abort_all_inflight. A stuck ractor leaks until process exit.
69
+ def kill_stragglers
70
+ Log.error("shutdown deadline passed with stuck ractor workers") unless done?
71
+ end
72
+
73
+ # Workers that are staying: the live ones minus those the quarantine
74
+ # monitor abandoned as wedged (their replacements count instead) and
75
+ # minus those already told to leave. A retiring worker leaves the
76
+ # count at once, not when its ractor finally exits, so the scaler
77
+ # never sees a stale surplus and retires past its floor.
78
+ def active_count
79
+ @lock.synchronize do
80
+ @live.count { |index, _| !@replaced.key?(index) && !@retiring.key?(index) }
81
+ end
82
+ end
83
+
84
+ # Worker index => slot ids for every worker the scaler may retire:
85
+ # live, not already leaving, not quarantined, and past its spawn (a
86
+ # worker marked live whose supervisor thread has not assigned slots
87
+ # yet is not listed until it has).
88
+ def groups
89
+ @lock.synchronize do
90
+ @live.keys
91
+ .reject { |index| @retiring.key?(index) || @replaced.key?(index) || !@worker_slots.key?(index) }
92
+ .to_h { |index| [index, @worker_slots[index].dup] }
93
+ end
94
+ end
95
+
96
+ # Add one supervised worker; returns its index.
97
+ def grow
98
+ new_index = @lock.synchronize { @next_worker_index += 1 }
99
+ thread = supervise(new_index)
100
+ @lock.synchronize { @supervisor_threads << thread }
101
+ report_active
102
+ new_index
103
+ end
104
+
105
+ # Send a worker home. Its slots stop receiving work now; the worker
106
+ # finishes what it holds, leaves at its next idle tick, and its slots
107
+ # come back to the free list once the ractor has exited. Returns
108
+ # false when there is no such live worker to retire.
109
+ def retire(worker_index)
110
+ slot_ids = @lock.synchronize do
111
+ next nil unless @live.key?(worker_index) && !@retiring.key?(worker_index)
112
+
113
+ @retiring[worker_index] = true
114
+ @worker_slots[worker_index]
115
+ end
116
+ return false unless slot_ids
117
+
118
+ slot_ids.each { |id| Native.retire_slot(@server_id, id) }
119
+ true
120
+ end
121
+
55
122
  # Replace the ractor owning slot `worker_id`: spawn a fresh supervised
56
123
  # ractor, then quarantine the old ractor's slots. The old supervisor
57
124
  # thread stays blocked in ractor.value on the wedged ractor (it and the
@@ -90,6 +157,9 @@ module Kino
90
157
  private
91
158
 
92
159
  def supervise(index)
160
+ # Live from the moment it is asked for, not from when its thread gets
161
+ # around to spawning: `grow` reports the count right after this.
162
+ @lock.synchronize { @live[index] = true }
93
163
  Thread.new do
94
164
  Thread.current.name = "supervisor-#{index}"
95
165
  crashes = 0
@@ -97,8 +167,9 @@ module Kino
97
167
  ractor, worker_ids = spawn_worker(index)
98
168
  begin
99
169
  ractor.value # blocks until the ractor terminates
100
- HookFire.fire(@on_worker_exit, "on_worker_exit", index, nil) # clean exit: queue drained
101
- break # clean exit: queue closed, workers drained
170
+ HookFire.fire(@on_worker_exit, "on_worker_exit", index, nil) # clean exit: drained or retired
171
+ exited(index, worker_ids)
172
+ break
102
173
  rescue Ractor::Error => e
103
174
  # The ractor died mid-flight. Anything it was serving will never
104
175
  # be answered by Ruby: 500 those clients NOW (not when GC gets
@@ -106,11 +177,17 @@ module Kino
106
177
  worker_ids.each { |id| Native.abort_inflight(@server_id, id) }
107
178
  cause = (e.respond_to?(:cause) && e.cause) ? e.cause : e
108
179
  HookFire.fire(@on_worker_exit, "on_worker_exit", index, cause)
109
- break if draining?
180
+ if draining?
181
+ exited(index, nil)
182
+ break
183
+ end
110
184
 
111
185
  crashes += 1
112
186
  Native.record_respawn(@server_id)
113
187
  Log.error("worker-#{index} crashed (#{cause.class}: #{cause.message}); respawning")
188
+ # A crashed worker that was on its way out respawns on fresh
189
+ # slots like any other; the scaler retires it again when idle.
190
+ @lock.synchronize { @retiring.delete(index) }
114
191
  # Policy (crash recovery): unlimited respawn
115
192
  # keeps the server up under rare crashes but turns a
116
193
  # crash-on-every-request bug into a busy loop. A circuit breaker
@@ -121,11 +198,10 @@ module Kino
121
198
  end
122
199
  end
123
200
 
124
- # Fresh ractor + fresh native slots. Slots are never reused across
125
- # respawns: stale interrupt kicks and dead weak refs go down with the
126
- # old slot.
201
+ # Fresh ractor on slots from the bank: fresh ones, or ones a retired
202
+ # worker handed back.
127
203
  def spawn_worker(worker_index)
128
- worker_ids = Array.new(@threads) { Native.register_worker(@server_id) }
204
+ worker_ids = Array.new(@threads) { @bank.claim }
129
205
  @lock.synchronize do
130
206
  @worker_slots[worker_index] = worker_ids
131
207
  worker_ids.each { |id| @slot_to_worker[id] = worker_index }
@@ -147,6 +223,24 @@ module Kino
147
223
  [ractor, worker_ids]
148
224
  end
149
225
 
226
+ # Bookkeeping for a supervisor thread that is done: the worker is no
227
+ # longer live, a retired worker's slots go back to the bank, and the
228
+ # thread leaves the join set so a long-lived elastic pool does not
229
+ # accumulate dead threads.
230
+ def exited(index, worker_ids)
231
+ retired = @lock.synchronize do
232
+ @live.delete(index)
233
+ @supervisor_threads.delete(Thread.current)
234
+ @retiring.delete(index)
235
+ end
236
+ @bank.release(worker_ids) if retired && worker_ids
237
+ report_active
238
+ end
239
+
240
+ def report_active
241
+ Native.set_active_workers(@server_id, active_count)
242
+ end
243
+
150
244
  def draining?
151
245
  @lock.synchronize { @draining }
152
246
  end
data/lib/kino/server.rb CHANGED
@@ -68,6 +68,16 @@ module Kino
68
68
  @bind = settings[:bind]
69
69
  @requested_port = settings[:port]
70
70
  @workers = Integer(settings[:workers])
71
+ # The pool ceiling; equal to the floor for a fixed pool.
72
+ @max_workers = settings[:max_workers].nil? ? @workers : Integer(settings[:max_workers])
73
+ if @max_workers < @workers
74
+ raise ArgumentError, "max_workers (#{@max_workers}) must be at least workers (#{@workers})"
75
+ end
76
+ @scale_down_after = settings[:scale_down_after].nil? ? 30.0 : Float(settings[:scale_down_after])
77
+ raise ArgumentError, "scale_down_after must be positive" unless @scale_down_after.positive?
78
+ if !settings[:scale_down_after].nil? && !elastic?
79
+ Log.warn("scale_down_after has no effect unless max_workers is above workers")
80
+ end
71
81
  @on_error = validate_hook(settings[:on_error], :on_error)
72
82
  @after_worker_boot = validate_hook(settings[:after_worker_boot], :after_worker_boot)
73
83
  @after_request_complete = validate_hook(settings[:after_request_complete], :after_request_complete)
@@ -81,8 +91,9 @@ module Kino
81
91
  # The access log's GC and allocation figures come from the VM's
82
92
  # process-wide counters, so they are measured only where one
83
93
  # request at a time can own them: the GVL serializes :threaded
84
- # mode, and a single ractor has nothing to race.
85
- access_timing: !!settings[:log_requests] && (@mode == :threaded || @workers == 1)
94
+ # mode, and a single ractor (a pool that can never grow past one)
95
+ # has nothing to race.
96
+ access_timing: !!settings[:log_requests] && (@mode == :threaded || @max_workers == 1)
86
97
  )
87
98
  # Default threads per mode: 1 in :ractor (threads inside a ractor
88
99
  # share its lock; a measured +17% on fast handlers; raise `workers`
@@ -109,6 +120,7 @@ module Kino
109
120
  if @tls && unix?
110
121
  raise ArgumentError, "TLS is not supported on a unix socket bind; terminate TLS at the proxy in front"
111
122
  end
123
+ @http2 = settings.fetch(:http2, true) ? true : false
112
124
  @pidfile = settings[:pidfile]
113
125
  @control_bind = settings[:control_bind]&.to_s
114
126
  @control_token = settings[:control_token]&.to_s
@@ -125,10 +137,10 @@ module Kino
125
137
  else
126
138
  @workers * @threads
127
139
  end
128
- @worker_threads = []
129
- @worker_threads_lock = Mutex.new
130
140
  @supervisor = nil
141
+ @threaded_pool = nil
131
142
  @quarantine_monitor = nil
143
+ @pool_scaler = nil
132
144
  @started = false
133
145
  end
134
146
 
@@ -155,8 +167,10 @@ module Kino
155
167
  io_threads: @io_threads,
156
168
  tokio_threads: @tokio_threads,
157
169
  tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
170
+ http2: @http2,
158
171
  lanes: @lanes, log_requests: @log_requests,
159
- mode: @mode.to_s, workers: @workers, threads: @threads, batch: @batch,
172
+ mode: @mode.to_s, workers: @workers, max_workers: @max_workers,
173
+ threads: @threads, batch: @batch,
160
174
  control_bind: @control_bind, control_token: @control_token
161
175
  )
162
176
  booted = true
@@ -167,12 +181,15 @@ module Kino
167
181
  # lifetime so in-flight buffers survive even a worker ractor crash.
168
182
  @pin_keeper = Native.pin_keeper(@id)
169
183
  if @mode == :ractor
184
+ warn_scheduler_cap
170
185
  @supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads,
171
186
  batch: @batch, hooks: @worker_hooks, on_worker_exit: @on_worker_exit).start
172
187
  else
173
- @worker_threads = (@workers * @threads).times.map { spawn_worker_thread }
188
+ @threaded_pool = ThreadedPool.new(@id, @app, threads: @threads, batch: @batch,
189
+ hooks: @worker_hooks, on_worker_exit: @on_worker_exit).start(@workers)
174
190
  end
175
191
  start_quarantine_monitor if @quarantine_timeout_ms
192
+ start_pool_scaler if elastic?
176
193
  Native.control_ready(@id)
177
194
  HookFire.fire(@after_boot, "after_boot")
178
195
  @started = true
@@ -190,6 +207,7 @@ module Kino
190
207
  def shutdown(timeout: nil)
191
208
  return unless @started
192
209
 
210
+ @pool_scaler&.stop
193
211
  @quarantine_monitor&.stop
194
212
  deadline = monotonic_now + (timeout || @shutdown_timeout)
195
213
  Native.stop_accepting(@id)
@@ -222,7 +240,6 @@ module Kino
222
240
  # The runtime is gone, so hyper has dropped every pinned buffer;
223
241
  # the keeper (and the strings it marked) may now be collected.
224
242
  @pin_keeper = nil
225
- @worker_threads.clear
226
243
  @started = false
227
244
  remove_pidfile if @pidfile
228
245
  nil
@@ -231,7 +248,7 @@ module Kino
231
248
  # Block until every worker has exited (i.e. until shutdown).
232
249
  # @return [void]
233
250
  def wait
234
- @supervisor ? @supervisor.join : @worker_threads.each(&:join)
251
+ pool.join
235
252
  end
236
253
 
237
254
  # Production entry point: build the server and {#run} it. The `kino`
@@ -296,19 +313,22 @@ module Kino
296
313
  # lanes mode) once started
297
314
  def stats
298
315
  base = {
299
- mode: @mode, lanes: @lanes, workers: @workers, threads: @threads,
300
- batch: @batch, respawns: 0
316
+ mode: @mode, lanes: @lanes, workers: @workers, max_workers: @max_workers,
317
+ threads: @threads, batch: @batch, respawns: 0,
318
+ active_workers: @workers, scale_ups: 0, scale_downs: 0
301
319
  }
302
320
  return base unless @started
303
321
 
304
322
  queued, in_flight, served, rejected, timeouts, respawns, lane_depths = Native.server_stats(@id)
305
323
  base.merge!(queued:, in_flight:, served:, rejected:, timeouts:, respawns:)
306
324
  base[:lane_depths] = lane_depths if lane_depths
325
+ active_workers, _max_workers, scale_ups, scale_downs = Native.pool_stats(@id)
326
+ base.merge!(active_workers:, scale_ups:, scale_downs:)
307
327
  rows = Native.worker_stats(@id)
308
- base[:worker_status] = rows.map do |index, served, in_flight, busy_ms, quarantined|
309
- {index:, served:, in_flight:, busy_ms:, quarantined:}
328
+ base[:worker_status] = rows.map do |index, served, in_flight, busy_ms, quarantined, retired|
329
+ {index:, served:, in_flight:, busy_ms:, quarantined:, retired:}
310
330
  end
311
- base[:quarantined] = rows.count { |_index, _served, _in_flight, _busy_ms, quarantined| quarantined }
331
+ base[:quarantined] = rows.count { |row| row[4] }
312
332
  count, sum_seconds = Native.queue_time(@id)
313
333
  base[:queue_time] = {count:, sum_seconds:}
314
334
  base
@@ -316,63 +336,38 @@ module Kino
316
336
 
317
337
  private
318
338
 
319
- # Register a fresh dispatch slot and run a worker thread on it; returns
320
- # the thread. Used at boot and by the quarantine replacer.
321
- def spawn_worker_thread
322
- worker_id = Native.register_worker(@id)
323
- Thread.new do
324
- # Named so log lines from inside say which worker spoke.
325
- Thread.current.name = "worker-#{worker_id}"
326
- error = nil
327
- begin
328
- Worker.run(@id, worker_id, @app, @batch, @worker_hooks)
329
- rescue Exception => e # rubocop:disable Lint/RescueException -- a hard crash in a threaded worker thread
330
- error = e
331
- raise
332
- ensure
333
- HookFire.fire(@on_worker_exit, "on_worker_exit", worker_id, error)
334
- end
335
- end
339
+ # The worker pool this mode runs: the ractor supervisor, or the
340
+ # threaded pool. Both spawn, retire, replace and join workers behind
341
+ # the same methods.
342
+ def pool
343
+ @supervisor || @threaded_pool
336
344
  end
337
345
 
338
- # Track a replacement thread spawned outside the initial pool assignment
339
- # (the quarantine replacer) so shutdown's join/done?/kill sweeps see it.
340
- def track_replacement_thread(thread)
341
- @worker_threads_lock.synchronize { @worker_threads << thread }
346
+ # Both pools are quarantine replacers: replace(worker_id) spawns a
347
+ # replacement worker, then quarantines the wedged slot.
348
+ def start_quarantine_monitor
349
+ @quarantine_monitor = QuarantineMonitor.new(
350
+ server_id: @id, timeout_ms: @quarantine_timeout_ms,
351
+ max: @quarantine_max, replacer: pool
352
+ ).start
342
353
  end
343
354
 
344
- # @private
345
- # The :threaded-mode quarantine replacer: spawns a replacement worker
346
- # thread, quarantines the wedged slot, then tracks the new thread so
347
- # shutdown's join/done?/kill sweeps see it. Built from bound Method
348
- # objects instead of a server reference, so it drives the server
349
- # through those methods without send or instance_variable_get.
350
- class ThreadedReplacer
351
- def initialize(server_id:, spawner:, tracker:)
352
- @server_id = server_id
353
- @spawner = spawner
354
- @tracker = tracker
355
- end
355
+ # Ruby's M:N scheduler runs non-main ractors' Ruby code on at most
356
+ # RUBY_MAX_CPU native threads (default 8). Workers past that cap share
357
+ # timeslices instead of adding parallelism, and a fresh ractor can wait
358
+ # seconds for its first one while the others are CPU-bound.
359
+ def warn_scheduler_cap
360
+ cap = Integer(ENV.fetch("RUBY_MAX_CPU", "8"), exception: false) || 8
361
+ return if @max_workers <= cap
356
362
 
357
- def replace(worker_id)
358
- thread = @spawner.call # spawn FIRST (may raise ThreadError)
359
- Native.quarantine_slot(@server_id, worker_id) # quarantine after success
360
- @tracker.call(thread)
361
- true
362
- end
363
+ Log.warn("#{@max_workers} ractor workers exceed RUBY_MAX_CPU=#{cap}: only #{cap} can run " \
364
+ "Ruby code at once; set RUBY_MAX_CPU=#{@max_workers} for CPU-bound apps")
363
365
  end
364
- private_constant :ThreadedReplacer
365
366
 
366
- # A replacer.replace(worker_id) spawns a replacement worker, then
367
- # quarantines the wedged slot, mode-appropriately. In :ractor the
368
- # supervisor is the replacer; in :threaded a small object over
369
- # spawn_worker_thread.
370
- def start_quarantine_monitor
371
- replacer = @supervisor || ThreadedReplacer.new(server_id: @id, spawner: method(:spawn_worker_thread),
372
- tracker: method(:track_replacement_thread))
373
- @quarantine_monitor = QuarantineMonitor.new(
374
- server_id: @id, timeout_ms: @quarantine_timeout_ms,
375
- max: @quarantine_max, replacer: replacer
367
+ def start_pool_scaler
368
+ @pool_scaler = PoolScaler.new(
369
+ server_id: @id, pool: pool, floor: @workers, ceiling: @max_workers,
370
+ scale_down_after: @scale_down_after
376
371
  ).start
377
372
  end
378
373
 
@@ -398,6 +393,11 @@ module Kino
398
393
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
399
394
  end
400
395
 
396
+ # A pool that can grow: the ceiling is above the floor.
397
+ def elastic?
398
+ @max_workers > @workers
399
+ end
400
+
401
401
  # Default connection cap: most of the process open-file limit. A
402
402
  # connection flood's failure mode is descriptor exhaustion, and in
403
403
  # :ractor/:threaded mode the app's own sockets and files share this
@@ -472,34 +472,15 @@ module Kino
472
472
  end
473
473
 
474
474
  def join_workers(deadline)
475
- if @supervisor
476
- @supervisor.shutdown([deadline - monotonic_now, 0].max)
477
- else
478
- threads = @worker_threads_lock.synchronize { @worker_threads.dup }
479
- threads.each do |thread|
480
- thread.join([deadline - monotonic_now, 0.01].max)
481
- end
482
- end
475
+ pool.shutdown([deadline - monotonic_now, 0].max)
483
476
  end
484
477
 
485
478
  def workers_done?
486
- if @supervisor
487
- @supervisor.done?
488
- else
489
- threads = @worker_threads_lock.synchronize { @worker_threads.dup }
490
- threads.none?(&:alive?)
491
- end
479
+ pool.done?
492
480
  end
493
481
 
494
482
  def kill_stragglers
495
- if @supervisor
496
- # Ractors cannot be force-killed; their clients were already freed
497
- # by abort_all_inflight. The stuck ractor leaks until process exit.
498
- Log.error("shutdown deadline passed with stuck ractor workers") unless @supervisor.done?
499
- else
500
- threads = @worker_threads_lock.synchronize { @worker_threads.dup }
501
- threads.each { |thread| thread.kill if thread.alive? }
502
- end
483
+ pool.kill_stragglers
503
484
  end
504
485
 
505
486
  # Policy (mode resolution): when is an app safe for ractor
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # Dispatch slots for a worker pool: fresh ones from the native registry,
6
+ # or ones that retired workers handed back, reset for their next
7
+ # occupant. The native side never removes a slot, so recycling is what
8
+ # keeps the slot table from growing as an elastic pool breathes. Only
9
+ # cleanly exited workers return slots; a crashed worker's slots are
10
+ # abandoned (stale interrupt kicks and dead weak refs go down with
11
+ # them).
12
+ class SlotBank
13
+ def initialize(server_id)
14
+ @server_id = server_id
15
+ @free = []
16
+ @lock = Mutex.new
17
+ end
18
+
19
+ def claim
20
+ id = @lock.synchronize { @free.pop }
21
+ return Native.register_worker(@server_id) unless id
22
+
23
+ Native.reset_slot(@server_id, id)
24
+ id
25
+ end
26
+
27
+ def release(ids)
28
+ @lock.synchronize { @free.concat(ids) }
29
+ end
30
+ end
31
+ end
@@ -20,6 +20,11 @@
20
20
  # PEM strings also work).
21
21
  # tls cert: "config/certs/server.pem", key: "config/certs/server.key"
22
22
 
23
+ # HTTP/2 is on by default: negotiated via ALPN on TLS binds, and served
24
+ # to prior-knowledge (h2c) clients on plaintext. Everything else keeps
25
+ # getting HTTP/1.1. Set false to serve HTTP/1 only.
26
+ # http2 false
27
+
23
28
  ## Topology
24
29
 
25
30
  # How many workers to run. Each worker handles requests independently;
@@ -35,6 +40,21 @@
35
40
  # `workers` instead.
36
41
  # threads 1
37
42
 
43
+ ## Elastic pool (experimental)
44
+ #
45
+ # Let the pool grow past `workers` under load and shrink back when
46
+ # idle: one worker is added every 100 ms while requests wait in the
47
+ # queue, and a worker above `workers` retires after `scale_down_after`
48
+ # seconds idle. Helps apps that wait on databases or other services;
49
+ # pure CPU work gains nothing past the core count. Unset (the default)
50
+ # keeps the pool fixed. In :ractor mode, Ruby runs at most RUBY_MAX_CPU
51
+ # (default 8) ractors' Ruby code at once; set that variable to match
52
+ # `max_workers` on bigger boxes.
53
+ # max_workers 32
54
+
55
+ # Seconds an extra worker must sit idle before it is retired.
56
+ # scale_down_after 30
57
+
38
58
  ## Dispatch mode
39
59
  #
40
60
  # :auto - picks :ractor when your app supports it, else :threaded.