kino 0.2.0 → 0.3.0

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/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", "~> 0.1"
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 (Mythos, Opus).
518
+ Claude Code (Fable 5, Opus 4.8).
438
519
 
439
520
  ## Contributing
440
521
 
data/ext/kino/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kino"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  edition = "2021"
5
5
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
6
6
  license = "MIT"
@@ -14,11 +14,11 @@ crate-type = ["cdylib", "rlib"]
14
14
  [dependencies]
15
15
  magnus = { version = "0.8.2", features = ["rb-sys"] }
16
16
  rb-sys = { version = "0.9", features = ["stable-api-compiled-fallback"] }
17
- flume = "0.11"
17
+ flume = "0.12"
18
18
  parking_lot = "0.12"
19
19
  ahash = "0.8"
20
20
  smallvec = "1"
21
- lru = "0.12"
21
+ lru = "0.18"
22
22
  mimalloc = { version = "0.1", default-features = false }
23
23
  tokio = { version = "1.45", features = ["rt-multi-thread", "net", "time", "sync", "io-util", "macros"] }
24
24
  hyper = { version = "1.6", features = ["http1", "server"] }