kino 0.5.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 +67 -0
- data/README.md +153 -44
- data/doc/architecture.md +64 -3
- data/doc/benchmarks.md +120 -26
- data/lib/kino/cli.rb +12 -4
- data/lib/kino/configuration.rb +22 -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 +66 -85
- data/lib/kino/slot_bank.rb +31 -0
- data/lib/kino/templates/kino.rb.tt +20 -0
- data/lib/kino/threaded_pool.rb +186 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +14 -11
- data/lib/kino.rb +4 -0
- data/lib/rackup/handler/kino.rb +4 -4
- data/sig/kino.rbs +3 -0
- metadata +20 -2
data/doc/benchmarks.md
CHANGED
|
@@ -17,9 +17,21 @@ the deployment most apps run today.
|
|
|
17
17
|
9R14 (Genoa), 16 GB RAM, Amazon Linux 2023, kernel 6.18. A realistic
|
|
18
18
|
app-server size, deliberately: nobody provisions a 32-core box per
|
|
19
19
|
app process.
|
|
20
|
-
- Toolchain built on the box via mise: Ruby 4.0.
|
|
21
|
-
`RUBY_YJIT_ENABLE=1` for every server), Rust
|
|
20
|
+
- Toolchain built on the box via mise: Ruby 4.0.6 (**YJIT enabled**,
|
|
21
|
+
`RUBY_YJIT_ENABLE=1` for every server), Rust stable, Kino compiled in
|
|
22
22
|
the release profile.
|
|
23
|
+
- **2026-09 full re-measurement** (a fresh c7a.2xlarge): every number in
|
|
24
|
+
this document and the README was re-run on Ruby 4.0.6 / kernel 6.18 /
|
|
25
|
+
Puma 8.0.2, adding the sharded-I/O and HTTP/2 studies. Kino's numbers
|
|
26
|
+
reproduced within 1-3% across the board—including the /io slot
|
|
27
|
+
ceiling and the arena balloon, both intact. The one real shift: Puma
|
|
28
|
+
8.0.2 is faster than 7.x (142k plaintext, 61k /cpu), narrowing
|
|
29
|
+
ractor mode's /cpu lead to +25% (was +34%). Reversed-boot-order
|
|
30
|
+
re-runs reproduced within ~1%. A methodology trap worth recording:
|
|
31
|
+
a 5-second single-endpoint warmup understates memory badly (81 MB
|
|
32
|
+
where the full battery shows 137 MB) and hides the arena balloon
|
|
33
|
+
entirely—memory is only comparable after the full endpoint battery,
|
|
34
|
+
and /io numbers are only comparable at equal slot counts.
|
|
23
35
|
- Load generator: wrk 4.2 on the same host, 8-second windows, 64
|
|
24
36
|
connections (`bench/run.sh 8 64`). Same-host load generation costs
|
|
25
37
|
both sides CPU equally; we verified the generator was not the
|
|
@@ -249,14 +261,17 @@ visible.
|
|
|
249
261
|
|
|
250
262
|
| config | RSS | PSS |
|
|
251
263
|
|---|---:|---:|
|
|
252
|
-
| Kino :ractor 8×1 (default) |
|
|
253
|
-
| Kino lanes 8×1 |
|
|
254
|
-
| Kino :ractor 8×3 |
|
|
264
|
+
| Kino :ractor 8×1 (default) | 137 | **135** |
|
|
265
|
+
| Kino lanes 8×1 | 128 | **126** |
|
|
266
|
+
| Kino :ractor 8×3 | 170 | **168** |
|
|
255
267
|
| Kino :threaded 8×3 (`MALLOC_ARENA_MAX=2`) | 109 | **107** |
|
|
256
|
-
| Kino :threaded 8×3 (no arena cap) |
|
|
257
|
-
| Puma cluster 8×3 | 1,
|
|
268
|
+
| Kino :threaded 8×3 (no arena cap) | 672 | **670**¹ |
|
|
269
|
+
| Puma cluster 8×3 | 1,216 | **1,072** |
|
|
258
270
|
|
|
259
|
-
|
|
271
|
+
(2026-09 re-measure; the 2026-06 numbers reproduced within a few MB on
|
|
272
|
+
every row—the arena-capped threaded row to the megabyte.)
|
|
273
|
+
|
|
274
|
+
The tiny app is ~8× lighter than the cluster in ractor mode, ~10× in
|
|
260
275
|
arena-capped threaded mode. RSS ≈ PSS for every Kino row (one process,
|
|
261
276
|
nothing to share) and within ~12% for Puma here: a trivial app has almost
|
|
262
277
|
no shared state, so Puma's footprint is ~1,051 MB of *private* per-worker
|
|
@@ -280,18 +295,19 @@ Here copy-on-write **does** matter, which is exactly why PSS is mandatory:
|
|
|
280
295
|
|
|
281
296
|
| config | RSS | PSS |
|
|
282
297
|
|---|---:|---:|
|
|
283
|
-
| Kino :threaded (one process) | 97 | **
|
|
284
|
-
| Puma cluster 8×
|
|
298
|
+
| Kino :threaded (one process) | 97 | **95** |
|
|
299
|
+
| Puma cluster 8×5 (preload) | 813 | **405** |
|
|
300
|
+
| Puma cluster 8×5 (no preload) | 824 | **646** |
|
|
285
301
|
|
|
286
302
|
Puma serves the same Rails framework from 8 forks that share it
|
|
287
|
-
copy-on-write; RSS counts that shared framework once per worker (
|
|
288
|
-
PSS counts it once (
|
|
303
|
+
copy-on-write; RSS counts that shared framework once per worker (813 MB),
|
|
304
|
+
PSS counts it once (405 MB). The fair ratio is **~4×**, not the ~8× a
|
|
289
305
|
naive RSS sum reports—this is the correction that prompted the whole
|
|
290
|
-
re-measure.
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
not the live object heap.
|
|
306
|
+
re-measure. The 2026-09 run also split out preload: it now saves a
|
|
307
|
+
third (405 vs 646 MB PSS)—worth turning on—but even preloaded, Ruby's
|
|
308
|
+
GC dirties heap pages and breaks copy-on-write, so each worker keeps a
|
|
309
|
+
large private heap. That is why "CoW should make a fork cluster nearly
|
|
310
|
+
free" is only half true—it shares the code, not the live object heap.
|
|
295
311
|
|
|
296
312
|
## Run-to-run variance (a.k.a. "is this a regression?")
|
|
297
313
|
|
|
@@ -376,27 +392,46 @@ crash semantics, stealing fairness, and drain behavior have spec
|
|
|
376
392
|
coverage but not production mileage. (On loopback-bound macOS, lanes
|
|
377
393
|
lose a few percent instead; see the secondary table below.)
|
|
378
394
|
|
|
395
|
+
## Sharded I/O (`io_shards true`)
|
|
396
|
+
|
|
397
|
+
`bench/studies.sh 8 64 shards`, ractor 8×3, 2026-09 reference box:
|
|
398
|
+
|
|
399
|
+
| case (/plaintext) | req/s |
|
|
400
|
+
|---|---:|
|
|
401
|
+
| shared tokio runtime (baseline) | 193,461 |
|
|
402
|
+
| io_shards, default shard count | 195,436 |
|
|
403
|
+
| io_shards, `io_threads 2` | 170,509 |
|
|
404
|
+
| io_shards, `io_threads 4` | 196,298 |
|
|
405
|
+
| io_shards, `io_threads 8` | 199,657 |
|
|
406
|
+
|
|
407
|
+
On 8 cores the shards buy +1-3%, best at `io_threads 8` (the
|
|
408
|
+
half-the-cores default is close behind; 2 shards choke on accept
|
|
409
|
+
handoff). The design removes work-stealing and cross-thread wakeups,
|
|
410
|
+
so the win scales with scheduler contention—expect more on boxes with
|
|
411
|
+
more cores and connections, and measure on your own core count.
|
|
412
|
+
|
|
379
413
|
## Logging costs
|
|
380
414
|
|
|
381
415
|
Measured at full plaintext saturation (one log line per request—rates
|
|
382
416
|
that no real deployment logs at; treat these as worst-case ceilings, not
|
|
383
417
|
typical costs):
|
|
384
418
|
|
|
385
|
-
| case (8×3, same session) | req/s |
|
|
419
|
+
| case (8×3, same session, 2026-09) | req/s |
|
|
386
420
|
|---|---:|
|
|
387
|
-
| threaded, no logging |
|
|
388
|
-
| threaded, `log_requests true` (native access log) |
|
|
389
|
-
| ractor, access log off / on |
|
|
390
|
-
| app logs 1 line/req via shared `::Logger` (file) | **
|
|
391
|
-
| app logs 1 line/req via `Kino::Logger` (file) | **
|
|
421
|
+
| threaded, no logging | 213,166 |
|
|
422
|
+
| threaded, `log_requests true` (native access log) | 173,501 (−19%) |
|
|
423
|
+
| ractor, access log off / on | 191,152 / 163,777 (−14%) |
|
|
424
|
+
| app logs 1 line/req via shared `::Logger` (file) | **90,175** |
|
|
425
|
+
| app logs 1 line/req via `Kino::Logger` (file) | **154,653 (1.7×)** |
|
|
392
426
|
|
|
393
427
|
The shared-`::Logger` cost is the mutex: 24 worker threads serialize
|
|
394
428
|
through one lock plus a write syscall per line. `Kino::Logger` hands the
|
|
395
429
|
formatted line to a lock-free channel and returns—the remaining cost vs
|
|
396
430
|
not logging at all is Ruby-side formatting, which no device can remove.
|
|
397
|
-
(
|
|
398
|
-
|
|
399
|
-
|
|
431
|
+
(The multiple moves with the environment: 2.4× on the 2026-06 box,
|
|
432
|
+
1.7× on the 2026-09 one, 8.5× under Docker, where overlay-fs write
|
|
433
|
+
latency punished the synchronous logger hardest. The ranking is
|
|
434
|
+
environment-independent; the multiple is not.)
|
|
400
435
|
|
|
401
436
|
One trade-off worth knowing: the sink **never blocks** request threads,
|
|
402
437
|
so at absurd rates against a slow disk it drops lines once its 8192-line
|
|
@@ -408,6 +443,65 @@ Puma comparison note: request logging is opt-in there too (`--quiet` is
|
|
|
408
443
|
the default, `-v/--log-requests` enables it)—Kino's default-off
|
|
409
444
|
`log_requests` matches the ecosystem's standard behavior.
|
|
410
445
|
|
|
446
|
+
## HTTP/2
|
|
447
|
+
|
|
448
|
+
`bench/h2.sh`, Linux only (on macOS run it under Docker; the 2026-09
|
|
449
|
+
numbers below are from the c7a.2xlarge reference box). Every lane is
|
|
450
|
+
measured with h2load so the generator is identical everywhere: h2
|
|
451
|
+
lanes run 8 connections × 8 concurrent streams, h1 lanes 64
|
|
452
|
+
connections—the same total in-flight. Servers without native h2 get
|
|
453
|
+
the standard pattern instead: nginx terminating h2 and proxying
|
|
454
|
+
HTTP/1.1 upstream over keep-alive. One labeled run (kino ractor 8×3,
|
|
455
|
+
falcon `--count 8`, puma `-w 8 -t 3:3`, 5 s/lane); re-run the whole
|
|
456
|
+
script for close calls, per the variance section.
|
|
457
|
+
|
|
458
|
+
| target (h2 unless noted) | /plaintext | /10k | /big-cookie | /upload (64 KB) |
|
|
459
|
+
|---|---:|---:|---:|---:|
|
|
460
|
+
| kino h2c | 207,461 | 150,105 | 195,093 | 26,304 |
|
|
461
|
+
| kino h1 cleartext (same boot) | 116,082 | 97,634 | 110,614 | 24,338 |
|
|
462
|
+
| kino h2 TLS | 164,044 | 116,617 | 154,301 | 19,226 |
|
|
463
|
+
| kino h1 TLS (same boot) | 80,789 | 69,942 | 76,654 | 17,210 |
|
|
464
|
+
| falcon TLS (native h2) | 55,637 | 37,541 | 49,151 | 18,838 |
|
|
465
|
+
| nginx h2 → puma h1 | 81,234 | 57,891 | 51,332 | 1,247 |
|
|
466
|
+
| nginx h2 → kino h1 | 109,219 | 66,634 | 54,996 | 1,217 |
|
|
467
|
+
|
|
468
|
+
What the numbers say:
|
|
469
|
+
|
|
470
|
+
- **Native h2 beats h1 on the same server by +79% cleartext and +103%
|
|
471
|
+
over TLS** on /plaintext: the same 64 in-flight requests ride 8
|
|
472
|
+
connections instead of 64, so frames batch into fewer, larger
|
|
473
|
+
syscalls—and TLS amplifies it, since h1's 64 connections each pay
|
|
474
|
+
crypto per record. The `/big-cookie` lane (a ~2 KB cookie per
|
|
475
|
+
request) shows HPACK on top: the cookie crosses the wire once per
|
|
476
|
+
connection, not once per request, and holds 94% of bare-plaintext
|
|
477
|
+
throughput where h1 loses 5%.
|
|
478
|
+
- **Native h2 beats proxied h2 by +50%** with the *same backend*: the
|
|
479
|
+
nginx→kino-h1 lane is the proxy-cost control, and the extra hop,
|
|
480
|
+
re-parse, and re-serialize cost ~55k req/s on /plaintext.
|
|
481
|
+
- **Uploads run at h1 parity**—but only after a fix this lane
|
|
482
|
+
caught: h2 delivers bodies as 16 KB DATA frames, and `read_body`
|
|
483
|
+
originally crossed the GVL once per chunk, halving upload
|
|
484
|
+
throughput. It now drains every queued chunk per crossing
|
|
485
|
+
(doc/architecture.md), and a knob sweep over hyper's h2 codec
|
|
486
|
+
(frame size, adaptive/bigger windows) moved nothing afterwards.
|
|
487
|
+
nginx's h2 upload collapse (~1.5k) is its default-config
|
|
488
|
+
request-body flow control; tune `http2_body_preread_size`/buffering
|
|
489
|
+
before drawing conclusions there.
|
|
490
|
+
- falcon lands at roughly a third of kino-h2-TLS on fast handlers and
|
|
491
|
+
slightly behind on uploads. Single-run caveat: the nginx lanes
|
|
492
|
+
showed ±20% swings between runs in the Docker environment; on the
|
|
493
|
+
reference box the kino-vs-kino and kino-vs-proxy ratios reproduced
|
|
494
|
+
across runs, the nginx lanes remain the noisiest.
|
|
495
|
+
- **Header-value interning** (user-agent, accept-*, sec-ch-*: one
|
|
496
|
+
frozen string instead of a fresh allocation per request) was
|
|
497
|
+
measured with a realistic 11-header browser set on /plaintext,
|
|
498
|
+
using the within-boot header cost (bare vs with-headers) as the
|
|
499
|
+
drift-resistant metric: over h2 that cost fell from ~16% to ~12-13%
|
|
500
|
+
(~+3-5% throughput on the headers lane); h1's smaller header cost
|
|
501
|
+
stayed within noise. The effect the tiny app understates: 6-8 fewer
|
|
502
|
+
string allocations per request is GC pressure a real app feels more
|
|
503
|
+
than this one does.
|
|
504
|
+
|
|
411
505
|
## Hot-path notes
|
|
412
506
|
|
|
413
507
|
For the curious, the dispatch-path work behind the numbers: a try-pop
|
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,
|
|
@@ -30,6 +32,7 @@ module Kino
|
|
|
30
32
|
io_threads: nil,
|
|
31
33
|
tokio_threads: nil,
|
|
32
34
|
tls: nil,
|
|
35
|
+
http2: true,
|
|
33
36
|
environment: nil,
|
|
34
37
|
pidfile: nil,
|
|
35
38
|
control_bind: nil,
|
|
@@ -43,7 +46,7 @@ module Kino
|
|
|
43
46
|
SETTINGS = DEFAULTS.keys.freeze
|
|
44
47
|
|
|
45
48
|
# Source template for {.sample}.
|
|
46
|
-
SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__)
|
|
49
|
+
SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__).freeze
|
|
47
50
|
|
|
48
51
|
# Where the `kino` CLI and the Rack handler look for a config file when
|
|
49
52
|
# none is named: the project root first, then the Rails-style config/.
|
|
@@ -145,6 +148,8 @@ module Kino
|
|
|
145
148
|
# bind "0.0.0.0"
|
|
146
149
|
# port 9292
|
|
147
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
|
|
148
153
|
# threads 3 # threads per worker
|
|
149
154
|
# mode :ractor # :auto | :ractor | :threaded
|
|
150
155
|
# queue_depth 2048
|
|
@@ -171,9 +176,19 @@ module Kino
|
|
|
171
176
|
# Port to listen on; 0 picks an ephemeral port.
|
|
172
177
|
def port(port) = @config.set(:port, Integer(port))
|
|
173
178
|
|
|
174
|
-
# 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.
|
|
175
181
|
def workers(count) = @config.set(:workers, Integer(count))
|
|
176
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
|
+
|
|
177
192
|
# Threads per worker (I/O concurrency inside one ractor); default is
|
|
178
193
|
# mode-dependent: 1 in :ractor mode, 3 in :threaded.
|
|
179
194
|
def threads(count) = @config.set(:threads, Integer(count))
|
|
@@ -245,6 +260,11 @@ module Kino
|
|
|
245
260
|
# TLS termination; file paths or inline PEM strings.
|
|
246
261
|
def tls(cert:, key:) = @config.set(:tls, {cert: cert, key: key})
|
|
247
262
|
|
|
263
|
+
# HTTP/2: negotiated via ALPN on TLS binds, served to
|
|
264
|
+
# prior-knowledge clients on plaintext. On by default; set false
|
|
265
|
+
# to serve HTTP/1 only.
|
|
266
|
+
def http2(enabled = true) = @config.set(:http2, !!enabled)
|
|
267
|
+
|
|
248
268
|
# Sets RACK_ENV (unless already set) before the CLI loads the app.
|
|
249
269
|
def environment(env) = @config.set(:environment, env.to_s)
|
|
250
270
|
|
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
|
|