kino 0.6.0-aarch64-linux → 0.7.0-aarch64-linux

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47797b5264a1cdd29fcccc4e370cbf8e92fc71b46daaf3831e7bca69c51affea
4
- data.tar.gz: 98b264a8a72224f37136e4ec14154e35c1b378844bbe73f35c6110dee59ffde9
3
+ metadata.gz: b101408a972721dec1c1041f195c4c56a18a8ca31e586903215382e54acc5fb0
4
+ data.tar.gz: '0386e61aa5dcbb60ce1bba381f5733b86d74e0352a47494273a575ca49ef0b47'
5
5
  SHA512:
6
- metadata.gz: 9035020f4e0f2a47666391c05c8a7992176258f186ccc0613fb97de3609bdd98866ab015bd1263246245dc9be0622dfbd93880516bca2d818f5d5bf2ea65739c
7
- data.tar.gz: fd323d0e09a40124a91b19f1142f335c41f2e97fc8d33369fc437e5a1cb50fcf91bd82aeb74f85d3c6f577533e1345f1cf323b2004784a6ab21df9d7b5839648
6
+ metadata.gz: 5dd12ec5e0bfb20e14f2d6aa5da9f3ea38168a6f9308559a1e7f9e115ce8eaaeda1050d1be514a6263a283f70b7dcd004e4eb12f66dbce4884be2ae73a7cae02
7
+ data.tar.gz: 7c30dbd7e644e6dbb5436c049c831e35e6032b3ef4b56ea4777ce0bb0d20bcdd12c236a85e60bfc35c97c354e2a63909bda1fb5b787b47a4d46ca13714a97d68
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## [0.7.0] - 2026-09-08
2
+
3
+ - Experimental elastic worker pool. Set `max_workers` above `workers`
4
+ and the pool grows under load, one worker at a time, then shrinks
5
+ back to `workers` once the extra workers have sat idle for
6
+ `scale_down_after` seconds (default 30). Works in both modes; a
7
+ retiring worker finishes its request first. `stats`, `/stats` and
8
+ `/metrics` gain `max_workers`, `active_workers`, `scale_ups` and
9
+ `scale_downs`, and each `worker_status` row gains `retired`. Leave
10
+ `max_workers` unset and the pool is fixed, as before.
11
+ - Ractor mode warns at boot when `max_workers` (or `workers`) exceeds
12
+ `RUBY_MAX_CPU` (default 8), Ruby's cap on how many ractors run Ruby
13
+ code at once. Set the variable to your worker count to lift it.
14
+ - Ractor-readiness fixes, so external Ractor audits of Kino pass: the
15
+ env string caches root their strings through the lock-free pin slab
16
+ instead of per-value GC registration (unsynchronized across ractors
17
+ in Ruby 4.0, a crash) and no longer call into Ruby while locked (a
18
+ GC-barrier deadlock); the shared rack.errors and rack.input
19
+ singletons must be Ractor-shareable, not just frozen; the Rack
20
+ handler's option table is shareable. Throughput is unchanged.
21
+
1
22
  ## [0.6.0] - 2026-09-01
2
23
 
3
24
  - HTTP/2 support. Kino now speaks HTTP/2 natively, on by default:
data/README.md CHANGED
@@ -259,6 +259,8 @@ server = Kino::Server.new(app,
259
259
  bind: "127.0.0.1", # or "unix:///run/kino.sock" behind a proxy
260
260
  port: 9292, # 0 = ephemeral; read back via server.port
261
261
  workers: Kino.available_parallelism, # ractors (parallelism); the default
262
+ max_workers: nil, # experimental: grow past workers under load (see Elastic pool)
263
+ scale_down_after: 30, # seconds idle before an extra worker retires
262
264
  threads: 1, # per worker; ractor default 1, threaded default 3
263
265
  mode: :auto, # :auto | :ractor | :threaded
264
266
  queue_depth: 1024, # bounded queue; overflow → 503
@@ -418,6 +420,56 @@ Kino fires four lifecycle hooks alongside `on_error`, split by firing context.
418
420
 
419
421
  A raising hook is logged and never kills a worker.
420
422
 
423
+ ## Elastic pool (experimental)
424
+
425
+ Size the pool for the quiet hours and let it grow for the busy ones:
426
+
427
+ ```ruby
428
+ # kino.rb
429
+ workers 4 # always running
430
+ max_workers 16 # reached only under load
431
+ scale_down_after 30 # seconds idle before an extra worker retires
432
+ ```
433
+
434
+ Or `Kino::Server.new(app, workers: 4, max_workers: 16)`. Leave
435
+ `max_workers` unset and the pool is fixed at `workers`, as before.
436
+
437
+ While requests wait in the queue, Kino adds a worker every 100 ms until
438
+ the queue clears or the pool hits `max_workers`. When the load passes,
439
+ workers above `workers` retire one at a time after `scale_down_after`
440
+ seconds idle, each finishing its current request first. Same behavior
441
+ in `:ractor` and `:threaded` mode.
442
+
443
+ **Use it when your app waits**: on databases, upstream services, slow
444
+ clients. With `workers` at your core count, all workers can be blocked
445
+ on I/O while cores sit idle; a higher ceiling puts those cores to work,
446
+ and a ractor starts in microseconds, so the pool follows load closely.
447
+ Pure CPU work gains nothing past the core count. In `:ractor` mode Ruby
448
+ itself runs at most `RUBY_MAX_CPU` ractors' Ruby code at once (default
449
+ 8), and Kino warns at boot when the pool can exceed it. On a bigger box:
450
+
451
+ ```sh
452
+ RUBY_MAX_CPU=16 kino
453
+ ```
454
+
455
+ **Watch it breathe** in `server.stats`, `GET /stats` and `GET /metrics`:
456
+
457
+ ```sh
458
+ $ curl -s localhost:9293/stats | jq '{workers, max_workers, active_workers, scale_ups, scale_downs}'
459
+ {
460
+ "workers": 4,
461
+ "max_workers": 16,
462
+ "active_workers": 9,
463
+ "scale_ups": 12,
464
+ "scale_downs": 7
465
+ }
466
+ ```
467
+
468
+ Prometheus gets `kino_max_workers`, `kino_active_workers`,
469
+ `kino_scale_ups_total` and `kino_scale_downs_total`. `after_worker_boot`
470
+ fires for every worker the pool adds, `on_worker_exit` (with a nil
471
+ cause) for every one it retires.
472
+
421
473
  ## Stuck-worker quarantine
422
474
 
423
475
  `quarantine_timeout: seconds` (or `quarantine_timeout 60` in `kino.rb`)
data/doc/architecture.md CHANGED
@@ -31,6 +31,16 @@ Puma-style two-level: `workers × threads`.
31
31
  - Identical machinery either way: the flume queue is MPMC, a "worker slot"
32
32
  is per-thread, and the worker loop (`lib/kino/worker.rb`) is shared
33
33
  verbatim.
34
+ - Elastic pool (`max_workers`): a scaler thread on the main ractor
35
+ samples queue depth and the per-slot sensors every 100 ms, adds one
36
+ worker per sample while requests wait, and retires the longest-idle
37
+ worker above the floor after `scale_down_after`. Retirement is a
38
+ per-slot flag raised under the slot's lane lock: the lane dispatcher
39
+ skips the slot, the take loop honors the flag at its next idle tick (a
40
+ request already taken finishes first, a lane worker drains its own
41
+ lane), and the slot is reset and reused by the next worker, so the
42
+ slot table never grows with churn. Both pools (the ractor supervisor
43
+ and the threaded pool) expose the same grow/retire/groups seam.
34
44
  - Experimental `lanes true` replaces the one shared queue with a small
35
45
  private queue per worker slot (awake-preferring dispatch, work
36
46
  stealing); see [benchmarks](benchmarks.md#lane-dispatch-experimental-lanes-true).
data/lib/kino/cli.rb CHANGED
@@ -127,7 +127,7 @@ module Kino
127
127
  stats = server.stats
128
128
  puts dim("- ruby: #{RUBY_DESCRIPTION}")
129
129
  puts dim("- env: #{ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"}")
130
- puts dim("- mode: #{server.mode}, #{count(stats[:workers], "worker")} × #{count(stats[:threads], "thread")}")
130
+ puts dim("- mode: #{server.mode}, #{workers_label(stats)} × #{count(stats[:threads], "thread")}")
131
131
  puts dim("- pid: #{Process.pid}")
132
132
  puts dim("- listening: #{server.url}")
133
133
  puts dim("- control: #{server.control_url}") if server.control_url
@@ -140,6 +140,14 @@ module Kino
140
140
  "#{number} #{noun}#{"s" unless number == 1}"
141
141
  end
142
142
 
143
+ # The pool as configured: "8 workers", or "8-32 workers" when it can
144
+ # grow.
145
+ def workers_label(stats)
146
+ return count(stats[:workers], "worker") if stats[:max_workers] == stats[:workers]
147
+
148
+ "#{stats[:workers]}-#{stats[:max_workers]} workers"
149
+ end
150
+
143
151
  # Roll credits when the process ends: normal exit or crash (at_exit
144
152
  # also runs after an uncaught exception; only a force-exit skips it).
145
153
  # @return [void]
@@ -234,7 +242,7 @@ module Kino
234
242
  end
235
243
 
236
244
  def write_sample(path)
237
- require "kino"
245
+ require "kino" # audition:disable runtime-require
238
246
  Configuration.write_sample(path)
239
247
  puts "Kino: wrote sample config to #{path}"
240
248
  0
@@ -245,8 +253,8 @@ module Kino
245
253
 
246
254
  # Resolve the full configuration once: file + CLI flag overrides.
247
255
  def resolve_config(options)
248
- require "kino"
249
- require "rack"
256
+ require "kino" # audition:disable runtime-require
257
+ require "rack" # audition:disable runtime-require
250
258
 
251
259
  config_file = options[:config_file] || Configuration.default_path
252
260
 
@@ -10,6 +10,8 @@ module Kino
10
10
  bind: "127.0.0.1",
11
11
  port: 0,
12
12
  workers: nil, # resolved to Kino.available_parallelism in #to_h
13
+ max_workers: nil, # nil = fixed pool; above workers = elastic pool
14
+ scale_down_after: nil, # resolved to 30 seconds in Server
13
15
  threads: nil, # resolved per mode in Server: 1 in :ractor, 3 in :threaded
14
16
  mode: :auto,
15
17
  queue_depth: 1024,
@@ -44,7 +46,7 @@ module Kino
44
46
  SETTINGS = DEFAULTS.keys.freeze
45
47
 
46
48
  # Source template for {.sample}.
47
- SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__)
49
+ SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__).freeze
48
50
 
49
51
  # Where the `kino` CLI and the Rack handler look for a config file when
50
52
  # none is named: the project root first, then the Rails-style config/.
@@ -146,6 +148,8 @@ module Kino
146
148
  # bind "0.0.0.0"
147
149
  # port 9292
148
150
  # workers 8 # ractors (or thread groups in :threaded mode)
151
+ # max_workers 32 # elastic pool ceiling; unset = fixed pool
152
+ # scale_down_after 30 # seconds idle before an extra worker retires
149
153
  # threads 3 # threads per worker
150
154
  # mode :ractor # :auto | :ractor | :threaded
151
155
  # queue_depth 2048
@@ -172,9 +176,19 @@ module Kino
172
176
  # Port to listen on; 0 picks an ephemeral port.
173
177
  def port(port) = @config.set(:port, Integer(port))
174
178
 
175
- # Worker count (ractors in :ractor mode); defaults to CPU cores.
179
+ # Worker count (ractors in :ractor mode); defaults to CPU cores. The
180
+ # pool floor when max_workers is set.
176
181
  def workers(count) = @config.set(:workers, Integer(count))
177
182
 
183
+ # Pool ceiling: under sustained queue pressure the pool grows past
184
+ # `workers`, one worker at a time, up to this many. Unset (the
185
+ # default) keeps the pool fixed at `workers`.
186
+ def max_workers(count) = @config.set(:max_workers, Integer(count))
187
+
188
+ # Seconds a worker above the floor must sit idle before it is
189
+ # retired (default 30). Only meaningful with max_workers.
190
+ def scale_down_after(seconds) = @config.set(:scale_down_after, seconds)
191
+
178
192
  # Threads per worker (I/O concurrency inside one ractor); default is
179
193
  # mode-dependent: 1 in :ractor mode, 3 in :threaded.
180
194
  def threads(count) = @config.set(:threads, Integer(count))
data/lib/kino/kino.so CHANGED
Binary file
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # A thread on the main ractor that calls `scan` every `tick` seconds
6
+ # until stopped. Main-ractor so it stays responsive when worker ractors
7
+ # are wedged. A scan that raises is logged and skipped, never fatal:
8
+ # monitors keep the server healthy, they must not take it down.
9
+ class Monitor
10
+ def initialize(name:, tick:)
11
+ @name = name
12
+ @tick = tick
13
+ @running = false
14
+ @thread = nil
15
+ end
16
+
17
+ def start
18
+ @running = true
19
+ @thread = Thread.new do
20
+ Thread.current.name = @name
21
+ run
22
+ end
23
+ self
24
+ end
25
+
26
+ def stop
27
+ @running = false
28
+ @thread&.join(@tick * 2)
29
+ end
30
+
31
+ private
32
+
33
+ def run
34
+ tick while @running
35
+ rescue => e
36
+ Log.error("#{@name} crashed: #{e.class}: #{e.message}")
37
+ end
38
+
39
+ def tick
40
+ scan
41
+ rescue => e
42
+ Log.error("#{@name} tick error: #{e.class}: #{e.message}")
43
+ ensure
44
+ sleep @tick
45
+ end
46
+
47
+ # One poll; subclasses define it.
48
+ def scan
49
+ raise NotImplementedError, "#{self.class} must define scan"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # Grows and shrinks the worker pool between `floor` and `ceiling`. One
6
+ # thread on the main ractor polls the native queue and per-slot sensors
7
+ # every tick and drives a pool (RactorSupervisor or ThreadedPool) through
8
+ # three methods: `active_count`, `groups` (worker index => slot ids for
9
+ # every worker that may be retired), `grow`, and `retire(index)`.
10
+ #
11
+ # Policy: grow by one worker per tick once requests have been waiting in
12
+ # the queue on two consecutive ticks (a burst that clears within a tick
13
+ # is not pressure); retire one worker per tick, the one idle longest,
14
+ # once it has been idle for `scale_down_after`. "Idle" means no slot of
15
+ # the worker held a request at two consecutive samples and its served
16
+ # count did not move between them, so a worker serving short requests
17
+ # between samples is never mistaken for an idle one.
18
+ class PoolScaler < Monitor
19
+ TICK = 0.1
20
+ # Consecutive ticks with a non-empty queue before the pool grows.
21
+ PRESSURE_TICKS = 2
22
+
23
+ def initialize(server_id:, pool:, floor:, ceiling:, scale_down_after:, tick: TICK)
24
+ super(name: "pool scaler", tick: tick)
25
+ @server_id = server_id
26
+ @pool = pool
27
+ @floor = floor
28
+ @ceiling = ceiling
29
+ @scale_down_after = scale_down_after
30
+ @pressure = 0
31
+ @last_served = {}
32
+ @idle_since = {}
33
+ end
34
+
35
+ # One policy step over one set of observations: the monotonic time,
36
+ # the queue depth, and worker_stats rows ([slot, served, in_flight,
37
+ # busy_ms, quarantined, retired]). Public so the policy is testable
38
+ # without a server.
39
+ def step(now, queued, rows)
40
+ @pressure = queued.positive? ? @pressure + 1 : 0
41
+ if @pressure >= PRESSURE_TICKS
42
+ grow if @pool.active_count < @ceiling
43
+ return
44
+ end
45
+
46
+ observe_idle(now, rows)
47
+ return unless @pool.active_count > @floor
48
+
49
+ index, since = @idle_since.min_by { |_index, at| at }
50
+ return unless index && now - since >= @scale_down_after
51
+
52
+ retire(index)
53
+ end
54
+
55
+ private
56
+
57
+ def scan
58
+ queued, _in_flight = Native.queue_stats(@server_id)
59
+ step(Process.clock_gettime(Process::CLOCK_MONOTONIC), queued, Native.worker_stats(@server_id))
60
+ end
61
+
62
+ def grow
63
+ index = @pool.grow
64
+ return unless index
65
+
66
+ Native.record_scale_up(@server_id)
67
+ Log.info("pool grew to #{@pool.active_count} workers (queue pressure)")
68
+ end
69
+
70
+ def retire(index)
71
+ return unless @pool.retire(index)
72
+
73
+ forget(index)
74
+ Native.record_scale_down(@server_id)
75
+ Log.info("pool shrank to #{@pool.active_count} workers (worker-#{index} idle)")
76
+ end
77
+
78
+ # Per worker: idle when no slot holds a request at this sample and the
79
+ # served total did not move since the previous one. First sighting of
80
+ # a worker is never idle (it takes two samples to know).
81
+ def observe_idle(now, rows)
82
+ by_slot = rows.to_h { |row| [row[0], row] }
83
+ groups = @pool.groups
84
+ (@idle_since.keys - groups.keys).each { |index| forget(index) }
85
+ groups.each do |index, slot_ids|
86
+ served = slot_ids.sum { |id| by_slot.dig(id, 1) || 0 }
87
+ busy = slot_ids.any? { |id| (by_slot.dig(id, 2) || 0).positive? }
88
+ idle = !busy && @last_served[index] == served
89
+ @last_served[index] = served
90
+ if idle
91
+ @idle_since[index] ||= now
92
+ else
93
+ @idle_since.delete(index)
94
+ end
95
+ end
96
+ end
97
+
98
+ def forget(index)
99
+ @idle_since.delete(index)
100
+ @last_served.delete(index)
101
+ end
102
+ end
103
+ end
@@ -3,54 +3,22 @@
3
3
  module Kino
4
4
  # @private
5
5
  # Polls per-slot busy_ms and, past the timeout, quarantines a wedged slot
6
- # and asks the replacer to spawn a fresh worker. Runs one thread on the
7
- # main ractor (uncontended by wedged worker ractors, so it stays
8
- # responsive in :ractor mode). Never interrupts the wedged worker.
9
- class QuarantineMonitor
6
+ # and asks the replacer to spawn a fresh worker. Never interrupts the
7
+ # wedged worker.
8
+ class QuarantineMonitor < Monitor
10
9
  def initialize(server_id:, timeout_ms:, max:, replacer:, tick: 0.5)
10
+ super(name: "quarantine monitor", tick: tick)
11
11
  @server_id = server_id
12
12
  @timeout_ms = timeout_ms
13
13
  @max = max
14
14
  @replacer = replacer
15
- @tick = tick
16
15
  @outstanding = 0
17
16
  @at_cap_logged = false
18
- @running = false
19
- @thread = nil
20
- end
21
-
22
- def start
23
- @running = true
24
- @thread = Thread.new do
25
- Thread.current.name = "quarantine"
26
- run
27
- end
28
- self
29
- end
30
-
31
- def stop
32
- @running = false
33
- @thread&.join(@tick * 2)
34
17
  end
35
18
 
36
19
  private
37
20
 
38
- def run
39
- tick while @running
40
- rescue => e
41
- Log.error("quarantine monitor crashed: #{e.class}: #{e.message}")
42
- end
43
-
44
- def tick
45
- scan_slots
46
- rescue => e
47
- # A bad tick must never kill the monitor.
48
- Log.error("quarantine tick error: #{e.class}: #{e.message}")
49
- ensure
50
- sleep @tick
51
- end
52
-
53
- def scan_slots
21
+ def scan
54
22
  Native.worker_stats(@server_id).each do |index, _served, _in_flight, busy_ms, quarantined|
55
23
  next if quarantined || busy_ms <= @timeout_ms
56
24
 
@@ -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`
@@ -126,10 +137,10 @@ module Kino
126
137
  else
127
138
  @workers * @threads
128
139
  end
129
- @worker_threads = []
130
- @worker_threads_lock = Mutex.new
131
140
  @supervisor = nil
141
+ @threaded_pool = nil
132
142
  @quarantine_monitor = nil
143
+ @pool_scaler = nil
133
144
  @started = false
134
145
  end
135
146
 
@@ -158,7 +169,8 @@ module Kino
158
169
  tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
159
170
  http2: @http2,
160
171
  lanes: @lanes, log_requests: @log_requests,
161
- 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,
162
174
  control_bind: @control_bind, control_token: @control_token
163
175
  )
164
176
  booted = true
@@ -169,12 +181,15 @@ module Kino
169
181
  # lifetime so in-flight buffers survive even a worker ractor crash.
170
182
  @pin_keeper = Native.pin_keeper(@id)
171
183
  if @mode == :ractor
184
+ warn_scheduler_cap
172
185
  @supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads,
173
186
  batch: @batch, hooks: @worker_hooks, on_worker_exit: @on_worker_exit).start
174
187
  else
175
- @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)
176
190
  end
177
191
  start_quarantine_monitor if @quarantine_timeout_ms
192
+ start_pool_scaler if elastic?
178
193
  Native.control_ready(@id)
179
194
  HookFire.fire(@after_boot, "after_boot")
180
195
  @started = true
@@ -192,6 +207,7 @@ module Kino
192
207
  def shutdown(timeout: nil)
193
208
  return unless @started
194
209
 
210
+ @pool_scaler&.stop
195
211
  @quarantine_monitor&.stop
196
212
  deadline = monotonic_now + (timeout || @shutdown_timeout)
197
213
  Native.stop_accepting(@id)
@@ -224,7 +240,6 @@ module Kino
224
240
  # The runtime is gone, so hyper has dropped every pinned buffer;
225
241
  # the keeper (and the strings it marked) may now be collected.
226
242
  @pin_keeper = nil
227
- @worker_threads.clear
228
243
  @started = false
229
244
  remove_pidfile if @pidfile
230
245
  nil
@@ -233,7 +248,7 @@ module Kino
233
248
  # Block until every worker has exited (i.e. until shutdown).
234
249
  # @return [void]
235
250
  def wait
236
- @supervisor ? @supervisor.join : @worker_threads.each(&:join)
251
+ pool.join
237
252
  end
238
253
 
239
254
  # Production entry point: build the server and {#run} it. The `kino`
@@ -298,19 +313,22 @@ module Kino
298
313
  # lanes mode) once started
299
314
  def stats
300
315
  base = {
301
- mode: @mode, lanes: @lanes, workers: @workers, threads: @threads,
302
- 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
303
319
  }
304
320
  return base unless @started
305
321
 
306
322
  queued, in_flight, served, rejected, timeouts, respawns, lane_depths = Native.server_stats(@id)
307
323
  base.merge!(queued:, in_flight:, served:, rejected:, timeouts:, respawns:)
308
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:)
309
327
  rows = Native.worker_stats(@id)
310
- base[:worker_status] = rows.map do |index, served, in_flight, busy_ms, quarantined|
311
- {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:}
312
330
  end
313
- base[:quarantined] = rows.count { |_index, _served, _in_flight, _busy_ms, quarantined| quarantined }
331
+ base[:quarantined] = rows.count { |row| row[4] }
314
332
  count, sum_seconds = Native.queue_time(@id)
315
333
  base[:queue_time] = {count:, sum_seconds:}
316
334
  base
@@ -318,63 +336,38 @@ module Kino
318
336
 
319
337
  private
320
338
 
321
- # Register a fresh dispatch slot and run a worker thread on it; returns
322
- # the thread. Used at boot and by the quarantine replacer.
323
- def spawn_worker_thread
324
- worker_id = Native.register_worker(@id)
325
- Thread.new do
326
- # Named so log lines from inside say which worker spoke.
327
- Thread.current.name = "worker-#{worker_id}"
328
- error = nil
329
- begin
330
- Worker.run(@id, worker_id, @app, @batch, @worker_hooks)
331
- rescue Exception => e # rubocop:disable Lint/RescueException -- a hard crash in a threaded worker thread
332
- error = e
333
- raise
334
- ensure
335
- HookFire.fire(@on_worker_exit, "on_worker_exit", worker_id, error)
336
- end
337
- 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
338
344
  end
339
345
 
340
- # Track a replacement thread spawned outside the initial pool assignment
341
- # (the quarantine replacer) so shutdown's join/done?/kill sweeps see it.
342
- def track_replacement_thread(thread)
343
- @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
344
353
  end
345
354
 
346
- # @private
347
- # The :threaded-mode quarantine replacer: spawns a replacement worker
348
- # thread, quarantines the wedged slot, then tracks the new thread so
349
- # shutdown's join/done?/kill sweeps see it. Built from bound Method
350
- # objects instead of a server reference, so it drives the server
351
- # through those methods without send or instance_variable_get.
352
- class ThreadedReplacer
353
- def initialize(server_id:, spawner:, tracker:)
354
- @server_id = server_id
355
- @spawner = spawner
356
- @tracker = tracker
357
- 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
358
362
 
359
- def replace(worker_id)
360
- thread = @spawner.call # spawn FIRST (may raise ThreadError)
361
- Native.quarantine_slot(@server_id, worker_id) # quarantine after success
362
- @tracker.call(thread)
363
- true
364
- 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")
365
365
  end
366
- private_constant :ThreadedReplacer
367
366
 
368
- # A replacer.replace(worker_id) spawns a replacement worker, then
369
- # quarantines the wedged slot, mode-appropriately. In :ractor the
370
- # supervisor is the replacer; in :threaded a small object over
371
- # spawn_worker_thread.
372
- def start_quarantine_monitor
373
- replacer = @supervisor || ThreadedReplacer.new(server_id: @id, spawner: method(:spawn_worker_thread),
374
- tracker: method(:track_replacement_thread))
375
- @quarantine_monitor = QuarantineMonitor.new(
376
- server_id: @id, timeout_ms: @quarantine_timeout_ms,
377
- 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
378
371
  ).start
379
372
  end
380
373
 
@@ -400,6 +393,11 @@ module Kino
400
393
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
401
394
  end
402
395
 
396
+ # A pool that can grow: the ceiling is above the floor.
397
+ def elastic?
398
+ @max_workers > @workers
399
+ end
400
+
403
401
  # Default connection cap: most of the process open-file limit. A
404
402
  # connection flood's failure mode is descriptor exhaustion, and in
405
403
  # :ractor/:threaded mode the app's own sockets and files share this
@@ -474,34 +472,15 @@ module Kino
474
472
  end
475
473
 
476
474
  def join_workers(deadline)
477
- if @supervisor
478
- @supervisor.shutdown([deadline - monotonic_now, 0].max)
479
- else
480
- threads = @worker_threads_lock.synchronize { @worker_threads.dup }
481
- threads.each do |thread|
482
- thread.join([deadline - monotonic_now, 0.01].max)
483
- end
484
- end
475
+ pool.shutdown([deadline - monotonic_now, 0].max)
485
476
  end
486
477
 
487
478
  def workers_done?
488
- if @supervisor
489
- @supervisor.done?
490
- else
491
- threads = @worker_threads_lock.synchronize { @worker_threads.dup }
492
- threads.none?(&:alive?)
493
- end
479
+ pool.done?
494
480
  end
495
481
 
496
482
  def kill_stragglers
497
- if @supervisor
498
- # Ractors cannot be force-killed; their clients were already freed
499
- # by abort_all_inflight. The stuck ractor leaks until process exit.
500
- Log.error("shutdown deadline passed with stuck ractor workers") unless @supervisor.done?
501
- else
502
- threads = @worker_threads_lock.synchronize { @worker_threads.dup }
503
- threads.each { |thread| thread.kill if thread.alive? }
504
- end
483
+ pool.kill_stragglers
505
484
  end
506
485
 
507
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
@@ -40,6 +40,21 @@
40
40
  # `workers` instead.
41
41
  # threads 1
42
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
+
43
58
  ## Dispatch mode
44
59
  #
45
60
  # :auto - picks :ractor when your app supports it, else :threaded.
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # The :threaded-mode worker pool: `workers` groups of `threads` plain
6
+ # Threads, each thread on its own dispatch slot. A group is one ractor's
7
+ # worth of capacity, so `workers` and `max_workers` mean the same thing
8
+ # in both modes. Behind PoolScaler here (`grow`, `retire`, `groups`,
9
+ # `active_count`), the quarantine monitor's replacer (`replace`), and
10
+ # the join/kill sweeps Server#shutdown runs.
11
+ class ThreadedPool
12
+ def initialize(server_id, app, threads:, batch: 1, hooks: nil, on_worker_exit: nil)
13
+ @server_id = server_id
14
+ @app = app
15
+ @threads = threads
16
+ @batch = batch
17
+ @hooks = hooks
18
+ @on_worker_exit = on_worker_exit
19
+ @lock = Mutex.new
20
+ # index => {slots:, threads:}; the groups asked to leave; quarantine
21
+ # replacements (one thread each, standing in for a wedged slot:
22
+ # neither counted nor retired, so the wedged group keeps counting as
23
+ # the capacity it still is). Retired groups hand their slots back to
24
+ # the bank.
25
+ @groups = {}
26
+ @slot_to_group = {}
27
+ @retiring = {}
28
+ @bank = SlotBank.new(server_id)
29
+ @replacements = {}
30
+ @wedged = {}
31
+ @next_index = -1
32
+ end
33
+
34
+ def start(workers)
35
+ workers.times { spawn_group }
36
+ report_active
37
+ self
38
+ end
39
+
40
+ # Groups that are staying: quarantine replacements aside, and minus
41
+ # those already told to leave. A retiring group leaves the count at
42
+ # once, not when its last thread exits, so the scaler never sees a
43
+ # stale surplus and retires past its floor.
44
+ def active_count
45
+ @lock.synchronize do
46
+ @groups.count { |index, _| !@replacements.key?(index) && !@retiring.key?(index) }
47
+ end
48
+ end
49
+
50
+ # Group index => slot ids for every group the scaler may retire: not
51
+ # already leaving, not wedged, not a quarantine replacement.
52
+ def groups
53
+ reap
54
+ @lock.synchronize do
55
+ @groups
56
+ .reject { |index, _| @retiring.key?(index) || @wedged.key?(index) || @replacements.key?(index) }
57
+ .transform_values { |group| group[:slots].dup }
58
+ end
59
+ end
60
+
61
+ # Add one group; returns its index.
62
+ def grow
63
+ reap
64
+ index = spawn_group
65
+ report_active
66
+ index
67
+ end
68
+
69
+ # Send a group home: its slots stop receiving work now, each thread
70
+ # finishes what it holds and leaves at its next idle tick, and the
71
+ # slots come back to the free list once every thread has exited.
72
+ def retire(index)
73
+ slots = @lock.synchronize do
74
+ next nil unless @groups.key?(index) && !@retiring.key?(index)
75
+
76
+ @retiring[index] = true
77
+ @groups[index][:slots]
78
+ end
79
+ return false unless slots
80
+
81
+ slots.each { |id| Native.retire_slot(@server_id, id) }
82
+ true
83
+ end
84
+
85
+ # The quarantine replacer: spawn a replacement thread on a fresh slot
86
+ # FIRST (may raise ThreadError), then quarantine the wedged slot.
87
+ def replace(worker_id)
88
+ spawn_group(slots: 1, replacement: true)
89
+ Native.quarantine_slot(@server_id, worker_id)
90
+ @lock.synchronize do
91
+ wedged = @slot_to_group[worker_id]
92
+ @wedged[wedged] = true if wedged
93
+ end
94
+ true
95
+ end
96
+
97
+ # Join every thread up to the (numeric) deadline.
98
+ def shutdown(timeout)
99
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
100
+ all_threads.each do |thread|
101
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
+ thread.join([remaining, 0.01].max)
103
+ end
104
+ end
105
+
106
+ def done?
107
+ all_threads.none?(&:alive?)
108
+ end
109
+
110
+ # Block until every thread exits on its own (drain elsewhere).
111
+ def join
112
+ all_threads.each(&:join)
113
+ end
114
+
115
+ def kill_stragglers
116
+ all_threads.each { |thread| thread.kill if thread.alive? }
117
+ end
118
+
119
+ private
120
+
121
+ def all_threads
122
+ @lock.synchronize { @groups.values.flat_map { |group| group[:threads] } }
123
+ end
124
+
125
+ # The group is in the table before its first thread starts, so a
126
+ # ThreadError partway through leaves nothing untracked for shutdown.
127
+ def spawn_group(slots: @threads, replacement: false)
128
+ group = {slots: [], threads: []}
129
+ index = @lock.synchronize do
130
+ @next_index += 1
131
+ @groups[@next_index] = group
132
+ @replacements[@next_index] = true if replacement
133
+ @next_index
134
+ end
135
+ slots.times do
136
+ id = @bank.claim
137
+ @lock.synchronize do
138
+ group[:slots] << id
139
+ @slot_to_group[id] = index
140
+ end
141
+ thread = spawn_thread(id)
142
+ @lock.synchronize { group[:threads] << thread }
143
+ end
144
+ index
145
+ end
146
+
147
+ def spawn_thread(worker_id)
148
+ Thread.new do
149
+ # Named so log lines from inside say which worker spoke.
150
+ Thread.current.name = "worker-#{worker_id}"
151
+ error = nil
152
+ begin
153
+ Worker.run(@server_id, worker_id, @app, @batch, @hooks)
154
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hard crash in a threaded worker thread
155
+ error = e
156
+ raise
157
+ ensure
158
+ HookFire.fire(@on_worker_exit, "on_worker_exit", worker_id, error)
159
+ end
160
+ end
161
+ end
162
+
163
+ # Retiring groups whose threads have all exited give their slots back
164
+ # and leave the table. Runs before every pool decision, so the count
165
+ # the control plane sees never lags by more than a scaler tick.
166
+ def reap
167
+ freed = @lock.synchronize do
168
+ done = @retiring.keys.select { |index| @groups[index][:threads].none?(&:alive?) }
169
+ done.flat_map do |index|
170
+ group = @groups.delete(index)
171
+ @retiring.delete(index)
172
+ group[:slots].each { |id| @slot_to_group.delete(id) }
173
+ group[:slots]
174
+ end
175
+ end
176
+ return if freed.empty?
177
+
178
+ @bank.release(freed)
179
+ report_active
180
+ end
181
+
182
+ def report_active
183
+ Native.set_active_workers(@server_id, active_count)
184
+ end
185
+ end
186
+ end
data/lib/kino/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Kino
4
4
  # The gem version (single source of truth; ext/kino/Cargo.toml syncs).
5
- VERSION = "0.6.0"
5
+ VERSION = "0.7.0"
6
6
  end
data/lib/kino.rb CHANGED
@@ -57,8 +57,12 @@ require_relative "kino/configuration"
57
57
  require_relative "kino/hook_fire"
58
58
  require_relative "kino/worker_hooks"
59
59
  require_relative "kino/worker"
60
+ require_relative "kino/slot_bank"
60
61
  require_relative "kino/ractor_supervisor"
62
+ require_relative "kino/threaded_pool"
63
+ require_relative "kino/monitor"
61
64
  require_relative "kino/quarantine_monitor"
65
+ require_relative "kino/pool_scaler"
62
66
  require_relative "kino/server"
63
67
 
64
68
  # Hand the frozen shareable singletons to the native layer: it sets them
@@ -9,13 +9,13 @@ module Rackup
9
9
  module Kino
10
10
  # Host option name => Kino setting plus the coercion it needs: rackup
11
11
  # hands `-O NAME=VALUE` values (and its own -p) over as strings.
12
- OPTION_MAP = {
12
+ OPTION_MAP = Ractor.make_shareable({
13
13
  Host: [:bind, ->(value) { value.to_s }],
14
14
  Port: [:port, ->(value) { Integer(value) }],
15
15
  Workers: [:workers, ->(value) { Integer(value) }],
16
16
  Threads: [:threads, ->(value) { Integer(value) }],
17
17
  Mode: [:mode, ->(value) { value.to_sym }]
18
- }.freeze
18
+ })
19
19
  private_constant :OPTION_MAP
20
20
 
21
21
  # Boot a server for `app` and block until it shuts down, the way the
@@ -27,7 +27,7 @@ module Rackup
27
27
  # want a handle on it
28
28
  # @return [::Kino::Server] the stopped server, after shutdown
29
29
  def self.run(app, **options)
30
- require "kino"
30
+ require "kino" # audition:disable runtime-require
31
31
  server = ::Kino::Server.new(app, **server_options(options))
32
32
  yield server if block_given?
33
33
  server.run
@@ -58,7 +58,7 @@ module Rackup
58
58
  # @param options [Hash{Symbol => Object}]
59
59
  # @return [Hash{Symbol => Object}]
60
60
  def self.server_options(options)
61
- require "kino"
61
+ require "kino" # audition:disable runtime-require
62
62
  options = options.dup
63
63
  host_defaults = {}
64
64
  if (typed = options.delete(:user_supplied_options))
data/sig/kino.rbs CHANGED
@@ -110,6 +110,8 @@ module Kino
110
110
  def bind: (String host) -> untyped
111
111
  def port: (int port) -> untyped
112
112
  def workers: (int count) -> untyped
113
+ def max_workers: (int count) -> untyped
114
+ def scale_down_after: (Numeric seconds) -> untyped
113
115
  def threads: (int count) -> untyped
114
116
  def mode: (Symbol | String mode) -> untyped
115
117
  def queue_depth: (int depth) -> untyped
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kino
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Yaroslav Markin
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-01 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -180,12 +180,16 @@ files:
180
180
  - lib/kino/kino.so
181
181
  - lib/kino/log.rb
182
182
  - lib/kino/logger.rb
183
+ - lib/kino/monitor.rb
183
184
  - lib/kino/null_input.rb
185
+ - lib/kino/pool_scaler.rb
184
186
  - lib/kino/quarantine_monitor.rb
185
187
  - lib/kino/ractor_supervisor.rb
186
188
  - lib/kino/server.rb
189
+ - lib/kino/slot_bank.rb
187
190
  - lib/kino/stream.rb
188
191
  - lib/kino/templates/kino.rb.tt
192
+ - lib/kino/threaded_pool.rb
189
193
  - lib/kino/version.rb
190
194
  - lib/kino/worker.rb
191
195
  - lib/kino/worker_hooks.rb