kino 0.6.0 → 0.7.1

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.
@@ -68,6 +68,10 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
68
68
  let log_requests: bool = cfg_opt(ruby, config, "log_requests")?.unwrap_or(false);
69
69
  let mode: String = cfg_opt(ruby, config, "mode")?.unwrap_or_else(|| "threaded".to_string());
70
70
  let workers: usize = cfg_opt(ruby, config, "workers")?.unwrap_or(0);
71
+ // Absent or below the floor means a fixed pool.
72
+ let max_workers: usize = cfg_opt::<usize>(ruby, config, "max_workers")?
73
+ .unwrap_or(workers)
74
+ .max(workers);
71
75
  let threads: usize = cfg_opt(ruby, config, "threads")?.unwrap_or(0);
72
76
  let batch: usize = cfg_opt(ruby, config, "batch")?.unwrap_or(1);
73
77
  let acceptor = match (&tls_cert, &tls_key) {
@@ -113,6 +117,7 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
113
117
  let (req_tx, req_rx) = flume::bounded(queue_depth);
114
118
  let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
115
119
 
120
+ let pin_slab = Arc::new(crate::pin::PinSlab::for_responses(mode == "ractor"));
116
121
  let server = Arc::new(ServerInner {
117
122
  id: registry::next_server_id(),
118
123
  req_tx: Mutex::new(Some(req_tx)),
@@ -130,9 +135,13 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
130
135
  state: std::sync::atomic::AtomicU8::new(registry::STATE_BOOTING),
131
136
  respawns: std::sync::atomic::AtomicU64::new(0),
132
137
  quarantine_replacements: std::sync::atomic::AtomicU64::new(0),
138
+ active_workers: std::sync::atomic::AtomicUsize::new(workers),
139
+ scale_ups: std::sync::atomic::AtomicU64::new(0),
140
+ scale_downs: std::sync::atomic::AtomicU64::new(0),
133
141
  topology: registry::Topology {
134
142
  mode,
135
143
  workers,
144
+ max_workers,
136
145
  threads,
137
146
  batch,
138
147
  },
@@ -142,7 +151,7 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
142
151
  access_log: log_requests.then(|| crate::logsink::Sink::new(std::io::stdout())),
143
152
  lanes,
144
153
  lane_cursor: std::sync::atomic::AtomicUsize::new(0),
145
- pin_slab: Arc::new(crate::pin::PinSlab::new()),
154
+ pin_slab,
146
155
  queue_histogram: registry::QueueHistogram::new(),
147
156
  });
148
157
 
@@ -383,6 +392,12 @@ fn advertised_streams(workers: usize, threads: usize) -> u32 {
383
392
  slots.clamp(8, 1024) as u32
384
393
  }
385
394
 
395
+ /// The pool can grow to its ceiling to meet what an h2 balancer sends, so
396
+ /// the ceiling is the admission to advertise (the floor for a fixed pool).
397
+ fn stream_capacity(topology: &registry::Topology) -> u32 {
398
+ advertised_streams(topology.max_workers, topology.threads)
399
+ }
400
+
386
401
  fn conn_builder(http2: bool, max_streams: u32) -> auto::Builder<TokioExecutor> {
387
402
  let mut builder = auto::Builder::new(TokioExecutor::new());
388
403
  builder
@@ -418,7 +433,7 @@ async fn serve_connection<I>(
418
433
  I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
419
434
  {
420
435
  let http2 = server.http2;
421
- let max_streams = advertised_streams(server.topology.workers, server.topology.threads);
436
+ let max_streams = stream_capacity(&server.topology);
422
437
  let mut drain = server.shutdown_tx.subscribe();
423
438
  let service =
424
439
  service_fn(move |req| handle_request(server.clone(), remote_addr, local_addr, req));
@@ -712,6 +727,11 @@ fn try_dispatch(server: &ServerInner, mut ctx: BoxedCtx) -> Dispatch {
712
727
  let guard = slot.lane_tx.lock();
713
728
  let Some(tx) = guard.as_ref() else { continue };
714
729
  any_open = true;
730
+ // Checked under the lane lock, which WorkerSlot::retire also
731
+ // takes: see there for the ordering.
732
+ if slot.retired.load(Ordering::SeqCst) {
733
+ continue;
734
+ }
715
735
  match tx.try_send(ctx) {
716
736
  Ok(()) => return Dispatch::Sent,
717
737
  Err(flume::TrySendError::Full(c)) | Err(flume::TrySendError::Disconnected(c)) => {
@@ -900,7 +920,7 @@ pub fn queue_time(_ruby: &Ruby, server_id: u64) -> Result<(u64, f64), Error> {
900
920
  }
901
921
 
902
922
  /// One worker slot's [index, served, in_flight, busy_ms, quarantined] row.
903
- pub type WorkerStatRow = (usize, u64, usize, u64, bool);
923
+ pub type WorkerStatRow = (usize, u64, usize, u64, bool, bool);
904
924
 
905
925
  /// Per-slot rows for Server#stats parity: [index, served, in_flight,
906
926
  /// busy_ms, quarantined] each. Empty when the server is gone.
@@ -910,7 +930,16 @@ pub fn worker_stats(_ruby: &Ruby, server_id: u64) -> Result<Vec<WorkerStatRow>,
910
930
  };
911
931
  Ok(crate::control::collect_worker_status(&server)
912
932
  .into_iter()
913
- .map(|w| (w.index, w.served, w.in_flight, w.busy_ms, w.quarantined))
933
+ .map(|w| {
934
+ (
935
+ w.index,
936
+ w.served,
937
+ w.in_flight,
938
+ w.busy_ms,
939
+ w.quarantined,
940
+ w.retired,
941
+ )
942
+ })
914
943
  .collect())
915
944
  }
916
945
 
@@ -923,6 +952,61 @@ pub fn quarantine_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<
923
952
  Ok(())
924
953
  }
925
954
 
955
+ /// Send a slot's worker home once idle (pool scale-down); see
956
+ /// registry::WorkerSlot::retire. An unknown id is a caller bug: raise.
957
+ pub fn retire_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
958
+ if let Some(server) = registry::try_get(server_id) {
959
+ server.slot(ruby, worker_id)?.retire();
960
+ }
961
+ Ok(())
962
+ }
963
+
964
+ /// Hand a retired slot to a new worker (pool scale-up reusing a slot).
965
+ pub fn reset_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
966
+ if let Some(server) = registry::try_get(server_id) {
967
+ server.slot(ruby, worker_id)?.reset();
968
+ }
969
+ Ok(())
970
+ }
971
+
972
+ /// The Ruby pool reports how many workers are alive and serving.
973
+ pub fn set_active_workers(_ruby: &Ruby, server_id: u64, count: usize) -> Result<(), Error> {
974
+ if let Some(server) = registry::try_get(server_id) {
975
+ server.active_workers.store(count, Ordering::Relaxed);
976
+ }
977
+ Ok(())
978
+ }
979
+
980
+ /// One worker added by the pool scaler.
981
+ pub fn record_scale_up(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
982
+ if let Some(server) = registry::try_get(server_id) {
983
+ server.scale_ups.fetch_add(1, Ordering::Relaxed);
984
+ }
985
+ Ok(())
986
+ }
987
+
988
+ /// One worker retired by the pool scaler.
989
+ pub fn record_scale_down(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
990
+ if let Some(server) = registry::try_get(server_id) {
991
+ server.scale_downs.fetch_add(1, Ordering::Relaxed);
992
+ }
993
+ Ok(())
994
+ }
995
+
996
+ /// [active_workers, max_workers, scale_ups, scale_downs] for Server#stats.
997
+ /// Zeros when the server is gone.
998
+ pub fn pool_stats(_ruby: &Ruby, server_id: u64) -> Result<(usize, usize, u64, u64), Error> {
999
+ let Some(server) = registry::try_get(server_id) else {
1000
+ return Ok((0, 0, 0, 0));
1001
+ };
1002
+ Ok((
1003
+ server.active_workers.load(Ordering::Relaxed),
1004
+ server.topology.max_workers,
1005
+ server.scale_ups.load(Ordering::Relaxed),
1006
+ server.scale_downs.load(Ordering::Relaxed),
1007
+ ))
1008
+ }
1009
+
926
1010
  /// One replacement spawned by the quarantine monitor.
927
1011
  pub fn record_quarantine_replacement(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
928
1012
  if let Some(server) = registry::try_get(server_id) {
@@ -1355,6 +1439,32 @@ mod tests {
1355
1439
  assert_eq!(advertised_streams(8, 0), 200);
1356
1440
  }
1357
1441
 
1442
+ #[test]
1443
+ fn stream_capacity_is_advertised_from_the_pool_ceiling() {
1444
+ // An elastic pool grows to meet load an h2 balancer sends, so the
1445
+ // ceiling, not the floor, is the admission to advertise.
1446
+ let topology = registry::Topology {
1447
+ mode: "ractor".to_string(),
1448
+ workers: 2,
1449
+ max_workers: 8,
1450
+ threads: 3,
1451
+ batch: 1,
1452
+ };
1453
+ assert_eq!(stream_capacity(&topology), 24);
1454
+ }
1455
+
1456
+ #[test]
1457
+ fn stream_capacity_of_a_fixed_pool_is_its_slot_count() {
1458
+ let topology = registry::Topology {
1459
+ mode: "threaded".to_string(),
1460
+ workers: 4,
1461
+ max_workers: 4,
1462
+ threads: 3,
1463
+ batch: 1,
1464
+ };
1465
+ assert_eq!(stream_capacity(&topology), 12);
1466
+ }
1467
+
1358
1468
  #[tokio::test]
1359
1469
  async fn streams_beyond_the_advertised_cap_queue_instead_of_failing() {
1360
1470
  use http_body_util::Full;
@@ -1566,4 +1676,110 @@ mod tests {
1566
1676
  assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1567
1677
  assert_eq!(server.lane_depths(), Some(vec![0, 2]));
1568
1678
  }
1679
+
1680
+ #[test]
1681
+ fn dispatch_skips_retired_lanes() {
1682
+ let server = test_server(true, 4);
1683
+ server.register_worker();
1684
+ server.register_worker();
1685
+ server.slots.read()[0].retire();
1686
+
1687
+ // A retiring worker gets nothing new: both land on slot 1.
1688
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1689
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1690
+ assert_eq!(server.lane_depths(), Some(vec![0, 2]));
1691
+ }
1692
+
1693
+ #[test]
1694
+ fn dispatch_retries_rather_than_closing_when_only_retired_lanes_remain() {
1695
+ let server = test_server(true, 4);
1696
+ server.register_worker();
1697
+ server.slots.read()[0].retire();
1698
+
1699
+ // Retired is not draining: the caller keeps retrying until the
1700
+ // queue timeout, so a replacement worker can still pick this up.
1701
+ assert!(matches!(
1702
+ try_dispatch(&server, test_ctx()),
1703
+ Dispatch::Full(_)
1704
+ ));
1705
+ }
1706
+
1707
+ #[test]
1708
+ fn dispatch_skips_a_retired_lane_even_when_it_is_the_only_awake_one() {
1709
+ let server = test_server(true, 4);
1710
+ server.register_worker();
1711
+ server.register_worker();
1712
+ server.slots.read()[0].retire();
1713
+ server.slots.read()[1].parked.store(true, Ordering::Relaxed);
1714
+
1715
+ // The second pass (parked lanes allowed) still skips the retired one.
1716
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1717
+ assert_eq!(server.lane_depths(), Some(vec![0, 1]));
1718
+ }
1719
+
1720
+ #[test]
1721
+ fn dispatch_keeps_retrying_when_the_other_lanes_are_closed() {
1722
+ let server = test_server(true, 4);
1723
+ server.register_worker();
1724
+ server.register_worker();
1725
+ server.slots.read()[0].lane_tx.lock().take(); // a crashed worker's lane
1726
+ server.slots.read()[1].retire();
1727
+
1728
+ assert!(matches!(
1729
+ try_dispatch(&server, test_ctx()),
1730
+ Dispatch::Full(_)
1731
+ ));
1732
+ }
1733
+
1734
+ #[test]
1735
+ fn a_reset_lane_receives_dispatches_again() {
1736
+ let server = test_server(true, 4);
1737
+ server.register_worker();
1738
+ server.register_worker();
1739
+ server.slots.read()[0].retire();
1740
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1741
+ assert_eq!(server.lane_depths(), Some(vec![0, 1]));
1742
+
1743
+ server.slots.read()[0].reset();
1744
+ for _ in 0..2 {
1745
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1746
+ }
1747
+ // Back in the round-robin.
1748
+ assert_eq!(server.lane_depths(), Some(vec![1, 2]));
1749
+ }
1750
+
1751
+ #[test]
1752
+ fn nothing_lands_in_a_lane_after_its_retirement_and_final_drain() {
1753
+ let server = test_server(true, 4);
1754
+ server.register_worker();
1755
+ server.register_worker();
1756
+ let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1757
+ let dispatcher = std::thread::spawn({
1758
+ let server = server.clone();
1759
+ let stop = stop.clone();
1760
+ move || {
1761
+ while !stop.load(Ordering::Relaxed) {
1762
+ let _ = try_dispatch(&server, test_ctx());
1763
+ // Lane 1 keeps draining, so dispatch never stalls on Full.
1764
+ let slots = server.slots.read();
1765
+ if let Some(rx) = slots[1].lane_rx.as_ref() {
1766
+ while rx.try_recv().is_ok() {}
1767
+ }
1768
+ }
1769
+ }
1770
+ });
1771
+ std::thread::sleep(Duration::from_millis(20));
1772
+
1773
+ // The scaler retires slot 0; its worker then drains what it holds.
1774
+ let slot = server.slots.read()[0].clone();
1775
+ slot.retire();
1776
+ if let Some(rx) = slot.lane_rx.as_ref() {
1777
+ while rx.try_recv().is_ok() {}
1778
+ }
1779
+
1780
+ std::thread::sleep(Duration::from_millis(50));
1781
+ stop.store(true, Ordering::Relaxed);
1782
+ dispatcher.join().expect("dispatcher thread");
1783
+ assert_eq!(server.lane_depths(), Some(vec![0, 0]));
1784
+ }
1569
1785
  }
@@ -92,3 +92,47 @@ pub fn close(ruby: &magnus::Ruby, id: u64) -> Result<(), magnus::Error> {
92
92
  chan.tx.lock().take();
93
93
  Ok(())
94
94
  }
95
+
96
+ /// Build the cache-backed slice of a Rack env directly, the way `build_env`
97
+ /// does for a real request: REMOTE_ADDR from `ip`, SERVER_NAME/SERVER_PORT
98
+ /// from `host` (the Host-header path) or, when `authority` is given, from
99
+ /// it (the :authority path, which also sets HTTP_HOST), plus one interned
100
+ /// header value. The rooting stress spec calls this from several ractors
101
+ /// at once with more distinct inputs than the LRU caches hold.
102
+ pub fn env_probe(
103
+ ruby: &magnus::Ruby,
104
+ host: String,
105
+ authority: Option<String>,
106
+ ip: String,
107
+ user_agent: String,
108
+ ) -> Result<magnus::RHash, magnus::Error> {
109
+ use crate::env_strings;
110
+ use crate::request::split_host_port;
111
+
112
+ let ip: std::net::IpAddr = ip.parse().map_err(|e| {
113
+ magnus::Error::new(ruby.exception_arg_error(), format!("bad ip {ip:?}: {e}"))
114
+ })?;
115
+ let env = ruby.hash_new();
116
+ env_strings::set_addr_env(ruby, env, ip)?;
117
+ match &authority {
118
+ Some(authority) => {
119
+ env_strings::set_authority_env(ruby, env, authority, || split_host_port(authority, 80))?
120
+ }
121
+ None => {
122
+ env_strings::set_host_env(ruby, env, host.as_bytes(), || split_host_port(&host, 80))?
123
+ }
124
+ }
125
+ let strings = env_strings::get();
126
+ let key = strings
127
+ .header_names
128
+ .get("user-agent")
129
+ .expect("user-agent is a common header");
130
+ env_strings::set_value_env(
131
+ ruby,
132
+ env,
133
+ ruby.get_inner(*key),
134
+ "user-agent",
135
+ user_agent.as_bytes(),
136
+ )?;
137
+ Ok(env)
138
+ }
data/lib/kino/cli.rb CHANGED
@@ -127,7 +127,7 @@ module Kino
127
127
  stats = server.stats
128
128
  puts dim("- ruby: #{RUBY_DESCRIPTION}")
129
129
  puts dim("- env: #{ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"}")
130
- puts dim("- mode: #{server.mode}, #{count(stats[:workers], "worker")} × #{count(stats[:threads], "thread")}")
130
+ puts dim("- mode: #{server.mode}, #{workers_label(stats)} × #{count(stats[:threads], "thread")}")
131
131
  puts dim("- pid: #{Process.pid}")
132
132
  puts dim("- listening: #{server.url}")
133
133
  puts dim("- control: #{server.control_url}") if server.control_url
@@ -140,6 +140,14 @@ module Kino
140
140
  "#{number} #{noun}#{"s" unless number == 1}"
141
141
  end
142
142
 
143
+ # The pool as configured: "8 workers", or "8-32 workers" when it can
144
+ # grow.
145
+ def workers_label(stats)
146
+ return count(stats[:workers], "worker") if stats[:max_workers] == stats[:workers]
147
+
148
+ "#{stats[:workers]}-#{stats[:max_workers]} workers"
149
+ end
150
+
143
151
  # Roll credits when the process ends: normal exit or crash (at_exit
144
152
  # also runs after an uncaught exception; only a force-exit skips it).
145
153
  # @return [void]
@@ -234,7 +242,7 @@ module Kino
234
242
  end
235
243
 
236
244
  def write_sample(path)
237
- require "kino"
245
+ require "kino" # audition:disable runtime-require
238
246
  Configuration.write_sample(path)
239
247
  puts "Kino: wrote sample config to #{path}"
240
248
  0
@@ -245,8 +253,8 @@ module Kino
245
253
 
246
254
  # Resolve the full configuration once: file + CLI flag overrides.
247
255
  def resolve_config(options)
248
- require "kino"
249
- require "rack"
256
+ require "kino" # audition:disable runtime-require
257
+ require "rack" # audition:disable runtime-require
250
258
 
251
259
  config_file = options[:config_file] || Configuration.default_path
252
260
 
@@ -10,6 +10,8 @@ module Kino
10
10
  bind: "127.0.0.1",
11
11
  port: 0,
12
12
  workers: nil, # resolved to Kino.available_parallelism in #to_h
13
+ max_workers: nil, # nil = fixed pool; above workers = elastic pool
14
+ scale_down_after: nil, # resolved to 30 seconds in Server
13
15
  threads: nil, # resolved per mode in Server: 1 in :ractor, 3 in :threaded
14
16
  mode: :auto,
15
17
  queue_depth: 1024,
@@ -44,7 +46,7 @@ module Kino
44
46
  SETTINGS = DEFAULTS.keys.freeze
45
47
 
46
48
  # Source template for {.sample}.
47
- SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__)
49
+ SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__).freeze
48
50
 
49
51
  # Where the `kino` CLI and the Rack handler look for a config file when
50
52
  # none is named: the project root first, then the Rails-style config/.
@@ -146,6 +148,8 @@ module Kino
146
148
  # bind "0.0.0.0"
147
149
  # port 9292
148
150
  # workers 8 # ractors (or thread groups in :threaded mode)
151
+ # max_workers 32 # elastic pool ceiling; unset = fixed pool
152
+ # scale_down_after 30 # seconds idle before an extra worker retires
149
153
  # threads 3 # threads per worker
150
154
  # mode :ractor # :auto | :ractor | :threaded
151
155
  # queue_depth 2048
@@ -172,9 +176,19 @@ module Kino
172
176
  # Port to listen on; 0 picks an ephemeral port.
173
177
  def port(port) = @config.set(:port, Integer(port))
174
178
 
175
- # Worker count (ractors in :ractor mode); defaults to CPU cores.
179
+ # Worker count (ractors in :ractor mode); defaults to CPU cores. The
180
+ # pool floor when max_workers is set.
176
181
  def workers(count) = @config.set(:workers, Integer(count))
177
182
 
183
+ # Pool ceiling: under sustained queue pressure the pool grows past
184
+ # `workers`, one worker at a time, up to this many. Unset (the
185
+ # default) keeps the pool fixed at `workers`.
186
+ def max_workers(count) = @config.set(:max_workers, Integer(count))
187
+
188
+ # Seconds a worker above the floor must sit idle before it is
189
+ # retired (default 30). Only meaningful with max_workers.
190
+ def scale_down_after(seconds) = @config.set(:scale_down_after, seconds)
191
+
178
192
  # Threads per worker (I/O concurrency inside one ractor); default is
179
193
  # mode-dependent: 1 in :ractor mode, 3 in :threaded.
180
194
  def threads(count) = @config.set(:threads, Integer(count))
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # A thread on the main ractor that calls `scan` every `tick` seconds
6
+ # until stopped. Main-ractor so it stays responsive when worker ractors
7
+ # are wedged. A scan that raises is logged and skipped, never fatal:
8
+ # monitors keep the server healthy, they must not take it down.
9
+ class Monitor
10
+ def initialize(name:, tick:)
11
+ @name = name
12
+ @tick = tick
13
+ @running = false
14
+ @thread = nil
15
+ end
16
+
17
+ def start
18
+ @running = true
19
+ @thread = Thread.new do
20
+ Thread.current.name = @name
21
+ run
22
+ end
23
+ self
24
+ end
25
+
26
+ def stop
27
+ @running = false
28
+ @thread&.join(@tick * 2)
29
+ end
30
+
31
+ private
32
+
33
+ def run
34
+ tick while @running
35
+ rescue => e
36
+ Log.error("#{@name} crashed: #{e.class}: #{e.message}")
37
+ end
38
+
39
+ def tick
40
+ scan
41
+ rescue => e
42
+ Log.error("#{@name} tick error: #{e.class}: #{e.message}")
43
+ ensure
44
+ sleep @tick
45
+ end
46
+
47
+ # One poll; subclasses define it.
48
+ def scan
49
+ raise NotImplementedError, "#{self.class} must define scan"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # Grows and shrinks the worker pool between `floor` and `ceiling`. One
6
+ # thread on the main ractor polls the native queue and per-slot sensors
7
+ # every tick and drives a pool (RactorSupervisor or ThreadedPool) through
8
+ # three methods: `active_count`, `groups` (worker index => slot ids for
9
+ # every worker that may be retired), `grow`, and `retire(index)`.
10
+ #
11
+ # Policy: grow by one worker per tick once requests have been waiting in
12
+ # the queue on two consecutive ticks (a burst that clears within a tick
13
+ # is not pressure); retire one worker per tick, the one idle longest,
14
+ # once it has been idle for `scale_down_after`. "Idle" means no slot of
15
+ # the worker held a request at two consecutive samples and its served
16
+ # count did not move between them, so a worker serving short requests
17
+ # between samples is never mistaken for an idle one.
18
+ class PoolScaler < Monitor
19
+ TICK = 0.1
20
+ # Consecutive ticks with a non-empty queue before the pool grows.
21
+ PRESSURE_TICKS = 2
22
+
23
+ def initialize(server_id:, pool:, floor:, ceiling:, scale_down_after:, tick: TICK)
24
+ super(name: "pool scaler", tick: tick)
25
+ @server_id = server_id
26
+ @pool = pool
27
+ @floor = floor
28
+ @ceiling = ceiling
29
+ @scale_down_after = scale_down_after
30
+ @pressure = 0
31
+ @last_served = {}
32
+ @idle_since = {}
33
+ end
34
+
35
+ # One policy step over one set of observations: the monotonic time,
36
+ # the queue depth, and worker_stats rows ([slot, served, in_flight,
37
+ # busy_ms, quarantined, retired]). Public so the policy is testable
38
+ # without a server.
39
+ def step(now, queued, rows)
40
+ @pressure = queued.positive? ? @pressure + 1 : 0
41
+ if @pressure >= PRESSURE_TICKS
42
+ grow if @pool.active_count < @ceiling
43
+ return
44
+ end
45
+
46
+ observe_idle(now, rows)
47
+ return unless @pool.active_count > @floor
48
+
49
+ index, since = @idle_since.min_by { |_index, at| at }
50
+ return unless index && now - since >= @scale_down_after
51
+
52
+ retire(index)
53
+ end
54
+
55
+ private
56
+
57
+ def scan
58
+ queued, _in_flight = Native.queue_stats(@server_id)
59
+ step(Process.clock_gettime(Process::CLOCK_MONOTONIC), queued, Native.worker_stats(@server_id))
60
+ end
61
+
62
+ def grow
63
+ index = @pool.grow
64
+ return unless index
65
+
66
+ Native.record_scale_up(@server_id)
67
+ Log.info("pool grew to #{@pool.active_count} workers (queue pressure)")
68
+ end
69
+
70
+ def retire(index)
71
+ return unless @pool.retire(index)
72
+
73
+ forget(index)
74
+ Native.record_scale_down(@server_id)
75
+ Log.info("pool shrank to #{@pool.active_count} workers (worker-#{index} idle)")
76
+ end
77
+
78
+ # Per worker: idle when no slot holds a request at this sample and the
79
+ # served total did not move since the previous one. First sighting of
80
+ # a worker is never idle (it takes two samples to know).
81
+ def observe_idle(now, rows)
82
+ by_slot = rows.to_h { |row| [row[0], row] }
83
+ groups = @pool.groups
84
+ (@idle_since.keys - groups.keys).each { |index| forget(index) }
85
+ groups.each do |index, slot_ids|
86
+ served = slot_ids.sum { |id| by_slot.dig(id, 1) || 0 }
87
+ busy = slot_ids.any? { |id| (by_slot.dig(id, 2) || 0).positive? }
88
+ idle = !busy && @last_served[index] == served
89
+ @last_served[index] = served
90
+ if idle
91
+ @idle_since[index] ||= now
92
+ else
93
+ @idle_since.delete(index)
94
+ end
95
+ end
96
+ end
97
+
98
+ def forget(index)
99
+ @idle_since.delete(index)
100
+ @last_served.delete(index)
101
+ end
102
+ end
103
+ end
@@ -3,54 +3,22 @@
3
3
  module Kino
4
4
  # @private
5
5
  # Polls per-slot busy_ms and, past the timeout, quarantines a wedged slot
6
- # and asks the replacer to spawn a fresh worker. Runs one thread on the
7
- # main ractor (uncontended by wedged worker ractors, so it stays
8
- # responsive in :ractor mode). Never interrupts the wedged worker.
9
- class QuarantineMonitor
6
+ # and asks the replacer to spawn a fresh worker. Never interrupts the
7
+ # wedged worker.
8
+ class QuarantineMonitor < Monitor
10
9
  def initialize(server_id:, timeout_ms:, max:, replacer:, tick: 0.5)
10
+ super(name: "quarantine monitor", tick: tick)
11
11
  @server_id = server_id
12
12
  @timeout_ms = timeout_ms
13
13
  @max = max
14
14
  @replacer = replacer
15
- @tick = tick
16
15
  @outstanding = 0
17
16
  @at_cap_logged = false
18
- @running = false
19
- @thread = nil
20
- end
21
-
22
- def start
23
- @running = true
24
- @thread = Thread.new do
25
- Thread.current.name = "quarantine"
26
- run
27
- end
28
- self
29
- end
30
-
31
- def stop
32
- @running = false
33
- @thread&.join(@tick * 2)
34
17
  end
35
18
 
36
19
  private
37
20
 
38
- def run
39
- tick while @running
40
- rescue => e
41
- Log.error("quarantine monitor crashed: #{e.class}: #{e.message}")
42
- end
43
-
44
- def tick
45
- scan_slots
46
- rescue => e
47
- # A bad tick must never kill the monitor.
48
- Log.error("quarantine tick error: #{e.class}: #{e.message}")
49
- ensure
50
- sleep @tick
51
- end
52
-
53
- def scan_slots
21
+ def scan
54
22
  Native.worker_stats(@server_id).each do |index, _served, _in_flight, busy_ms, quarantined|
55
23
  next if quarantined || busy_ms <= @timeout_ms
56
24