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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/Cargo.lock +64 -71
- data/README.md +52 -0
- data/doc/architecture.md +10 -0
- data/ext/kino/Cargo.toml +2 -2
- data/ext/kino/src/control.rs +103 -6
- data/ext/kino/src/env_strings.rs +267 -124
- data/ext/kino/src/lib.rs +11 -0
- data/ext/kino/src/pin.rs +66 -15
- data/ext/kino/src/queue.rs +99 -3
- data/ext/kino/src/registry.rs +142 -1
- data/ext/kino/src/request.rs +2 -2
- data/ext/kino/src/server.rs +220 -4
- 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 +6 -2
data/ext/kino/src/server.rs
CHANGED
|
@@ -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
|
|
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: ®istry::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 =
|
|
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|
|
|
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}, #{
|
|
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
|
|
data/lib/kino/configuration.rb
CHANGED
|
@@ -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))
|
data/lib/kino/monitor.rb
ADDED
|
@@ -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.
|
|
7
|
-
#
|
|
8
|
-
|
|
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
|
|
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
|
|