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.
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.5 (**YJIT enabled**,
21
- `RUBY_YJIT_ENABLE=1` for every server), Rust 1.96, Kino compiled in
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) | 151 | **148** |
253
- | Kino lanes 8×1 | 137 | **135** |
254
- | Kino :ractor 8×3 | 171 | **169** |
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) | 668 | **666**¹ |
257
- | Puma cluster 8×3 | 1,213 | **1,068** |
268
+ | Kino :threaded 8×3 (no arena cap) | 672 | **670**¹ |
269
+ | Puma cluster 8×3 | 1,216 | **1,072** |
258
270
 
259
- The tiny app is ~7× lighter than the cluster in ractor mode, ~10× in
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 | **92** |
284
- | Puma cluster 8×3 (preload) | 794 | **389** |
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 (794 MB),
288
- PSS counts it once (389 MB). The fair ratio is **~4×**, not the ~8× a
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. Preload barely helps (389 vs 400 MB without): Ruby's GC
291
- dirties most heap pages, breaking copy-on-write, so even a preloaded
292
- cluster keeps a large private heap per worker. That is why "CoW should
293
- make a fork cluster nearly free" is only half true—it shares the code,
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 | 219,168 |
388
- | threaded, `log_requests true` (native access log) | 193,998 (−11%) |
389
- | ractor, access log off / on | 197,596 / 181,050 (−8%) |
390
- | app logs 1 line/req via shared `::Logger` (file) | **62,961** |
391
- | app logs 1 line/req via `Kino::Logger` (file) | **149,519 (2.4×)** |
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
- (In the Docker environment the same comparison showed 8.5×—overlay-fs
398
- write latency punished the synchronous logger far harder than this box's
399
- NVMe does. The ranking is environment-independent; the multiple is not.)
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}, #{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,
@@ -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
@@ -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