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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 591ec1716d7d8f23c1483ba2db85f517c42da5de78343c938a27d3e00ca1a66a
4
- data.tar.gz: 91a95efe00e8193be3792c4e2b8055f1b45e2b1a4dcb6acaa1a1a4525300f878
3
+ metadata.gz: 03f08b02b92df6b069d24b28d3abfba6147b2aa6ab854034a6e7619dd1eb771c
4
+ data.tar.gz: 945c95b779ea6c2729d514657b62e7a7014c30b28965122f6a62b7fb2f27ca20
5
5
  SHA512:
6
- metadata.gz: ed8d00148990ce34bd73308b376f342b011de37ccf39a078a8676183b0689ad1762af4dc038d6baf2b0501cdb8556a5448e37e0f7ab0c8c755d4e719ceca62c6
7
- data.tar.gz: 8305703e2c93bfbe4a00944a4773213f0414459bfdc2e85f963736767a382d85437889d9b216a845df2e314c4d339e0ddd40aebb5b6689b413c16cdf04e4cc87
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
@@ -444,7 +444,7 @@ dependencies = [
444
444
 
445
445
  [[package]]
446
446
  name = "kino"
447
- version = "0.6.0"
447
+ version = "0.7.0"
448
448
  dependencies = [
449
449
  "ahash",
450
450
  "bytes",
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
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kino"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  edition = "2021"
5
5
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
6
6
  license = "MIT"
@@ -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"#));