kino 0.6.0 → 0.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/Cargo.lock +1 -1
- data/README.md +52 -0
- data/doc/architecture.md +10 -0
- data/ext/kino/Cargo.toml +1 -1
- data/ext/kino/src/control.rs +103 -6
- data/ext/kino/src/env_strings.rs +249 -120
- data/ext/kino/src/lib.rs +11 -0
- data/ext/kino/src/pin.rs +21 -9
- data/ext/kino/src/queue.rs +99 -3
- data/ext/kino/src/registry.rs +141 -0
- data/ext/kino/src/request.rs +1 -1
- data/ext/kino/src/server.rs +218 -3
- data/ext/kino/src/test_support.rs +44 -0
- data/lib/kino/cli.rb +12 -4
- data/lib/kino/configuration.rb +16 -2
- data/lib/kino/monitor.rb +52 -0
- data/lib/kino/pool_scaler.rb +103 -0
- data/lib/kino/quarantine_monitor.rb +5 -37
- data/lib/kino/ractor_supervisor.rb +102 -8
- data/lib/kino/server.rb +64 -85
- data/lib/kino/slot_bank.rb +31 -0
- data/lib/kino/templates/kino.rb.tt +15 -0
- data/lib/kino/threaded_pool.rb +186 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino.rb +4 -0
- data/lib/rackup/handler/kino.rb +4 -4
- data/sig/kino.rbs +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03f08b02b92df6b069d24b28d3abfba6147b2aa6ab854034a6e7619dd1eb771c
|
|
4
|
+
data.tar.gz: 945c95b779ea6c2729d514657b62e7a7014c30b28965122f6a62b7fb2f27ca20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 631c65a77e3cae89796e2a7465ed11fbcec652b9bf78fe325d18f77b53f7c26d3922770cf1f42b6b6d3bf07e70ed53dbd639af4e71ba053e77b85508bc6825b4
|
|
7
|
+
data.tar.gz: 7cc673c49dcdefbb7e1f7343a9058426339d2b983507ced5e72aa573106c91219608761f84cc8dd06889292ca8761a88f85e901d49be70eb98afca6707340385
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## [0.7.0] - 2026-09-08
|
|
2
|
+
|
|
3
|
+
- Experimental elastic worker pool. Set `max_workers` above `workers`
|
|
4
|
+
and the pool grows under load, one worker at a time, then shrinks
|
|
5
|
+
back to `workers` once the extra workers have sat idle for
|
|
6
|
+
`scale_down_after` seconds (default 30). Works in both modes; a
|
|
7
|
+
retiring worker finishes its request first. `stats`, `/stats` and
|
|
8
|
+
`/metrics` gain `max_workers`, `active_workers`, `scale_ups` and
|
|
9
|
+
`scale_downs`, and each `worker_status` row gains `retired`. Leave
|
|
10
|
+
`max_workers` unset and the pool is fixed, as before.
|
|
11
|
+
- Ractor mode warns at boot when `max_workers` (or `workers`) exceeds
|
|
12
|
+
`RUBY_MAX_CPU` (default 8), Ruby's cap on how many ractors run Ruby
|
|
13
|
+
code at once. Set the variable to your worker count to lift it.
|
|
14
|
+
- Ractor-readiness fixes, so external Ractor audits of Kino pass: the
|
|
15
|
+
env string caches root their strings through the lock-free pin slab
|
|
16
|
+
instead of per-value GC registration (unsynchronized across ractors
|
|
17
|
+
in Ruby 4.0, a crash) and no longer call into Ruby while locked (a
|
|
18
|
+
GC-barrier deadlock); the shared rack.errors and rack.input
|
|
19
|
+
singletons must be Ractor-shareable, not just frozen; the Rack
|
|
20
|
+
handler's option table is shareable. Throughput is unchanged.
|
|
21
|
+
|
|
1
22
|
## [0.6.0] - 2026-09-01
|
|
2
23
|
|
|
3
24
|
- HTTP/2 support. Kino now speaks HTTP/2 natively, on by default:
|
data/Cargo.lock
CHANGED
data/README.md
CHANGED
|
@@ -259,6 +259,8 @@ server = Kino::Server.new(app,
|
|
|
259
259
|
bind: "127.0.0.1", # or "unix:///run/kino.sock" behind a proxy
|
|
260
260
|
port: 9292, # 0 = ephemeral; read back via server.port
|
|
261
261
|
workers: Kino.available_parallelism, # ractors (parallelism); the default
|
|
262
|
+
max_workers: nil, # experimental: grow past workers under load (see Elastic pool)
|
|
263
|
+
scale_down_after: 30, # seconds idle before an extra worker retires
|
|
262
264
|
threads: 1, # per worker; ractor default 1, threaded default 3
|
|
263
265
|
mode: :auto, # :auto | :ractor | :threaded
|
|
264
266
|
queue_depth: 1024, # bounded queue; overflow → 503
|
|
@@ -418,6 +420,56 @@ Kino fires four lifecycle hooks alongside `on_error`, split by firing context.
|
|
|
418
420
|
|
|
419
421
|
A raising hook is logged and never kills a worker.
|
|
420
422
|
|
|
423
|
+
## Elastic pool (experimental)
|
|
424
|
+
|
|
425
|
+
Size the pool for the quiet hours and let it grow for the busy ones:
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
# kino.rb
|
|
429
|
+
workers 4 # always running
|
|
430
|
+
max_workers 16 # reached only under load
|
|
431
|
+
scale_down_after 30 # seconds idle before an extra worker retires
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Or `Kino::Server.new(app, workers: 4, max_workers: 16)`. Leave
|
|
435
|
+
`max_workers` unset and the pool is fixed at `workers`, as before.
|
|
436
|
+
|
|
437
|
+
While requests wait in the queue, Kino adds a worker every 100 ms until
|
|
438
|
+
the queue clears or the pool hits `max_workers`. When the load passes,
|
|
439
|
+
workers above `workers` retire one at a time after `scale_down_after`
|
|
440
|
+
seconds idle, each finishing its current request first. Same behavior
|
|
441
|
+
in `:ractor` and `:threaded` mode.
|
|
442
|
+
|
|
443
|
+
**Use it when your app waits**: on databases, upstream services, slow
|
|
444
|
+
clients. With `workers` at your core count, all workers can be blocked
|
|
445
|
+
on I/O while cores sit idle; a higher ceiling puts those cores to work,
|
|
446
|
+
and a ractor starts in microseconds, so the pool follows load closely.
|
|
447
|
+
Pure CPU work gains nothing past the core count. In `:ractor` mode Ruby
|
|
448
|
+
itself runs at most `RUBY_MAX_CPU` ractors' Ruby code at once (default
|
|
449
|
+
8), and Kino warns at boot when the pool can exceed it. On a bigger box:
|
|
450
|
+
|
|
451
|
+
```sh
|
|
452
|
+
RUBY_MAX_CPU=16 kino
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
**Watch it breathe** in `server.stats`, `GET /stats` and `GET /metrics`:
|
|
456
|
+
|
|
457
|
+
```sh
|
|
458
|
+
$ curl -s localhost:9293/stats | jq '{workers, max_workers, active_workers, scale_ups, scale_downs}'
|
|
459
|
+
{
|
|
460
|
+
"workers": 4,
|
|
461
|
+
"max_workers": 16,
|
|
462
|
+
"active_workers": 9,
|
|
463
|
+
"scale_ups": 12,
|
|
464
|
+
"scale_downs": 7
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Prometheus gets `kino_max_workers`, `kino_active_workers`,
|
|
469
|
+
`kino_scale_ups_total` and `kino_scale_downs_total`. `after_worker_boot`
|
|
470
|
+
fires for every worker the pool adds, `on_worker_exit` (with a nil
|
|
471
|
+
cause) for every one it retires.
|
|
472
|
+
|
|
421
473
|
## Stuck-worker quarantine
|
|
422
474
|
|
|
423
475
|
`quarantine_timeout: seconds` (or `quarantine_timeout 60` in `kino.rb`)
|
data/doc/architecture.md
CHANGED
|
@@ -31,6 +31,16 @@ Puma-style two-level: `workers × threads`.
|
|
|
31
31
|
- Identical machinery either way: the flume queue is MPMC, a "worker slot"
|
|
32
32
|
is per-thread, and the worker loop (`lib/kino/worker.rb`) is shared
|
|
33
33
|
verbatim.
|
|
34
|
+
- Elastic pool (`max_workers`): a scaler thread on the main ractor
|
|
35
|
+
samples queue depth and the per-slot sensors every 100 ms, adds one
|
|
36
|
+
worker per sample while requests wait, and retires the longest-idle
|
|
37
|
+
worker above the floor after `scale_down_after`. Retirement is a
|
|
38
|
+
per-slot flag raised under the slot's lane lock: the lane dispatcher
|
|
39
|
+
skips the slot, the take loop honors the flag at its next idle tick (a
|
|
40
|
+
request already taken finishes first, a lane worker drains its own
|
|
41
|
+
lane), and the slot is reset and reused by the next worker, so the
|
|
42
|
+
slot table never grows with churn. Both pools (the ractor supervisor
|
|
43
|
+
and the threaded pool) expose the same grow/retire/groups seam.
|
|
34
44
|
- Experimental `lanes true` replaces the one shared queue with a small
|
|
35
45
|
private queue per worker slot (awake-preferring dispatch, work
|
|
36
46
|
stealing); see [benchmarks](benchmarks.md#lane-dispatch-experimental-lanes-true).
|
data/ext/kino/Cargo.toml
CHANGED
data/ext/kino/src/control.rs
CHANGED
|
@@ -18,6 +18,8 @@ pub struct WorkerStat {
|
|
|
18
18
|
pub in_flight: usize,
|
|
19
19
|
pub busy_ms: u64,
|
|
20
20
|
pub quarantined: bool,
|
|
21
|
+
/// Sent home by the pool scaler; leaves at its next idle tick.
|
|
22
|
+
pub retired: bool,
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
/// Read every slot's per-worker sensors in one pass under the slots read
|
|
@@ -46,6 +48,7 @@ pub fn collect_worker_status(server: &ServerInner) -> Vec<WorkerStat> {
|
|
|
46
48
|
now.saturating_sub(started)
|
|
47
49
|
},
|
|
48
50
|
quarantined,
|
|
51
|
+
retired: slot.retired.load(Ordering::Relaxed),
|
|
49
52
|
}
|
|
50
53
|
})
|
|
51
54
|
.collect()
|
|
@@ -70,6 +73,10 @@ pub struct StatsSnapshot {
|
|
|
70
73
|
pub worker_status: Vec<WorkerStat>,
|
|
71
74
|
pub quarantined_count: usize,
|
|
72
75
|
pub quarantine_replacements: u64,
|
|
76
|
+
pub max_workers: usize,
|
|
77
|
+
pub active_workers: usize,
|
|
78
|
+
pub scale_ups: u64,
|
|
79
|
+
pub scale_downs: u64,
|
|
73
80
|
pub queue_histogram: crate::registry::QueueHistogramSnapshot,
|
|
74
81
|
}
|
|
75
82
|
|
|
@@ -94,6 +101,10 @@ impl StatsSnapshot {
|
|
|
94
101
|
worker_status,
|
|
95
102
|
quarantined_count,
|
|
96
103
|
quarantine_replacements: server.quarantine_replacements.load(Ordering::Relaxed),
|
|
104
|
+
max_workers: server.topology.max_workers,
|
|
105
|
+
active_workers: server.active_workers.load(Ordering::Relaxed),
|
|
106
|
+
scale_ups: server.scale_ups.load(Ordering::Relaxed),
|
|
107
|
+
scale_downs: server.scale_downs.load(Ordering::Relaxed),
|
|
97
108
|
queue_histogram: server.queue_histogram.snapshot(),
|
|
98
109
|
}
|
|
99
110
|
}
|
|
@@ -114,9 +125,10 @@ pub fn stats_json(s: &StatsSnapshot) -> String {
|
|
|
114
125
|
let mut out = String::with_capacity(256);
|
|
115
126
|
write!(
|
|
116
127
|
out,
|
|
117
|
-
r#"{{"mode":"{}","lanes":{},"workers":{},"threads":{},"batch":{},"respawns":{},"queued":{},"in_flight":{},"served":{},"rejected":{},"timeouts":{}"#,
|
|
128
|
+
r#"{{"mode":"{}","lanes":{},"workers":{},"threads":{},"batch":{},"respawns":{},"queued":{},"in_flight":{},"served":{},"rejected":{},"timeouts":{},"max_workers":{},"active_workers":{},"scale_ups":{},"scale_downs":{}"#,
|
|
118
129
|
s.mode, s.lanes, s.workers, s.threads, s.batch, s.respawns,
|
|
119
|
-
s.queued, s.in_flight, s.served, s.rejected, s.timeouts
|
|
130
|
+
s.queued, s.in_flight, s.served, s.rejected, s.timeouts,
|
|
131
|
+
s.max_workers, s.active_workers, s.scale_ups, s.scale_downs
|
|
120
132
|
)
|
|
121
133
|
.expect("writing to a String cannot fail");
|
|
122
134
|
if let Some(depths) = &s.lane_depths {
|
|
@@ -134,8 +146,8 @@ pub fn stats_json(s: &StatsSnapshot) -> String {
|
|
|
134
146
|
}
|
|
135
147
|
write!(
|
|
136
148
|
out,
|
|
137
|
-
r#"{{"index":{},"served":{},"in_flight":{},"busy_ms":{},"quarantined":{}}}"#,
|
|
138
|
-
w.index, w.served, w.in_flight, w.busy_ms, w.quarantined
|
|
149
|
+
r#"{{"index":{},"served":{},"in_flight":{},"busy_ms":{},"quarantined":{},"retired":{}}}"#,
|
|
150
|
+
w.index, w.served, w.in_flight, w.busy_ms, w.quarantined, w.retired
|
|
139
151
|
)
|
|
140
152
|
.expect("writing to a String cannot fail");
|
|
141
153
|
}
|
|
@@ -247,6 +259,34 @@ pub fn metrics_text(s: &StatsSnapshot) -> String {
|
|
|
247
259
|
"Configured threads per worker.",
|
|
248
260
|
s.threads,
|
|
249
261
|
);
|
|
262
|
+
metric(
|
|
263
|
+
&mut out,
|
|
264
|
+
"kino_max_workers",
|
|
265
|
+
"gauge",
|
|
266
|
+
"Worker pool ceiling (equals kino_workers for a fixed pool).",
|
|
267
|
+
s.max_workers,
|
|
268
|
+
);
|
|
269
|
+
metric(
|
|
270
|
+
&mut out,
|
|
271
|
+
"kino_active_workers",
|
|
272
|
+
"gauge",
|
|
273
|
+
"Workers currently alive and serving.",
|
|
274
|
+
s.active_workers,
|
|
275
|
+
);
|
|
276
|
+
metric(
|
|
277
|
+
&mut out,
|
|
278
|
+
"kino_scale_ups_total",
|
|
279
|
+
"counter",
|
|
280
|
+
"Workers added by the pool scaler.",
|
|
281
|
+
s.scale_ups,
|
|
282
|
+
);
|
|
283
|
+
metric(
|
|
284
|
+
&mut out,
|
|
285
|
+
"kino_scale_downs_total",
|
|
286
|
+
"counter",
|
|
287
|
+
"Idle workers retired by the pool scaler.",
|
|
288
|
+
s.scale_downs,
|
|
289
|
+
);
|
|
250
290
|
metric(
|
|
251
291
|
&mut out,
|
|
252
292
|
"kino_ready",
|
|
@@ -641,6 +681,10 @@ mod tests {
|
|
|
641
681
|
worker_status: vec![],
|
|
642
682
|
quarantined_count: 0,
|
|
643
683
|
quarantine_replacements: 0,
|
|
684
|
+
max_workers: 32,
|
|
685
|
+
active_workers: 12,
|
|
686
|
+
scale_ups: 3,
|
|
687
|
+
scale_downs: 1,
|
|
644
688
|
queue_histogram: crate::registry::QueueHistogramSnapshot {
|
|
645
689
|
buckets: [0; crate::registry::QUEUE_BOUNDS_US.len()],
|
|
646
690
|
overflow: 0,
|
|
@@ -665,6 +709,10 @@ mod tests {
|
|
|
665
709
|
r#""served":100"#,
|
|
666
710
|
r#""rejected":5"#,
|
|
667
711
|
r#""timeouts":6"#,
|
|
712
|
+
r#""max_workers":32"#,
|
|
713
|
+
r#""active_workers":12"#,
|
|
714
|
+
r#""scale_ups":3"#,
|
|
715
|
+
r#""scale_downs":1"#,
|
|
668
716
|
r#""state":"ready""#,
|
|
669
717
|
r#""version":""#,
|
|
670
718
|
] {
|
|
@@ -690,6 +738,49 @@ mod tests {
|
|
|
690
738
|
assert!(draining.contains("kino_ready 0"));
|
|
691
739
|
}
|
|
692
740
|
|
|
741
|
+
#[test]
|
|
742
|
+
fn metrics_text_reports_the_elastic_pool() {
|
|
743
|
+
let text = metrics_text(&snapshot(crate::registry::STATE_READY));
|
|
744
|
+
assert!(text.contains("# TYPE kino_max_workers gauge"));
|
|
745
|
+
assert!(text.contains("kino_max_workers 32"));
|
|
746
|
+
assert!(text.contains("# TYPE kino_active_workers gauge"));
|
|
747
|
+
assert!(text.contains("kino_active_workers 12"));
|
|
748
|
+
assert!(text.contains("# TYPE kino_scale_ups_total counter"));
|
|
749
|
+
assert!(text.contains("kino_scale_ups_total 3"));
|
|
750
|
+
assert!(text.contains("# TYPE kino_scale_downs_total counter"));
|
|
751
|
+
assert!(text.contains("kino_scale_downs_total 1"));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
#[test]
|
|
755
|
+
fn worker_status_reports_retired_slots() {
|
|
756
|
+
let server = crate::registry::test_server(false, 4);
|
|
757
|
+
server.register_worker();
|
|
758
|
+
server.register_worker();
|
|
759
|
+
server.slots.read()[1].retire();
|
|
760
|
+
|
|
761
|
+
let status = collect_worker_status(&server);
|
|
762
|
+
|
|
763
|
+
assert_eq!(
|
|
764
|
+
status.iter().map(|w| w.retired).collect::<Vec<_>>(),
|
|
765
|
+
vec![false, true]
|
|
766
|
+
);
|
|
767
|
+
assert!(status.iter().all(|w| w.busy_ms == 0));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
#[test]
|
|
771
|
+
fn snapshot_reads_the_pool_counters() {
|
|
772
|
+
let server = crate::registry::test_server(false, 4);
|
|
773
|
+
server.active_workers.store(3, Ordering::Relaxed);
|
|
774
|
+
server.scale_ups.fetch_add(2, Ordering::Relaxed);
|
|
775
|
+
server.scale_downs.fetch_add(1, Ordering::Relaxed);
|
|
776
|
+
|
|
777
|
+
let s = StatsSnapshot::take(&server);
|
|
778
|
+
|
|
779
|
+
assert_eq!(s.active_workers, 3);
|
|
780
|
+
assert_eq!(s.max_workers, server.topology.max_workers);
|
|
781
|
+
assert_eq!((s.scale_ups, s.scale_downs), (2, 1));
|
|
782
|
+
}
|
|
783
|
+
|
|
693
784
|
#[test]
|
|
694
785
|
fn metrics_text_includes_lane_depth_samples_when_lanes_are_on() {
|
|
695
786
|
let mut s = snapshot(crate::registry::STATE_READY);
|
|
@@ -757,6 +848,7 @@ mod tests {
|
|
|
757
848
|
in_flight: 1,
|
|
758
849
|
busy_ms: 4,
|
|
759
850
|
quarantined: false,
|
|
851
|
+
retired: false,
|
|
760
852
|
},
|
|
761
853
|
WorkerStat {
|
|
762
854
|
index: 1,
|
|
@@ -764,10 +856,11 @@ mod tests {
|
|
|
764
856
|
in_flight: 0,
|
|
765
857
|
busy_ms: 0,
|
|
766
858
|
quarantined: false,
|
|
859
|
+
retired: true,
|
|
767
860
|
},
|
|
768
861
|
];
|
|
769
862
|
let json = stats_json(&s);
|
|
770
|
-
assert!(json.contains(r#""worker_status":[{"index":0,"served":10,"in_flight":1,"busy_ms":4,"quarantined":false},{"index":1,"served":7,"in_flight":0,"busy_ms":0,"quarantined":false}]"#), "got {json}");
|
|
863
|
+
assert!(json.contains(r#""worker_status":[{"index":0,"served":10,"in_flight":1,"busy_ms":4,"quarantined":false,"retired":false},{"index":1,"served":7,"in_flight":0,"busy_ms":0,"quarantined":false,"retired":true}]"#), "got {json}");
|
|
771
864
|
}
|
|
772
865
|
|
|
773
866
|
#[test]
|
|
@@ -786,6 +879,7 @@ mod tests {
|
|
|
786
879
|
in_flight: 1,
|
|
787
880
|
busy_ms: 4,
|
|
788
881
|
quarantined: false,
|
|
882
|
+
retired: false,
|
|
789
883
|
},
|
|
790
884
|
WorkerStat {
|
|
791
885
|
index: 1,
|
|
@@ -793,6 +887,7 @@ mod tests {
|
|
|
793
887
|
in_flight: 0,
|
|
794
888
|
busy_ms: 0,
|
|
795
889
|
quarantined: false,
|
|
890
|
+
retired: true,
|
|
796
891
|
},
|
|
797
892
|
];
|
|
798
893
|
let text = metrics_text(&s);
|
|
@@ -835,6 +930,7 @@ mod tests {
|
|
|
835
930
|
in_flight: 1,
|
|
836
931
|
busy_ms: 0,
|
|
837
932
|
quarantined: true,
|
|
933
|
+
retired: false,
|
|
838
934
|
},
|
|
839
935
|
WorkerStat {
|
|
840
936
|
index: 1,
|
|
@@ -842,6 +938,7 @@ mod tests {
|
|
|
842
938
|
in_flight: 1,
|
|
843
939
|
busy_ms: 5,
|
|
844
940
|
quarantined: false,
|
|
941
|
+
retired: false,
|
|
845
942
|
},
|
|
846
943
|
];
|
|
847
944
|
let json = stats_json(&s);
|
|
@@ -850,7 +947,7 @@ mod tests {
|
|
|
850
947
|
"top-level count: {json}"
|
|
851
948
|
);
|
|
852
949
|
assert!(
|
|
853
|
-
json.contains(r#"{"index":0,"served":3,"in_flight":1,"busy_ms":0,"quarantined":true}"#),
|
|
950
|
+
json.contains(r#"{"index":0,"served":3,"in_flight":1,"busy_ms":0,"quarantined":true,"retired":false}"#),
|
|
854
951
|
"{json}"
|
|
855
952
|
);
|
|
856
953
|
assert!(json.contains(r#""quarantined":false"#));
|