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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +52 -0
- data/doc/architecture.md +10 -0
- data/lib/kino/cli.rb +12 -4
- data/lib/kino/configuration.rb +16 -2
- data/lib/kino/kino.so +0 -0
- data/lib/kino/monitor.rb +52 -0
- data/lib/kino/pool_scaler.rb +103 -0
- data/lib/kino/quarantine_monitor.rb +5 -37
- data/lib/kino/ractor_supervisor.rb +102 -8
- data/lib/kino/server.rb +64 -85
- data/lib/kino/slot_bank.rb +31 -0
- data/lib/kino/templates/kino.rb.tt +15 -0
- data/lib/kino/threaded_pool.rb +186 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino.rb +4 -0
- data/lib/rackup/handler/kino.rb +4 -4
- data/sig/kino.rbs +2 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b101408a972721dec1c1041f195c4c56a18a8ca31e586903215382e54acc5fb0
|
|
4
|
+
data.tar.gz: '0386e61aa5dcbb60ce1bba381f5733b86d74e0352a47494273a575ca49ef0b47'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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}, #{
|
|
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
|
|
data/lib/kino/configuration.rb
CHANGED
|
@@ -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
|
data/lib/kino/monitor.rb
ADDED
|
@@ -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.
|
|
7
|
-
#
|
|
8
|
-
|
|
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
|
|
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
|
|
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:
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
#
|
|
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) {
|
|
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
|
|
85
|
-
|
|
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,
|
|
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
|
-
@
|
|
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
|
-
|
|
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,
|
|
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 { |
|
|
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
|
-
#
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
#
|
|
341
|
-
#
|
|
342
|
-
def
|
|
343
|
-
@
|
|
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
|
-
#
|
|
347
|
-
#
|
|
348
|
-
#
|
|
349
|
-
#
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
data/lib/rackup/handler/kino.rb
CHANGED
|
@@ -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
|
-
}
|
|
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.
|
|
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-
|
|
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
|