kino 0.2.0-aarch64-linux → 0.3.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 +32 -0
- data/README.md +84 -3
- data/lib/kino/cli.rb +5 -2
- data/lib/kino/configuration.rb +57 -0
- data/lib/kino/hook_fire.rb +23 -0
- data/lib/kino/kino.so +0 -0
- data/lib/kino/quarantine_monitor.rb +70 -0
- data/lib/kino/ractor_supervisor.rb +59 -14
- data/lib/kino/server.rb +143 -21
- data/lib/kino/templates/kino.rb.tt +51 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +36 -19
- data/lib/kino/worker_hooks.rb +11 -0
- data/lib/kino.rb +3 -0
- data/sig/kino.rbs +9 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18bb12fa02179c4c2e164cf83e605ac893094650fcaee119132b48b2eea0ae2e
|
|
4
|
+
data.tar.gz: 56c6652d6671f2872e0399f82a789e6b4cc5b0ea018f5503bb91ed575f187985
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c3f92ba0bd339f2415ae94024f4afc5666a356c69b7721bb36deeccc492521d7f75a5ef064f994306a771924a42ccb00ff6e3ce138754b797ef5e2263144622
|
|
7
|
+
data.tar.gz: ae5f7535f0fc9ef4b9398a1fdd57723aae445d49ef7705fb1cab907d6b8ab01bb25338394509467e7cc755b37105b306ab08f89f755d4496e186e63f32cd6ace
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
## [0.3.0] - 2026-08-13
|
|
2
|
+
|
|
3
|
+
- Queue-time histogram: `/metrics` exposes `kino_request_queue_seconds`, a
|
|
4
|
+
histogram of how long each request waited for a free worker (the
|
|
5
|
+
saturation signal), and `server.stats`/`/stats` gain `queue_time`
|
|
6
|
+
(count and summed seconds). Measured internally with a monotonic clock,
|
|
7
|
+
so it needs no proxy header and is immune to clock skew.
|
|
8
|
+
- Lifecycle hooks: `after_boot`, `after_worker_boot`,
|
|
9
|
+
`after_request_complete`, and `on_worker_exit` join `on_error`, so apps
|
|
10
|
+
can wire their own metrics, readiness, and error tracking. The
|
|
11
|
+
worker-context hooks must be Ractor-shareable in `:ractor` mode; a raising
|
|
12
|
+
hook is logged and never kills a worker.
|
|
13
|
+
- Stuck-worker quarantine: past `quarantine_timeout`, a wedged dispatch
|
|
14
|
+
slot is quarantined and a replacement worker is spawned to restore
|
|
15
|
+
capacity (capped by `quarantine_max`), surfaced via `/stats`, `/metrics`,
|
|
16
|
+
and `server.stats`. The wedged worker is never force-killed.
|
|
17
|
+
- Control plane: a read-only monitoring listener (`control_bind`,
|
|
18
|
+
optional `control_token`) serving live stats as JSON at `/stats`,
|
|
19
|
+
Prometheus metrics at `/metrics`, and `/ready`/`/live` probes, answered
|
|
20
|
+
from the native layer on a dedicated thread so it stays responsive
|
|
21
|
+
while workers are busy, stuck, or draining.
|
|
22
|
+
- Per-worker stats: `/stats`, `/metrics`, and `server.stats` now break the
|
|
23
|
+
counters down per dispatch slot (served, in-flight, and `busy_ms`, the
|
|
24
|
+
age of the slot's current request), so a stuck slot is visible
|
|
25
|
+
individually.
|
|
26
|
+
- Update Rust and Ruby dependencies.
|
|
27
|
+
|
|
28
|
+
## [0.2.1] - 2026-07-27
|
|
29
|
+
|
|
30
|
+
- Update Rust dependencies for Kino.
|
|
31
|
+
- Update: puma 8 in benchmarks.
|
|
32
|
+
|
|
1
33
|
## [0.2.0] - 2026-07-13
|
|
2
34
|
|
|
3
35
|
- Strip debug info from release builds.
|
data/README.md
CHANGED
|
@@ -183,7 +183,7 @@ bundle add kino # or: gem install kino (outside a bundle)
|
|
|
183
183
|
or put it in the `Gemfile` yourself:
|
|
184
184
|
|
|
185
185
|
```ruby
|
|
186
|
-
gem "kino"
|
|
186
|
+
gem "kino"
|
|
187
187
|
```
|
|
188
188
|
|
|
189
189
|
Then generate a config and serve:
|
|
@@ -230,6 +230,8 @@ server = Kino::Server.new(app,
|
|
|
230
230
|
max_body_size: 50 * 1024 * 1024, # bytes before a 413; nil = let a proxy handle it
|
|
231
231
|
on_error: ->(e, env) { ErrorTracker.capture(e) }, # after the client got its 500
|
|
232
232
|
shutdown_timeout: 30, # drain deadline
|
|
233
|
+
control_bind: "127.0.0.1:9293", # monitoring: /stats /metrics /ready /live; port 0 reads back via server.control_port
|
|
234
|
+
control_token: ENV["KINO_CONTROL_TOKEN"], # optional Bearer auth for /stats + /metrics
|
|
233
235
|
tls: { cert: "cert.pem", key: "key.pem" }, # file paths or inline PEM
|
|
234
236
|
)
|
|
235
237
|
server.start
|
|
@@ -320,6 +322,44 @@ after the client got its 500—the only place a tracker sees errors
|
|
|
320
322
|
raised while the response was being written (in `:ractor` mode, build
|
|
321
323
|
the handler with `Ractor.shareable_proc`).
|
|
322
324
|
|
|
325
|
+
## Lifecycle hooks
|
|
326
|
+
|
|
327
|
+
Kino fires four lifecycle hooks alongside `on_error`, split by firing context.
|
|
328
|
+
|
|
329
|
+
**Worker-context hooks** run inside the worker and are available to all workers:
|
|
330
|
+
- `after_worker_boot { |worker_id| }`: runs once before the worker begins serving, with its slot id. In `:ractor` mode it runs inside the worker ractor and must be `Ractor.shareable_proc`.
|
|
331
|
+
- `after_request_complete { |env, status| }`: fires inside the worker after each successful response. This is the hot path—leave it unset for zero cost. In `:ractor` mode it must be `Ractor.shareable_proc`.
|
|
332
|
+
|
|
333
|
+
**Main-context hooks** run on the main thread, outside workers, and are plain procs:
|
|
334
|
+
- `after_boot { }`: fires once after the worker pool is up. Wire readiness here—sd_notify, a "server ready" metric, and so on.
|
|
335
|
+
- `on_worker_exit { |worker_index, error| }`: fires when a worker exits, with its index and the crash cause (or nil on a clean exit).
|
|
336
|
+
|
|
337
|
+
`after_worker_boot`'s argument is the worker's slot id, while in `:ractor` mode `on_worker_exit`'s argument identifies the exited ractor (`0`..`workers - 1`)—a different number space—so don't correlate boot and exit by that number in `:ractor` mode.
|
|
338
|
+
|
|
339
|
+
A raising hook is logged and never kills a worker.
|
|
340
|
+
|
|
341
|
+
## Stuck-worker quarantine
|
|
342
|
+
|
|
343
|
+
`quarantine_timeout: seconds` (or `quarantine_timeout 60` in `kino.rb`)
|
|
344
|
+
quarantines a dispatch slot whose request has run longer than the deadline
|
|
345
|
+
and spawns a replacement worker to restore capacity—distinct from
|
|
346
|
+
`request_timeout`, which gives the client a 504 but leaves the slot
|
|
347
|
+
occupied. `quarantine_max` (default: the worker count in `:ractor` mode,
|
|
348
|
+
workers × threads in `:threaded`) caps the total number of replacement
|
|
349
|
+
events over the process lifetime—past it the monitor stops replacing and
|
|
350
|
+
the server runs at reduced capacity.
|
|
351
|
+
|
|
352
|
+
The wedged worker is never interrupted or force-killed, and its slot stays
|
|
353
|
+
quarantined for good. In `:threaded` mode, if the blocked thread
|
|
354
|
+
eventually returns, it keeps serving requests on that same slot—but the
|
|
355
|
+
slot itself stays flagged quarantined (busy_ms reported as 0) for the rest
|
|
356
|
+
of the process; in `:ractor` mode the wedged ractor (and its supervisor
|
|
357
|
+
thread) leaks until the process exits, since a wedged ractor cannot be
|
|
358
|
+
safely interrupted. Monitor quarantine activity via `server.stats`
|
|
359
|
+
(top-level `quarantined` count and per-slot `worker_status[].quarantined`
|
|
360
|
+
flag), `GET /stats` (same), and `GET /metrics` (`kino_quarantined_workers`
|
|
361
|
+
gauge and `kino_quarantine_replacements_total` counter).
|
|
362
|
+
|
|
323
363
|
## Stats
|
|
324
364
|
|
|
325
365
|
`server.stats` returns a live snapshot: the configuration plus counters
|
|
@@ -330,7 +370,7 @@ cost):
|
|
|
330
370
|
server.stats
|
|
331
371
|
# => {mode: :ractor, lanes: false, workers: 8, threads: 1, batch: 1,
|
|
332
372
|
# respawns: 0, queued: 0, in_flight: 2, served: 1041, rejected: 0,
|
|
333
|
-
# timeouts: 0}
|
|
373
|
+
# timeouts: 0, worker_status: [...]}
|
|
334
374
|
# plus lane_depths: [...] when lane dispatch is on
|
|
335
375
|
```
|
|
336
376
|
|
|
@@ -341,6 +381,39 @@ From the outside, `kill -USR1 <pid>` prints the same snapshot as one line
|
|
|
341
381
|
Kino stats: mode=:ractor lanes=false workers=8 threads=1 batch=1 respawns=0 queued=0 in_flight=2 served=1041 rejected=0 timeouts=0
|
|
342
382
|
```
|
|
343
383
|
|
|
384
|
+
For pull-based monitoring, `control_bind "127.0.0.1:9293"` (or a
|
|
385
|
+
`unix://` path) serves a read-only **control plane** from the native
|
|
386
|
+
layer on its own thread—it keeps answering even while every Ruby worker
|
|
387
|
+
is busy or stuck, and reports `draining` through a graceful shutdown:
|
|
388
|
+
|
|
389
|
+
- `GET /stats`—the same snapshot as `server.stats`, as JSON (plus
|
|
390
|
+
`state` and `version`).
|
|
391
|
+
- `GET /metrics`—Prometheus text format (`kino_requests_served_total`,
|
|
392
|
+
`kino_queue_depth`, `kino_ready`, …).
|
|
393
|
+
|
|
394
|
+
Both `/stats` and `/metrics` also break the counters down per dispatch
|
|
395
|
+
slot: `/stats` carries a `worker_status` array (`index`, `served`,
|
|
396
|
+
`in_flight`, `busy_ms`) and `/metrics` emits `kino_worker_*{worker="N"}`
|
|
397
|
+
series, one entry per execution slot (`workers × threads`)—a crashed
|
|
398
|
+
worker's slot is never reused, so it stays in the list with its counters
|
|
399
|
+
frozen where they stopped, meaning the array (and its `worker="N"` metric
|
|
400
|
+
series) grows by one across every respawn. `busy_ms` is how long the
|
|
401
|
+
slot's current request has been running (0 when idle), so a single slot
|
|
402
|
+
climbing while the rest sit at 0 is your stuck worker.
|
|
403
|
+
|
|
404
|
+
The `/stats` response and `server.stats` carry `queue_time` (count and
|
|
405
|
+
summed seconds), and `/metrics` exposes `kino_request_queue_seconds`—a
|
|
406
|
+
Prometheus histogram of queue-wait time, the worker-saturation signal.
|
|
407
|
+
Counts admitted requests only; a 503 after queue wait goes to `rejected`,
|
|
408
|
+
not `queue_time`.
|
|
409
|
+
|
|
410
|
+
- `GET /ready`—`200` when serving, `503` while booting or draining:
|
|
411
|
+
wire it to your load balancer or Kubernetes readiness probe.
|
|
412
|
+
- `GET /live`—`200` whenever the process is alive: the liveness probe.
|
|
413
|
+
|
|
414
|
+
`control_token "..."` puts `/stats` and `/metrics` behind
|
|
415
|
+
`Authorization: Bearer`; the probes stay open.
|
|
416
|
+
|
|
344
417
|
## Logging
|
|
345
418
|
|
|
346
419
|
With one log line per request, `Kino::Logger` sustained **2.4× the
|
|
@@ -432,9 +505,17 @@ bundle exec rake # compile, Rust tests, specs, RBS, lint
|
|
|
432
505
|
RB_SYS_CARGO_PROFILE=dev bundle exec rake compile # fast dev rebuilds
|
|
433
506
|
```
|
|
434
507
|
|
|
508
|
+
## Acknowledgements
|
|
509
|
+
|
|
510
|
+
Thanks to [Mat Sadler](https://github.com/matsadler) for [magnus](https://github.com/matsadler/magnus).
|
|
511
|
+
|
|
512
|
+
For ractors, thanks to [Koichi Sasada](https://github.com/ko1), [John Hawthorn](https://github.com/jhawthorn), [Jean Boussier](https://github.com/byroot), [Luke Gruber](https://github.com/luke-gruber), and other Ruby core contributors.
|
|
513
|
+
|
|
514
|
+
For the Rust network stack, thanks to [Sean McArthur](https://github.com/seanmonstar) for [hyper](https://github.com/hyperium/hyper), and to [Carl Lerche](https://github.com/carllerche), [Alice Ryhl](https://github.com/Darksonn), and the other [Tokio](https://github.com/tokio-rs/tokio) maintainers for the runtime underneath it. Thanks to [Joshua Barretto](https://github.com/zesterer) for [flume](https://github.com/zesterer/flume)—its channels carry every request between the network side and the workers.
|
|
515
|
+
|
|
435
516
|
## Assisted by
|
|
436
517
|
|
|
437
|
-
Claude Code (
|
|
518
|
+
Claude Code (Fable 5, Opus 4.8).
|
|
438
519
|
|
|
439
520
|
## Contributing
|
|
440
521
|
|
data/lib/kino/cli.rb
CHANGED
|
@@ -100,11 +100,14 @@ module Kino
|
|
|
100
100
|
end.join
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
# One-line stats dump (the SIGUSR1 handler's output).
|
|
103
|
+
# One-line stats dump (the SIGUSR1 handler's output). Excludes
|
|
104
|
+
# worker_status: it's an array with one entry per execution slot, and
|
|
105
|
+
# printing it inline would break the one-line contract (see /stats for
|
|
106
|
+
# per-worker detail).
|
|
104
107
|
# @param stats [Hash{Symbol => Object}] see {Kino::Server#stats}
|
|
105
108
|
# @return [String]
|
|
106
109
|
def stats_line(stats)
|
|
107
|
-
dim("Kino stats: #{stats.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}")
|
|
110
|
+
dim("Kino stats: #{stats.except(:worker_status).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}")
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
# The two banner halves around Server#start: credits before, the ready
|
data/lib/kino/configuration.rb
CHANGED
|
@@ -23,11 +23,19 @@ module Kino
|
|
|
23
23
|
lanes: false,
|
|
24
24
|
log_requests: false,
|
|
25
25
|
on_error: nil,
|
|
26
|
+
after_boot: nil,
|
|
27
|
+
after_worker_boot: nil,
|
|
28
|
+
after_request_complete: nil,
|
|
29
|
+
on_worker_exit: nil,
|
|
26
30
|
shutdown_timeout: 30,
|
|
27
31
|
tokio_threads: nil,
|
|
28
32
|
tls: nil,
|
|
29
33
|
environment: nil,
|
|
30
34
|
pidfile: nil,
|
|
35
|
+
control_bind: nil,
|
|
36
|
+
control_token: nil,
|
|
37
|
+
quarantine_timeout: nil,
|
|
38
|
+
quarantine_max: nil,
|
|
31
39
|
rackup: nil
|
|
32
40
|
}.freeze
|
|
33
41
|
|
|
@@ -185,6 +193,24 @@ module Kino
|
|
|
185
193
|
# or a block. Must be Ractor-shareable in :ractor mode.
|
|
186
194
|
def on_error(handler = nil, &block) = @config.set(:on_error, handler || block)
|
|
187
195
|
|
|
196
|
+
# Called once on the main thread after the worker pool is up. The
|
|
197
|
+
# readiness seam (wire sd_notify or a "server ready" metric here).
|
|
198
|
+
def after_boot(handler = nil, &block) = @config.set(:after_boot, handler || block)
|
|
199
|
+
|
|
200
|
+
# Called once inside each worker (a ractor in :ractor mode) before it
|
|
201
|
+
# serves, with the worker's slot id. Must be Ractor-shareable in
|
|
202
|
+
# :ractor mode (build it with Ractor.shareable_proc).
|
|
203
|
+
def after_worker_boot(handler = nil, &block) = @config.set(:after_worker_boot, handler || block)
|
|
204
|
+
|
|
205
|
+
# Called inside the worker after each successful response with
|
|
206
|
+
# (env, status). Hot path: leave unset for zero cost. Must be
|
|
207
|
+
# Ractor-shareable in :ractor mode.
|
|
208
|
+
def after_request_complete(handler = nil, &block) = @config.set(:after_request_complete, handler || block)
|
|
209
|
+
|
|
210
|
+
# Called on the main thread when a worker exits, with (worker_index,
|
|
211
|
+
# error_or_nil). error is the crash cause, or nil on a clean exit.
|
|
212
|
+
def on_worker_exit(handler = nil, &block) = @config.set(:on_worker_exit, handler || block)
|
|
213
|
+
|
|
188
214
|
# Graceful-shutdown drain deadline in seconds.
|
|
189
215
|
def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
|
|
190
216
|
|
|
@@ -200,6 +226,37 @@ module Kino
|
|
|
200
226
|
# Write the master PID here on start.
|
|
201
227
|
def pidfile(path) = @config.set(:pidfile, path.to_s)
|
|
202
228
|
|
|
229
|
+
# Serve the read-only control plane (live stats as JSON at /stats,
|
|
230
|
+
# Prometheus text at /metrics, /ready and /live probes) on this
|
|
231
|
+
# address: "host:port" or "unix://path". Off unless set.
|
|
232
|
+
def control_bind(addr) = @config.set(:control_bind, addr.to_s)
|
|
233
|
+
|
|
234
|
+
# When set, /stats and /metrics require "Authorization: Bearer <token>".
|
|
235
|
+
# The probes stay open; they carry no data.
|
|
236
|
+
def control_token(token) = @config.set(:control_token, token.to_s)
|
|
237
|
+
|
|
238
|
+
# Quarantine a dispatch slot whose current request has run longer
|
|
239
|
+
# than this many seconds, spawning a replacement to restore capacity.
|
|
240
|
+
# Off unless set. Set it above your slowest legitimate endpoint (and
|
|
241
|
+
# typically above request_timeout).
|
|
242
|
+
def quarantine_timeout(seconds)
|
|
243
|
+
seconds &&= Float(seconds)
|
|
244
|
+
if seconds && seconds <= 0
|
|
245
|
+
raise ArgumentError, "quarantine_timeout must be greater than 0 (got #{seconds})"
|
|
246
|
+
end
|
|
247
|
+
@config.set(:quarantine_timeout, seconds)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Cap on the total number of replacement events over the process
|
|
251
|
+
# lifetime. Past the cap the monitor stops replacing and the server
|
|
252
|
+
# runs at reduced capacity. Default: the worker count in :ractor
|
|
253
|
+
# mode, workers x threads in :threaded.
|
|
254
|
+
def quarantine_max(count)
|
|
255
|
+
count = Integer(count)
|
|
256
|
+
raise ArgumentError, "quarantine_max must be >= 1 (got #{count})" if count < 1
|
|
257
|
+
@config.set(:quarantine_max, count)
|
|
258
|
+
end
|
|
259
|
+
|
|
203
260
|
# Rackup file the `kino` CLI loads (positional argument wins).
|
|
204
261
|
def rackup(path) = @config.set(:rackup, path.to_s)
|
|
205
262
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# @private
|
|
5
|
+
# Fires a lifecycle hook and turns a raise into a logged line instead of
|
|
6
|
+
# letting it escape. Stateless and touches only its arguments plus
|
|
7
|
+
# Native.log_error (already called from inside worker ractors today), so
|
|
8
|
+
# it is safe to call from worker context: no main-ractor state is
|
|
9
|
+
# captured.
|
|
10
|
+
module HookFire
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def fire(hook, name, *args)
|
|
14
|
+
return unless hook
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
hook.call(*args)
|
|
18
|
+
rescue => e
|
|
19
|
+
Native.log_error("#{name} hook raised #{e.class}: #{e.message}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/kino/kino.so
CHANGED
|
Binary file
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# @private
|
|
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
|
|
10
|
+
def initialize(server_id:, timeout_ms:, max:, replacer:, tick: 0.5)
|
|
11
|
+
@server_id = server_id
|
|
12
|
+
@timeout_ms = timeout_ms
|
|
13
|
+
@max = max
|
|
14
|
+
@replacer = replacer
|
|
15
|
+
@tick = tick
|
|
16
|
+
@outstanding = 0
|
|
17
|
+
@at_cap_logged = false
|
|
18
|
+
@running = false
|
|
19
|
+
@thread = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def start
|
|
23
|
+
@running = true
|
|
24
|
+
@thread = Thread.new { run }
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stop
|
|
29
|
+
@running = false
|
|
30
|
+
@thread&.join(@tick * 2)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
tick while @running
|
|
37
|
+
rescue => e
|
|
38
|
+
Native.log_error("quarantine monitor crashed: #{e.class}: #{e.message}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tick
|
|
42
|
+
scan_slots
|
|
43
|
+
rescue => e
|
|
44
|
+
# A bad tick must never kill the monitor.
|
|
45
|
+
Native.log_error("quarantine tick error: #{e.class}: #{e.message}")
|
|
46
|
+
ensure
|
|
47
|
+
sleep @tick
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def scan_slots
|
|
51
|
+
Native.worker_stats(@server_id).each do |index, _served, _in_flight, busy_ms, quarantined|
|
|
52
|
+
next if quarantined || busy_ms <= @timeout_ms
|
|
53
|
+
|
|
54
|
+
if @outstanding >= @max
|
|
55
|
+
unless @at_cap_logged
|
|
56
|
+
Native.log_error("quarantine at cap (#{@max}); serving at reduced capacity")
|
|
57
|
+
@at_cap_logged = true
|
|
58
|
+
end
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if @replacer.replace(index)
|
|
63
|
+
Native.record_quarantine_replacement(@server_id)
|
|
64
|
+
@outstanding += 1
|
|
65
|
+
@at_cap_logged = false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -7,19 +7,23 @@ module Kino
|
|
|
7
7
|
# ractor, Exception from app code included) wakes it to 500 the in-flight
|
|
8
8
|
# requests and respawn. Clean exits (queue drained) end supervision.
|
|
9
9
|
class RactorSupervisor
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def initialize(server_id, app, workers:, threads:, batch: 1, on_error: nil)
|
|
10
|
+
def initialize(server_id, app, workers:, threads:, batch: 1, hooks: nil, on_worker_exit: nil)
|
|
13
11
|
@server_id = server_id
|
|
14
12
|
@app = app
|
|
15
13
|
@workers = workers
|
|
16
14
|
@threads = threads
|
|
17
15
|
@batch = batch
|
|
18
|
-
@
|
|
19
|
-
@
|
|
16
|
+
@hooks = hooks
|
|
17
|
+
@on_worker_exit = on_worker_exit
|
|
20
18
|
@draining = false
|
|
21
19
|
@lock = Mutex.new
|
|
22
20
|
@supervisor_threads = []
|
|
21
|
+
@worker_slots = {}
|
|
22
|
+
@slot_to_worker = {}
|
|
23
|
+
@replaced = {}
|
|
24
|
+
# The first replacement's index; `replace` increments before using it,
|
|
25
|
+
# so this starts one below the first free index (@workers).
|
|
26
|
+
@next_worker_index = @workers - 1
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
def start
|
|
@@ -32,20 +36,55 @@ module Kino
|
|
|
32
36
|
def shutdown(timeout)
|
|
33
37
|
@lock.synchronize { @draining = true }
|
|
34
38
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
35
|
-
@supervisor_threads.each do |thread|
|
|
39
|
+
@lock.synchronize { @supervisor_threads.dup }.each do |thread|
|
|
36
40
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
37
41
|
thread.join([remaining, 0.01].max)
|
|
38
42
|
end
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
def done?
|
|
42
|
-
@supervisor_threads.none?(&:alive?)
|
|
46
|
+
@lock.synchronize { @supervisor_threads.dup }.none?(&:alive?)
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
# Block until the workers exit on their own (drain elsewhere): join
|
|
46
50
|
# without flipping the draining flag.
|
|
47
51
|
def join
|
|
48
|
-
@supervisor_threads.each(&:join)
|
|
52
|
+
@lock.synchronize { @supervisor_threads.dup }.each(&:join)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Replace the ractor owning slot `worker_id`: spawn a fresh supervised
|
|
56
|
+
# ractor, then quarantine the old ractor's slots. The old supervisor
|
|
57
|
+
# thread stays blocked in ractor.value on the wedged ractor (it and the
|
|
58
|
+
# ractor leak until process exit; a wedged ractor cannot be
|
|
59
|
+
# force-killed). Returns true if a replacement was spawned.
|
|
60
|
+
def replace(worker_id)
|
|
61
|
+
worker_index = @lock.synchronize { @slot_to_worker[worker_id] }
|
|
62
|
+
return false unless worker_index
|
|
63
|
+
|
|
64
|
+
# Idempotent per ractor: a stale monitor snapshot can list two sibling
|
|
65
|
+
# slots of the same ractor, only the first replaces it.
|
|
66
|
+
claimed = @lock.synchronize do
|
|
67
|
+
if @replaced.key?(worker_index)
|
|
68
|
+
false
|
|
69
|
+
else
|
|
70
|
+
@replaced[worker_index] = true
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
return false unless claimed
|
|
75
|
+
|
|
76
|
+
new_index = @lock.synchronize { @next_worker_index += 1 }
|
|
77
|
+
thread =
|
|
78
|
+
begin
|
|
79
|
+
supervise(new_index) # spawn FIRST, nothing quarantined yet
|
|
80
|
+
rescue
|
|
81
|
+
@lock.synchronize { @replaced.delete(worker_index) } # allow retry next tick
|
|
82
|
+
raise
|
|
83
|
+
end
|
|
84
|
+
slot_ids = @lock.synchronize { @worker_slots[worker_index] } || []
|
|
85
|
+
slot_ids.each { |id| Native.quarantine_slot(@server_id, id) } # quarantine only after success
|
|
86
|
+
@lock.synchronize { @supervisor_threads << thread }
|
|
87
|
+
true
|
|
49
88
|
end
|
|
50
89
|
|
|
51
90
|
private
|
|
@@ -54,20 +93,22 @@ module Kino
|
|
|
54
93
|
Thread.new do
|
|
55
94
|
crashes = 0
|
|
56
95
|
loop do
|
|
57
|
-
ractor, worker_ids = spawn_worker
|
|
96
|
+
ractor, worker_ids = spawn_worker(index)
|
|
58
97
|
begin
|
|
59
98
|
ractor.value # blocks until the ractor terminates
|
|
99
|
+
HookFire.fire(@on_worker_exit, "on_worker_exit", index, nil) # clean exit: queue drained
|
|
60
100
|
break # clean exit: queue closed, workers drained
|
|
61
101
|
rescue Ractor::Error => e
|
|
62
102
|
# The ractor died mid-flight. Anything it was serving will never
|
|
63
103
|
# be answered by Ruby: 500 those clients NOW (not when GC gets
|
|
64
104
|
# around to dropping the dead heap), then decide on respawn.
|
|
65
105
|
worker_ids.each { |id| Native.abort_inflight(@server_id, id) }
|
|
106
|
+
cause = (e.respond_to?(:cause) && e.cause) ? e.cause : e
|
|
107
|
+
HookFire.fire(@on_worker_exit, "on_worker_exit", index, cause)
|
|
66
108
|
break if draining?
|
|
67
109
|
|
|
68
110
|
crashes += 1
|
|
69
|
-
|
|
70
|
-
cause = (e.respond_to?(:cause) && e.cause) ? e.cause : e
|
|
111
|
+
Native.record_respawn(@server_id)
|
|
71
112
|
Native.log_error("worker ractor #{index} crashed (#{cause.class}: #{cause.message}); respawning")
|
|
72
113
|
# Policy (crash recovery): unlimited respawn
|
|
73
114
|
# keeps the server up under rare crashes but turns a
|
|
@@ -82,15 +123,19 @@ module Kino
|
|
|
82
123
|
# Fresh ractor + fresh native slots. Slots are never reused across
|
|
83
124
|
# respawns: stale interrupt kicks and dead weak refs go down with the
|
|
84
125
|
# old slot.
|
|
85
|
-
def spawn_worker
|
|
126
|
+
def spawn_worker(worker_index)
|
|
86
127
|
worker_ids = Array.new(@threads) { Native.register_worker(@server_id) }
|
|
87
|
-
|
|
128
|
+
@lock.synchronize do
|
|
129
|
+
@worker_slots[worker_index] = worker_ids
|
|
130
|
+
worker_ids.each { |id| @slot_to_worker[id] = worker_index }
|
|
131
|
+
end
|
|
132
|
+
ractor = Ractor.new(@server_id, worker_ids, @app, @batch, @hooks) do |server_id, ids, app, batch, hooks|
|
|
88
133
|
ids.map do |id|
|
|
89
134
|
Thread.new do
|
|
90
135
|
# Crashes surface via Ractor#value in the supervisor; don't also
|
|
91
136
|
# spray the backtrace to stderr from inside the dying ractor.
|
|
92
137
|
Thread.current.report_on_exception = false
|
|
93
|
-
Kino::Worker.run(server_id, id, app, batch,
|
|
138
|
+
Kino::Worker.run(server_id, id, app, batch, hooks)
|
|
94
139
|
end
|
|
95
140
|
end.each(&:join)
|
|
96
141
|
end
|
data/lib/kino/server.rb
CHANGED
|
@@ -12,6 +12,10 @@ module Kino
|
|
|
12
12
|
# port when configured with port 0)
|
|
13
13
|
attr_reader :port
|
|
14
14
|
|
|
15
|
+
# @return [Integer, nil] the control plane's TCP port (nil until #start,
|
|
16
|
+
# when the control plane is off, or for a unix-socket bind)
|
|
17
|
+
attr_reader :control_port
|
|
18
|
+
|
|
15
19
|
# @return [Symbol] the resolved dispatch mode, :ractor or :threaded
|
|
16
20
|
attr_reader :mode
|
|
17
21
|
|
|
@@ -41,8 +45,17 @@ module Kino
|
|
|
41
45
|
@bind = settings[:bind]
|
|
42
46
|
@requested_port = settings[:port]
|
|
43
47
|
@workers = Integer(settings[:workers])
|
|
44
|
-
@on_error =
|
|
48
|
+
@on_error = validate_hook(settings[:on_error], :on_error)
|
|
49
|
+
@after_worker_boot = validate_hook(settings[:after_worker_boot], :after_worker_boot)
|
|
50
|
+
@after_request_complete = validate_hook(settings[:after_request_complete], :after_request_complete)
|
|
51
|
+
@after_boot = validate_hook(settings[:after_boot], :after_boot)
|
|
52
|
+
@on_worker_exit = validate_hook(settings[:on_worker_exit], :on_worker_exit)
|
|
45
53
|
@mode = resolve_mode(settings[:mode])
|
|
54
|
+
@worker_hooks = WorkerHooks.new(
|
|
55
|
+
on_error: @on_error,
|
|
56
|
+
after_worker_boot: @after_worker_boot,
|
|
57
|
+
after_request_complete: @after_request_complete
|
|
58
|
+
)
|
|
46
59
|
# Default threads per mode: 1 in :ractor (threads inside a ractor
|
|
47
60
|
# share its lock; a measured +17% on fast handlers; raise `workers`
|
|
48
61
|
# for I/O concurrency instead), 3 in :threaded (threads ARE the
|
|
@@ -60,8 +73,25 @@ module Kino
|
|
|
60
73
|
@tokio_threads = settings[:tokio_threads]
|
|
61
74
|
@tls = validate_tls(settings[:tls])
|
|
62
75
|
@pidfile = settings[:pidfile]
|
|
76
|
+
@control_bind = settings[:control_bind]&.to_s
|
|
77
|
+
@control_token = settings[:control_token]&.to_s
|
|
78
|
+
# An empty token (e.g. control_token ENV["KINO_CONTROL_TOKEN"] with the
|
|
79
|
+
# var unset) must not half-disable auth: treat it as auth off, not as
|
|
80
|
+
# "require a zero-length Bearer token".
|
|
81
|
+
@control_token = nil if @control_token && @control_token.empty?
|
|
82
|
+
@quarantine_timeout_ms = settings[:quarantine_timeout] ? (Float(settings[:quarantine_timeout]) * 1000).round : nil
|
|
83
|
+
@quarantine_max =
|
|
84
|
+
if settings[:quarantine_max]
|
|
85
|
+
Integer(settings[:quarantine_max])
|
|
86
|
+
elsif @mode == :ractor
|
|
87
|
+
@workers
|
|
88
|
+
else
|
|
89
|
+
@workers * @threads
|
|
90
|
+
end
|
|
63
91
|
@worker_threads = []
|
|
92
|
+
@worker_threads_lock = Mutex.new
|
|
64
93
|
@supervisor = nil
|
|
94
|
+
@quarantine_monitor = nil
|
|
65
95
|
@started = false
|
|
66
96
|
end
|
|
67
97
|
|
|
@@ -78,7 +108,7 @@ module Kino
|
|
|
78
108
|
write_pidfile if @pidfile
|
|
79
109
|
booted = false
|
|
80
110
|
begin
|
|
81
|
-
@id, @port = Native.server_start(
|
|
111
|
+
@id, @port, @control_port = Native.server_start(
|
|
82
112
|
bind: @bind, port: @requested_port,
|
|
83
113
|
queue_depth: @queue_depth, queue_timeout_ms: @queue_timeout_ms,
|
|
84
114
|
request_timeout_ms: @request_timeout_ms,
|
|
@@ -86,7 +116,9 @@ module Kino
|
|
|
86
116
|
max_body_size: @max_body_size,
|
|
87
117
|
tokio_threads: @tokio_threads,
|
|
88
118
|
tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
|
|
89
|
-
lanes: @lanes, log_requests: @log_requests
|
|
119
|
+
lanes: @lanes, log_requests: @log_requests,
|
|
120
|
+
mode: @mode.to_s, workers: @workers, threads: @threads, batch: @batch,
|
|
121
|
+
control_bind: @control_bind, control_token: @control_token
|
|
90
122
|
)
|
|
91
123
|
booted = true
|
|
92
124
|
ensure
|
|
@@ -97,13 +129,13 @@ module Kino
|
|
|
97
129
|
@pin_keeper = Native.pin_keeper(@id)
|
|
98
130
|
if @mode == :ractor
|
|
99
131
|
@supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads,
|
|
100
|
-
batch: @batch,
|
|
132
|
+
batch: @batch, hooks: @worker_hooks, on_worker_exit: @on_worker_exit).start
|
|
101
133
|
else
|
|
102
|
-
@worker_threads = (@workers * @threads).times.map
|
|
103
|
-
worker_id = Native.register_worker(@id)
|
|
104
|
-
Thread.new { Worker.run(@id, worker_id, @app, @batch, @on_error) }
|
|
105
|
-
end
|
|
134
|
+
@worker_threads = (@workers * @threads).times.map { spawn_worker_thread }
|
|
106
135
|
end
|
|
136
|
+
start_quarantine_monitor if @quarantine_timeout_ms
|
|
137
|
+
Native.control_ready(@id)
|
|
138
|
+
HookFire.fire(@after_boot, "after_boot")
|
|
107
139
|
@started = true
|
|
108
140
|
self
|
|
109
141
|
end
|
|
@@ -119,6 +151,7 @@ module Kino
|
|
|
119
151
|
def shutdown(timeout: nil)
|
|
120
152
|
return unless @started
|
|
121
153
|
|
|
154
|
+
@quarantine_monitor&.stop
|
|
122
155
|
deadline = monotonic_now + (timeout || @shutdown_timeout)
|
|
123
156
|
Native.stop_accepting(@id)
|
|
124
157
|
|
|
@@ -144,6 +177,9 @@ module Kino
|
|
|
144
177
|
end
|
|
145
178
|
|
|
146
179
|
Native.shutdown_runtime(@id, 1_000)
|
|
180
|
+
# The control thread reports "draining" for the whole drain and stops
|
|
181
|
+
# only now, once there is nothing left to report.
|
|
182
|
+
Native.control_stop(@id)
|
|
147
183
|
# The runtime is gone, so hyper has dropped every pinned buffer;
|
|
148
184
|
# the keeper (and the strings it marked) may now be collected.
|
|
149
185
|
@pin_keeper = nil
|
|
@@ -205,22 +241,88 @@ module Kino
|
|
|
205
241
|
#
|
|
206
242
|
# @return [Hash{Symbol => Object}] mode, lanes, workers, threads,
|
|
207
243
|
# batch, respawns; plus queued, in_flight, served, rejected,
|
|
208
|
-
# timeouts (and lane_depths in
|
|
244
|
+
# timeouts, worker_status, quarantined, queue_time (and lane_depths in
|
|
245
|
+
# lanes mode) once started
|
|
209
246
|
def stats
|
|
210
247
|
base = {
|
|
211
248
|
mode: @mode, lanes: @lanes, workers: @workers, threads: @threads,
|
|
212
|
-
batch: @batch, respawns:
|
|
249
|
+
batch: @batch, respawns: 0
|
|
213
250
|
}
|
|
214
251
|
return base unless @started
|
|
215
252
|
|
|
216
|
-
queued, in_flight, served, rejected, timeouts, lane_depths = Native.server_stats(@id)
|
|
217
|
-
base.merge!(queued:, in_flight:, served:, rejected:, timeouts:)
|
|
253
|
+
queued, in_flight, served, rejected, timeouts, respawns, lane_depths = Native.server_stats(@id)
|
|
254
|
+
base.merge!(queued:, in_flight:, served:, rejected:, timeouts:, respawns:)
|
|
218
255
|
base[:lane_depths] = lane_depths if lane_depths
|
|
256
|
+
rows = Native.worker_stats(@id)
|
|
257
|
+
base[:worker_status] = rows.map do |index, served, in_flight, busy_ms, quarantined|
|
|
258
|
+
{index:, served:, in_flight:, busy_ms:, quarantined:}
|
|
259
|
+
end
|
|
260
|
+
base[:quarantined] = rows.count { |_index, _served, _in_flight, _busy_ms, quarantined| quarantined }
|
|
261
|
+
count, sum_seconds = Native.queue_time(@id)
|
|
262
|
+
base[:queue_time] = {count:, sum_seconds:}
|
|
219
263
|
base
|
|
220
264
|
end
|
|
221
265
|
|
|
222
266
|
private
|
|
223
267
|
|
|
268
|
+
# Register a fresh dispatch slot and run a worker thread on it; returns
|
|
269
|
+
# the thread. Used at boot and by the quarantine replacer.
|
|
270
|
+
def spawn_worker_thread
|
|
271
|
+
worker_id = Native.register_worker(@id)
|
|
272
|
+
Thread.new do
|
|
273
|
+
error = nil
|
|
274
|
+
begin
|
|
275
|
+
Worker.run(@id, worker_id, @app, @batch, @worker_hooks)
|
|
276
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- a hard crash in a threaded worker thread
|
|
277
|
+
error = e
|
|
278
|
+
raise
|
|
279
|
+
ensure
|
|
280
|
+
HookFire.fire(@on_worker_exit, "on_worker_exit", worker_id, error)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Track a replacement thread spawned outside the initial pool assignment
|
|
286
|
+
# (the quarantine replacer) so shutdown's join/done?/kill sweeps see it.
|
|
287
|
+
def track_replacement_thread(thread)
|
|
288
|
+
@worker_threads_lock.synchronize { @worker_threads << thread }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# @private
|
|
292
|
+
# The :threaded-mode quarantine replacer: spawns a replacement worker
|
|
293
|
+
# thread, quarantines the wedged slot, then tracks the new thread so
|
|
294
|
+
# shutdown's join/done?/kill sweeps see it. Built from bound Method
|
|
295
|
+
# objects instead of a server reference, so it drives the server
|
|
296
|
+
# through those methods without send or instance_variable_get.
|
|
297
|
+
class ThreadedReplacer
|
|
298
|
+
def initialize(server_id:, spawner:, tracker:)
|
|
299
|
+
@server_id = server_id
|
|
300
|
+
@spawner = spawner
|
|
301
|
+
@tracker = tracker
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def replace(worker_id)
|
|
305
|
+
thread = @spawner.call # spawn FIRST (may raise ThreadError)
|
|
306
|
+
Native.quarantine_slot(@server_id, worker_id) # quarantine after success
|
|
307
|
+
@tracker.call(thread)
|
|
308
|
+
true
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
private_constant :ThreadedReplacer
|
|
312
|
+
|
|
313
|
+
# A replacer.replace(worker_id) spawns a replacement worker, then
|
|
314
|
+
# quarantines the wedged slot, mode-appropriately. In :ractor the
|
|
315
|
+
# supervisor is the replacer; in :threaded a small object over
|
|
316
|
+
# spawn_worker_thread.
|
|
317
|
+
def start_quarantine_monitor
|
|
318
|
+
replacer = @supervisor || ThreadedReplacer.new(server_id: @id, spawner: method(:spawn_worker_thread),
|
|
319
|
+
tracker: method(:track_replacement_thread))
|
|
320
|
+
@quarantine_monitor = QuarantineMonitor.new(
|
|
321
|
+
server_id: @id, timeout_ms: @quarantine_timeout_ms,
|
|
322
|
+
max: @quarantine_max, replacer: replacer
|
|
323
|
+
).start
|
|
324
|
+
end
|
|
325
|
+
|
|
224
326
|
def validate_tls(tls)
|
|
225
327
|
return nil if tls.nil?
|
|
226
328
|
unless tls.is_a?(Hash) && tls[:cert] && tls[:key]
|
|
@@ -230,10 +332,10 @@ module Kino
|
|
|
230
332
|
{cert: String(tls[:cert]), key: String(tls[:key])}
|
|
231
333
|
end
|
|
232
334
|
|
|
233
|
-
def
|
|
335
|
+
def validate_hook(handler, name)
|
|
234
336
|
return nil if handler.nil?
|
|
235
337
|
unless handler.respond_to?(:call)
|
|
236
|
-
raise ArgumentError, "
|
|
338
|
+
raise ArgumentError, "#{name} must respond to #call (got #{handler.class})"
|
|
237
339
|
end
|
|
238
340
|
|
|
239
341
|
handler
|
|
@@ -320,7 +422,8 @@ module Kino
|
|
|
320
422
|
if @supervisor
|
|
321
423
|
@supervisor.shutdown([deadline - monotonic_now, 0].max)
|
|
322
424
|
else
|
|
323
|
-
@worker_threads.
|
|
425
|
+
threads = @worker_threads_lock.synchronize { @worker_threads.dup }
|
|
426
|
+
threads.each do |thread|
|
|
324
427
|
thread.join([deadline - monotonic_now, 0.01].max)
|
|
325
428
|
end
|
|
326
429
|
end
|
|
@@ -330,7 +433,8 @@ module Kino
|
|
|
330
433
|
if @supervisor
|
|
331
434
|
@supervisor.done?
|
|
332
435
|
else
|
|
333
|
-
@worker_threads.
|
|
436
|
+
threads = @worker_threads_lock.synchronize { @worker_threads.dup }
|
|
437
|
+
threads.none?(&:alive?)
|
|
334
438
|
end
|
|
335
439
|
end
|
|
336
440
|
|
|
@@ -340,7 +444,8 @@ module Kino
|
|
|
340
444
|
# by abort_all_inflight. The stuck ractor leaks until process exit.
|
|
341
445
|
Native.log_error("shutdown deadline passed with stuck ractor workers") unless @supervisor.done?
|
|
342
446
|
else
|
|
343
|
-
@
|
|
447
|
+
threads = @worker_threads_lock.synchronize { @worker_threads.dup }
|
|
448
|
+
threads.each { |thread| thread.kill if thread.alive? }
|
|
344
449
|
end
|
|
345
450
|
end
|
|
346
451
|
|
|
@@ -361,9 +466,9 @@ module Kino
|
|
|
361
466
|
"Ractor.shareable_proc endpoints); try Ractor.make_shareable(app) " \
|
|
362
467
|
"or mode: :threaded"
|
|
363
468
|
end
|
|
364
|
-
|
|
469
|
+
if (name = unshareable_worker_hook_name)
|
|
365
470
|
raise Error,
|
|
366
|
-
"mode: :ractor requires a Ractor-shareable
|
|
471
|
+
"mode: :ractor requires a Ractor-shareable #{name} hook " \
|
|
367
472
|
"(build it with Ractor.shareable_proc, or use mode: :threaded)"
|
|
368
473
|
end
|
|
369
474
|
:ractor
|
|
@@ -371,8 +476,8 @@ module Kino
|
|
|
371
476
|
if !Ractor.shareable?(@app)
|
|
372
477
|
warn "Kino: app is not Ractor-shareable; falling back to mode: :threaded"
|
|
373
478
|
:threaded
|
|
374
|
-
elsif
|
|
375
|
-
warn "Kino:
|
|
479
|
+
elsif (name = unshareable_worker_hook_name)
|
|
480
|
+
warn "Kino: #{name} hook is not Ractor-shareable; falling back to mode: :threaded"
|
|
376
481
|
:threaded
|
|
377
482
|
else
|
|
378
483
|
:ractor
|
|
@@ -381,5 +486,22 @@ module Kino
|
|
|
381
486
|
raise ArgumentError, "mode must be :auto, :ractor, or :threaded (got #{requested.inspect})"
|
|
382
487
|
end
|
|
383
488
|
end
|
|
489
|
+
|
|
490
|
+
# The hooks that ride into worker context (a ractor in :ractor mode)
|
|
491
|
+
# and so must be Ractor-shareable there. after_boot and on_worker_exit
|
|
492
|
+
# run on the main thread and are exempt.
|
|
493
|
+
def worker_context_hooks
|
|
494
|
+
[[:on_error, @on_error], [:after_worker_boot, @after_worker_boot],
|
|
495
|
+
[:after_request_complete, @after_request_complete]]
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# The name of the first worker-context hook that is set but not
|
|
499
|
+
# Ractor-shareable, or nil if all set ones are. Used by both the
|
|
500
|
+
# :ractor raise and the :auto warn/fallback branches, so "first
|
|
501
|
+
# offender" is defined once.
|
|
502
|
+
def unshareable_worker_hook_name
|
|
503
|
+
bad = worker_context_hooks.find { |_name, hook| !(hook.nil? || Ractor.shareable?(hook)) }
|
|
504
|
+
bad&.first
|
|
505
|
+
end
|
|
384
506
|
end
|
|
385
507
|
end
|
|
@@ -87,6 +87,27 @@
|
|
|
87
87
|
# must be Ractor-shareable (build it with Ractor.shareable_proc).
|
|
88
88
|
# on_error ->(error, env) { ExceptionService.capture(error) }
|
|
89
89
|
|
|
90
|
+
# Called once on the main thread after the worker pool is up. Wire
|
|
91
|
+
# readiness here (sd_notify, a "server ready" metric).
|
|
92
|
+
# after_boot { }
|
|
93
|
+
|
|
94
|
+
# Called once inside each worker before it serves, with the worker's slot
|
|
95
|
+
# id. In :ractor mode it runs inside the worker ractor, so it must be
|
|
96
|
+
# Ractor-shareable (build it with Ractor.shareable_proc).
|
|
97
|
+
# after_worker_boot { |worker_id| }
|
|
98
|
+
|
|
99
|
+
# Called inside the worker after each successful response, with (env,
|
|
100
|
+
# status). This is the hot path: leave it unset for zero cost. In :ractor
|
|
101
|
+
# mode it must be Ractor-shareable.
|
|
102
|
+
# after_request_complete { |env, status| }
|
|
103
|
+
|
|
104
|
+
# Called on the main thread when a worker exits, with (worker_index,
|
|
105
|
+
# error). error is the crash cause, or nil on a clean exit. In :ractor
|
|
106
|
+
# mode worker_index identifies the exited ractor (0..workers - 1), a
|
|
107
|
+
# different number space than after_worker_boot's slot id: do not
|
|
108
|
+
# correlate boot and exit by that number in :ractor mode.
|
|
109
|
+
# on_worker_exit { |worker_index, error| }
|
|
110
|
+
|
|
90
111
|
## Lifecycle
|
|
91
112
|
|
|
92
113
|
# On shutdown, give in-flight requests this many seconds to finish.
|
|
@@ -102,6 +123,36 @@
|
|
|
102
123
|
# heavily CPU-bound apps, try 1 to leave more cores for Ruby.
|
|
103
124
|
# tokio_threads 4
|
|
104
125
|
|
|
126
|
+
## Control plane
|
|
127
|
+
|
|
128
|
+
# Serve read-only monitoring on a separate address: live stats as JSON
|
|
129
|
+
# at /stats, Prometheus text at /metrics, and /ready and /live probes
|
|
130
|
+
# for an orchestrator. Answered by the native layer on its own thread,
|
|
131
|
+
# so it stays live even when every Ruby worker is busy or stuck.
|
|
132
|
+
# Accepts "host:port" or "unix://path". Off unless set.
|
|
133
|
+
# control_bind "127.0.0.1:9293"
|
|
134
|
+
|
|
135
|
+
# When set, /stats and /metrics require "Authorization: Bearer <token>".
|
|
136
|
+
# The probes stay open; they carry no data.
|
|
137
|
+
# control_token ENV["KINO_CONTROL_TOKEN"]
|
|
138
|
+
|
|
139
|
+
## Quarantine
|
|
140
|
+
|
|
141
|
+
# Quarantine a dispatch slot whose current request has run longer than
|
|
142
|
+
# this many seconds and spawn a replacement to restore capacity. The
|
|
143
|
+
# wedged worker is never interrupted or force-killed; its slot stays
|
|
144
|
+
# quarantined for good (in :ractor mode the ractor itself leaks until
|
|
145
|
+
# process exit, since a wedged ractor cannot be safely interrupted). Off
|
|
146
|
+
# unless set; set it above your slowest legitimate endpoint, and above
|
|
147
|
+
# request_timeout.
|
|
148
|
+
# quarantine_timeout 60
|
|
149
|
+
|
|
150
|
+
# Cap on the total number of replacement events over the process
|
|
151
|
+
# lifetime. Past the cap the monitor stops replacing and the server runs
|
|
152
|
+
# at reduced capacity. Default: the worker count in :ractor mode, workers
|
|
153
|
+
# x threads in :threaded.
|
|
154
|
+
# quarantine_max 8
|
|
155
|
+
|
|
105
156
|
## App
|
|
106
157
|
|
|
107
158
|
# Rackup file to load (a command-line argument wins).
|
data/lib/kino/version.rb
CHANGED
data/lib/kino/worker.rb
CHANGED
|
@@ -18,13 +18,14 @@ module Kino
|
|
|
18
18
|
|
|
19
19
|
module_function
|
|
20
20
|
|
|
21
|
-
def run(server_id, worker_id, app, batch_size = 1,
|
|
21
|
+
def run(server_id, worker_id, app, batch_size = 1, hooks = nil)
|
|
22
|
+
fire_after_worker_boot(hooks, worker_id)
|
|
22
23
|
if batch_size <= 1
|
|
23
24
|
env = Native.take_one(server_id, worker_id)
|
|
24
|
-
env = handle_one(env, server_id, worker_id, app,
|
|
25
|
+
env = handle_one(env, server_id, worker_id, app, hooks) while env
|
|
25
26
|
else
|
|
26
27
|
batch = Native.take_batch(server_id, worker_id, batch_size)
|
|
27
|
-
batch = process(batch, server_id, worker_id, app, batch_size,
|
|
28
|
+
batch = process(batch, server_id, worker_id, app, batch_size, hooks) while batch
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
@@ -34,8 +35,8 @@ module Kino
|
|
|
34
35
|
NOT_FUSED = Object.new.freeze
|
|
35
36
|
|
|
36
37
|
# Handle one request; returns the next env (fused take) or nil.
|
|
37
|
-
def handle_one(env, server_id, worker_id, app,
|
|
38
|
-
result = serve(env, app,
|
|
38
|
+
def handle_one(env, server_id, worker_id, app, hooks)
|
|
39
|
+
result = serve(env, app, hooks) do |request, status, headers, chunks|
|
|
39
40
|
request.respond_and_take_one(server_id, worker_id, status, headers, chunks)
|
|
40
41
|
end
|
|
41
42
|
result.equal?(NOT_FUSED) ? Native.take_one(server_id, worker_id) : result
|
|
@@ -43,10 +44,10 @@ module Kino
|
|
|
43
44
|
|
|
44
45
|
# Handle every env in the batch; returns the next batch (the last
|
|
45
46
|
# simple response rides the fused respond_and_take) or nil on shutdown.
|
|
46
|
-
def process(batch, server_id, worker_id, app, batch_size,
|
|
47
|
+
def process(batch, server_id, worker_id, app, batch_size, hooks)
|
|
47
48
|
last = batch.size - 1
|
|
48
49
|
batch.each_with_index do |env, index|
|
|
49
|
-
result = serve(env, app,
|
|
50
|
+
result = serve(env, app, hooks) do |request, status, headers, chunks|
|
|
50
51
|
if index == last
|
|
51
52
|
request.respond_and_take(server_id, worker_id, batch_size,
|
|
52
53
|
status, headers, chunks)
|
|
@@ -66,17 +67,30 @@ module Kino
|
|
|
66
67
|
# here and return NOT_FUSED. App errors must never kill the worker;
|
|
67
68
|
# hard crashes (Exception) are the supervisor's job; and `abort` does
|
|
68
69
|
# the right thing whether or not the response head already went out.
|
|
69
|
-
def serve(env, app,
|
|
70
|
+
def serve(env, app, hooks)
|
|
70
71
|
request = env[KINO_REQUEST]
|
|
71
72
|
env[RACK_INPUT] ||= Input.new(request)
|
|
72
73
|
status, headers, body = app.call(env)
|
|
73
74
|
|
|
74
75
|
if body.respond_to?(:to_ary)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
chunks = join_chunks(body.to_ary)
|
|
77
|
+
if hooks&.after_request_complete
|
|
78
|
+
# Hook set: do not fuse. Send the complete response, fire the hook
|
|
79
|
+
# after it is out, then signal the caller to take the next request
|
|
80
|
+
# separately (so the hook never waits on the next request).
|
|
81
|
+
request.send_simple(status.to_i, headers, chunks)
|
|
82
|
+
body.close if body.respond_to?(:close)
|
|
83
|
+
fire_after_request_complete(hooks, env, status.to_i)
|
|
84
|
+
NOT_FUSED
|
|
85
|
+
else
|
|
86
|
+
# No hook: fused fast path, unchanged, zero cost.
|
|
87
|
+
result = yield(request, status.to_i, headers, chunks)
|
|
88
|
+
body.close if body.respond_to?(:close)
|
|
89
|
+
result
|
|
90
|
+
end
|
|
78
91
|
else
|
|
79
92
|
deliver_streaming(request, status.to_i, headers, body, env[RACK_INPUT])
|
|
93
|
+
fire_after_request_complete(hooks, env, status.to_i)
|
|
80
94
|
NOT_FUSED
|
|
81
95
|
end
|
|
82
96
|
rescue => e
|
|
@@ -87,13 +101,7 @@ module Kino
|
|
|
87
101
|
# because nothing may escape this block and kill the worker.
|
|
88
102
|
Native.log_error(error_log_line(e))
|
|
89
103
|
request.abort
|
|
90
|
-
|
|
91
|
-
begin
|
|
92
|
-
on_error.call(e, env)
|
|
93
|
-
rescue => hook_error
|
|
94
|
-
Native.log_error("on_error hook raised #{hook_error.class}: #{hook_error.message}")
|
|
95
|
-
end
|
|
96
|
-
end
|
|
104
|
+
HookFire.fire(hooks&.on_error, "on_error", e, env)
|
|
97
105
|
NOT_FUSED
|
|
98
106
|
end
|
|
99
107
|
|
|
@@ -142,7 +150,16 @@ module Kino
|
|
|
142
150
|
joined
|
|
143
151
|
end
|
|
144
152
|
|
|
153
|
+
def fire_after_worker_boot(hooks, worker_id)
|
|
154
|
+
HookFire.fire(hooks&.after_worker_boot, "after_worker_boot", worker_id)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def fire_after_request_complete(hooks, env, status)
|
|
158
|
+
HookFire.fire(hooks&.after_request_complete, "after_request_complete", env, status)
|
|
159
|
+
end
|
|
160
|
+
|
|
145
161
|
private_class_method :handle_one, :process, :serve, :deliver_streaming,
|
|
146
|
-
:join_chunks, :error_log_line
|
|
162
|
+
:join_chunks, :error_log_line, :fire_after_worker_boot,
|
|
163
|
+
:fire_after_request_complete
|
|
147
164
|
end
|
|
148
165
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# @private
|
|
5
|
+
# The worker-context lifecycle hooks, bundled so one frozen, shareable
|
|
6
|
+
# value crosses into each worker (a ractor in :ractor mode) instead of
|
|
7
|
+
# several bare procs. Any member may be nil. A Data instance is frozen,
|
|
8
|
+
# so it is Ractor.shareable? exactly when its members are (nil, or a
|
|
9
|
+
# Ractor.shareable_proc), letting it ride the ractor boundary like the app.
|
|
10
|
+
WorkerHooks = Data.define(:on_error, :after_worker_boot, :after_request_complete)
|
|
11
|
+
end
|
data/lib/kino.rb
CHANGED
|
@@ -43,8 +43,11 @@ require_relative "kino/null_input"
|
|
|
43
43
|
require_relative "kino/errors_stream"
|
|
44
44
|
require_relative "kino/stream"
|
|
45
45
|
require_relative "kino/configuration"
|
|
46
|
+
require_relative "kino/hook_fire"
|
|
47
|
+
require_relative "kino/worker_hooks"
|
|
46
48
|
require_relative "kino/worker"
|
|
47
49
|
require_relative "kino/ractor_supervisor"
|
|
50
|
+
require_relative "kino/quarantine_monitor"
|
|
48
51
|
require_relative "kino/server"
|
|
49
52
|
|
|
50
53
|
# Hand the frozen shareable singletons to the native layer: it sets them
|
data/sig/kino.rbs
CHANGED
|
@@ -22,6 +22,7 @@ module Kino
|
|
|
22
22
|
|
|
23
23
|
class Server
|
|
24
24
|
attr_reader port: Integer?
|
|
25
|
+
attr_reader control_port: Integer?
|
|
25
26
|
attr_reader mode: Symbol
|
|
26
27
|
attr_reader bind: String
|
|
27
28
|
|
|
@@ -98,11 +99,19 @@ module Kino
|
|
|
98
99
|
def lanes: (boolish enabled) -> untyped
|
|
99
100
|
def log_requests: (boolish enabled) -> untyped
|
|
100
101
|
def on_error: (?^(Exception, Hash[String, untyped]) -> void handler) ?{ (Exception, Hash[String, untyped]) -> void } -> untyped
|
|
102
|
+
def after_boot: (?^() -> void handler) ?{ () -> void } -> untyped
|
|
103
|
+
def after_worker_boot: (?^(Integer) -> void handler) ?{ (Integer) -> void } -> untyped
|
|
104
|
+
def after_request_complete: (?^(Hash[String, untyped], Integer) -> void handler) ?{ (Hash[String, untyped], Integer) -> void } -> untyped
|
|
105
|
+
def on_worker_exit: (?^(Integer, Exception?) -> void handler) ?{ (Integer, Exception?) -> void } -> untyped
|
|
101
106
|
def shutdown_timeout: (Numeric seconds) -> untyped
|
|
102
107
|
def tokio_threads: (int count) -> untyped
|
|
103
108
|
def tls: (cert: String, key: String) -> untyped
|
|
104
109
|
def environment: (String | Symbol env) -> untyped
|
|
105
110
|
def pidfile: (String path) -> untyped
|
|
111
|
+
def control_bind: (String addr) -> String
|
|
112
|
+
def control_token: (String token) -> String
|
|
113
|
+
def quarantine_timeout: (Numeric? seconds) -> untyped
|
|
114
|
+
def quarantine_max: (int count) -> untyped
|
|
106
115
|
def rackup: (String path) -> untyped
|
|
107
116
|
end
|
|
108
117
|
end
|
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.3.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-
|
|
11
|
+
date: 2026-08-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|
|
@@ -147,16 +147,19 @@ files:
|
|
|
147
147
|
- lib/kino/cli.rb
|
|
148
148
|
- lib/kino/configuration.rb
|
|
149
149
|
- lib/kino/errors_stream.rb
|
|
150
|
+
- lib/kino/hook_fire.rb
|
|
150
151
|
- lib/kino/input.rb
|
|
151
152
|
- lib/kino/kino.so
|
|
152
153
|
- lib/kino/logger.rb
|
|
153
154
|
- lib/kino/null_input.rb
|
|
155
|
+
- lib/kino/quarantine_monitor.rb
|
|
154
156
|
- lib/kino/ractor_supervisor.rb
|
|
155
157
|
- lib/kino/server.rb
|
|
156
158
|
- lib/kino/stream.rb
|
|
157
159
|
- lib/kino/templates/kino.rb.tt
|
|
158
160
|
- lib/kino/version.rb
|
|
159
161
|
- lib/kino/worker.rb
|
|
162
|
+
- lib/kino/worker_hooks.rb
|
|
160
163
|
- sig/kino.rbs
|
|
161
164
|
homepage: https://github.com/yaroslav/kino
|
|
162
165
|
licenses:
|