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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/Cargo.lock +142 -139
- data/README.md +84 -3
- data/ext/kino/Cargo.toml +3 -3
- data/ext/kino/src/control.rs +718 -0
- data/ext/kino/src/lib.rs +12 -0
- data/ext/kino/src/mono.rs +26 -0
- data/ext/kino/src/queue.rs +7 -0
- data/ext/kino/src/registry.rs +155 -0
- data/ext/kino/src/request.rs +4 -0
- data/ext/kino/src/server.rs +116 -6
- data/lib/kino/cli.rb +5 -2
- data/lib/kino/configuration.rb +57 -0
- data/lib/kino/hook_fire.rb +23 -0
- data/lib/kino/quarantine_monitor.rb +70 -0
- data/lib/kino/ractor_supervisor.rb +59 -14
- data/lib/kino/server.rb +143 -21
- data/lib/kino/templates/kino.rb.tt +51 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +36 -19
- data/lib/kino/worker_hooks.rb +11 -0
- data/lib/kino.rb +3 -0
- data/sig/kino.rbs +9 -0
- metadata +7 -2
data/ext/kino/src/lib.rs
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
#[global_allocator]
|
|
5
5
|
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
|
6
6
|
|
|
7
|
+
mod control;
|
|
7
8
|
mod env_strings;
|
|
8
9
|
mod gvl;
|
|
9
10
|
mod logsink;
|
|
11
|
+
mod mono;
|
|
10
12
|
mod pin;
|
|
11
13
|
mod queue;
|
|
12
14
|
mod registry;
|
|
@@ -40,6 +42,16 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
40
42
|
native.define_singleton_method("close_queue", function!(server::close_queue, 1))?;
|
|
41
43
|
native.define_singleton_method("queue_stats", function!(server::queue_stats, 1))?;
|
|
42
44
|
native.define_singleton_method("server_stats", function!(server::server_stats, 1))?;
|
|
45
|
+
native.define_singleton_method("worker_stats", function!(server::worker_stats, 1))?;
|
|
46
|
+
native.define_singleton_method("queue_time", function!(server::queue_time, 1))?;
|
|
47
|
+
native.define_singleton_method("quarantine_slot", function!(server::quarantine_slot, 2))?;
|
|
48
|
+
native.define_singleton_method(
|
|
49
|
+
"record_quarantine_replacement",
|
|
50
|
+
function!(server::record_quarantine_replacement, 1),
|
|
51
|
+
)?;
|
|
52
|
+
native.define_singleton_method("control_ready", function!(server::control_ready, 1))?;
|
|
53
|
+
native.define_singleton_method("record_respawn", function!(server::record_respawn, 1))?;
|
|
54
|
+
native.define_singleton_method("control_stop", function!(control::control_stop, 1))?;
|
|
43
55
|
native.define_singleton_method("abort_inflight", function!(server::abort_inflight, 2))?;
|
|
44
56
|
native.define_singleton_method(
|
|
45
57
|
"abort_all_inflight",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//! Process-monotonic millisecond clock, shared by the request hot path
|
|
2
|
+
//! (recording) and the control thread (reading busy age). Independent of
|
|
3
|
+
//! wall-clock and of Ruby, so it is identical in :ractor and :threaded.
|
|
4
|
+
|
|
5
|
+
use std::sync::OnceLock;
|
|
6
|
+
use std::time::Instant;
|
|
7
|
+
|
|
8
|
+
static MONO_EPOCH: OnceLock<Instant> = OnceLock::new();
|
|
9
|
+
|
|
10
|
+
/// Milliseconds since the first call anywhere in the process. The u128
|
|
11
|
+
/// millis fit u64 for any realistic uptime (u64 ms is ~584 million years).
|
|
12
|
+
pub fn mono_ms() -> u64 {
|
|
13
|
+
MONO_EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#[cfg(test)]
|
|
17
|
+
mod tests {
|
|
18
|
+
use super::*;
|
|
19
|
+
|
|
20
|
+
#[test]
|
|
21
|
+
fn mono_ms_is_monotonic_nondecreasing() {
|
|
22
|
+
let a = mono_ms();
|
|
23
|
+
let b = mono_ms();
|
|
24
|
+
assert!(b >= a, "clock went backwards: {a} then {b}");
|
|
25
|
+
}
|
|
26
|
+
}
|
data/ext/kino/src/queue.rs
CHANGED
|
@@ -116,6 +116,12 @@ fn admit(
|
|
|
116
116
|
mut ctx: BoxedCtx,
|
|
117
117
|
) -> Result<RHash, Error> {
|
|
118
118
|
server.served.fetch_add(1, Ordering::Relaxed);
|
|
119
|
+
slot.served.fetch_add(1, Ordering::Relaxed);
|
|
120
|
+
slot.last_started_ms.store(crate::mono::mono_ms(), Ordering::Relaxed);
|
|
121
|
+
slot.in_flight.fetch_add(1, Ordering::Relaxed);
|
|
122
|
+
server
|
|
123
|
+
.queue_histogram
|
|
124
|
+
.record(ctx.enqueued_at.elapsed().as_micros() as u64);
|
|
119
125
|
slot.current.lock().push(Arc::downgrade(&ctx.responder));
|
|
120
126
|
// Wire the slot into the request so blocked body reads/writes are
|
|
121
127
|
// interruptible the same way the queue pop is.
|
|
@@ -137,6 +143,7 @@ fn checkout(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<Chec
|
|
|
137
143
|
|
|
138
144
|
// The previous batch is fully answered once the worker comes back.
|
|
139
145
|
slot.current.lock().clear();
|
|
146
|
+
slot.in_flight.store(0, Ordering::Relaxed);
|
|
140
147
|
slot.interrupted.store(false, Ordering::SeqCst);
|
|
141
148
|
|
|
142
149
|
Ok(block_take(&server, &slot)?.map(|ctx| (server, slot, ctx)))
|
data/ext/kino/src/registry.rs
CHANGED
|
@@ -11,6 +11,20 @@ use parking_lot::{Mutex, RwLock};
|
|
|
11
11
|
use crate::request::RequestCtx;
|
|
12
12
|
use crate::response::Responder;
|
|
13
13
|
|
|
14
|
+
/// Lifecycle as seen by the control plane's /ready.
|
|
15
|
+
pub const STATE_BOOTING: u8 = 0;
|
|
16
|
+
pub const STATE_READY: u8 = 1;
|
|
17
|
+
pub const STATE_DRAINING: u8 = 2;
|
|
18
|
+
|
|
19
|
+
/// Boot-time configuration echoed by /stats. Stored resolved: mode is
|
|
20
|
+
/// "ractor" or "threaded", never "auto".
|
|
21
|
+
pub struct Topology {
|
|
22
|
+
pub mode: String,
|
|
23
|
+
pub workers: usize,
|
|
24
|
+
pub threads: usize,
|
|
25
|
+
pub batch: usize,
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
/// Requests travel through channels boxed: one heap allocation at accept
|
|
15
29
|
/// time instead of moving ~300 bytes by value through every channel hop.
|
|
16
30
|
pub type BoxedCtx = Box<RequestCtx>;
|
|
@@ -45,6 +59,15 @@ pub struct ServerInner {
|
|
|
45
59
|
/// 413 (truthful Content-Length) or a mid-stream abort (chunked/lying).
|
|
46
60
|
pub max_body_size: usize,
|
|
47
61
|
pub timeouts: AtomicU64,
|
|
62
|
+
/// Lifecycle for /ready: booting until Ruby reports the workers up,
|
|
63
|
+
/// draining once stop_accepting runs. Relaxed everywhere (advisory).
|
|
64
|
+
pub state: std::sync::atomic::AtomicU8,
|
|
65
|
+
/// Worker respawns, recorded from the Ruby supervisor. Lives here so
|
|
66
|
+
/// the control plane reads it without touching Ruby.
|
|
67
|
+
pub respawns: AtomicU64,
|
|
68
|
+
/// Replacements spawned by the quarantine monitor (Relaxed, advisory).
|
|
69
|
+
pub quarantine_replacements: AtomicU64,
|
|
70
|
+
pub topology: Topology,
|
|
48
71
|
pub https: bool,
|
|
49
72
|
/// Native access log sink (None unless log_requests is on).
|
|
50
73
|
pub access_log: Option<crate::logsink::Sink>,
|
|
@@ -55,6 +78,9 @@ pub struct ServerInner {
|
|
|
55
78
|
/// GC roots for zero-copy response buffers (pin.rs). The Ruby Server
|
|
56
79
|
/// object holds the marking PinKeeper for this slab.
|
|
57
80
|
pub pin_slab: Arc<crate::pin::PinSlab>,
|
|
81
|
+
/// Queue-wait histogram: recorded at admit (queue.rs), emitted by the
|
|
82
|
+
/// control plane.
|
|
83
|
+
pub queue_histogram: QueueHistogram,
|
|
58
84
|
}
|
|
59
85
|
|
|
60
86
|
/// One per worker *thread* (slot count = workers × threads). The interrupt
|
|
@@ -72,12 +98,90 @@ pub struct WorkerSlot {
|
|
|
72
98
|
pub lane_tx: Mutex<Option<flume::Sender<BoxedCtx>>>,
|
|
73
99
|
pub lane_rx: Option<flume::Receiver<BoxedCtx>>,
|
|
74
100
|
pub parked: std::sync::atomic::AtomicBool,
|
|
101
|
+
/// Per-slot sensors (Relaxed, advisory). served/in_flight mirror the
|
|
102
|
+
/// global counters at slot granularity; last_started_ms (stamped on
|
|
103
|
+
/// admit) drives busy-age (wedge) reporting.
|
|
104
|
+
pub served: AtomicU64,
|
|
105
|
+
pub in_flight: AtomicUsize,
|
|
106
|
+
pub last_started_ms: AtomicU64,
|
|
107
|
+
/// Set by the quarantine monitor when this slot is abandoned as wedged:
|
|
108
|
+
/// excluded from wedge detection, and its busy_ms is reported as 0.
|
|
109
|
+
pub quarantined: std::sync::atomic::AtomicBool,
|
|
75
110
|
}
|
|
76
111
|
|
|
77
112
|
/// Per-lane depth cap: small, so a slow handler can only ever delay this
|
|
78
113
|
/// many queued neighbors (work stealing rescues them anyway).
|
|
79
114
|
pub const LANE_DEPTH: usize = 4;
|
|
80
115
|
|
|
116
|
+
/// Fixed queue-wait bucket boundaries in microseconds (0.5ms .. 10s),
|
|
117
|
+
/// ascending. Emitted in seconds. Not a knob (YAGNI).
|
|
118
|
+
pub const QUEUE_BOUNDS_US: [u64; 14] = [
|
|
119
|
+
500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000,
|
|
120
|
+
250_000, 500_000, 1_000_000, 2_500_000, 5_000_000, 10_000_000,
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/// Queue-wait histogram: per-bucket counts plus an overflow (the implicit
|
|
124
|
+
/// +Inf bucket), the sum of waits, and the total count. Relaxed atomics,
|
|
125
|
+
/// advisory like the other counters.
|
|
126
|
+
pub struct QueueHistogram {
|
|
127
|
+
pub buckets: [AtomicU64; QUEUE_BOUNDS_US.len()],
|
|
128
|
+
pub overflow: AtomicU64,
|
|
129
|
+
pub sum_us: AtomicU64,
|
|
130
|
+
pub count: AtomicU64,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// A plain (non-atomic) snapshot for the control thread to emit.
|
|
134
|
+
pub struct QueueHistogramSnapshot {
|
|
135
|
+
pub buckets: [u64; QUEUE_BOUNDS_US.len()],
|
|
136
|
+
pub overflow: u64,
|
|
137
|
+
pub sum_us: u64,
|
|
138
|
+
pub count: u64,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
impl QueueHistogram {
|
|
142
|
+
pub fn new() -> Self {
|
|
143
|
+
QueueHistogram {
|
|
144
|
+
buckets: std::array::from_fn(|_| AtomicU64::new(0)),
|
|
145
|
+
overflow: AtomicU64::new(0),
|
|
146
|
+
sum_us: AtomicU64::new(0),
|
|
147
|
+
count: AtomicU64::new(0),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Place one wait in its bucket (first bound >= wait, else overflow) and
|
|
152
|
+
/// update sum and count. A linear scan over 14 bounds is trivial.
|
|
153
|
+
pub fn record(&self, wait_us: u64) {
|
|
154
|
+
match QUEUE_BOUNDS_US.iter().position(|&bound| wait_us <= bound) {
|
|
155
|
+
Some(i) => self.buckets[i].fetch_add(1, Ordering::Relaxed),
|
|
156
|
+
None => self.overflow.fetch_add(1, Ordering::Relaxed),
|
|
157
|
+
};
|
|
158
|
+
self.sum_us.fetch_add(wait_us, Ordering::Relaxed);
|
|
159
|
+
self.count.fetch_add(1, Ordering::Relaxed);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
pub fn snapshot(&self) -> QueueHistogramSnapshot {
|
|
163
|
+
QueueHistogramSnapshot {
|
|
164
|
+
buckets: std::array::from_fn(|i| self.buckets[i].load(Ordering::Relaxed)),
|
|
165
|
+
overflow: self.overflow.load(Ordering::Relaxed),
|
|
166
|
+
sum_us: self.sum_us.load(Ordering::Relaxed),
|
|
167
|
+
count: self.count.load(Ordering::Relaxed),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
impl Default for QueueHistogram {
|
|
173
|
+
fn default() -> Self {
|
|
174
|
+
Self::new()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
impl QueueHistogramSnapshot {
|
|
179
|
+
/// Summed queue wait, converted from the stored microseconds to seconds.
|
|
180
|
+
pub fn sum_seconds(&self) -> f64 {
|
|
181
|
+
self.sum_us as f64 / 1_000_000.0
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
81
185
|
impl WorkerSlot {
|
|
82
186
|
fn new(lanes: bool) -> Self {
|
|
83
187
|
let (lane_tx, lane_rx) = if lanes {
|
|
@@ -92,6 +196,10 @@ impl WorkerSlot {
|
|
|
92
196
|
lane_tx: Mutex::new(lane_tx),
|
|
93
197
|
lane_rx,
|
|
94
198
|
parked: std::sync::atomic::AtomicBool::new(false),
|
|
199
|
+
served: AtomicU64::new(0),
|
|
200
|
+
in_flight: AtomicUsize::new(0),
|
|
201
|
+
last_started_ms: AtomicU64::new(0),
|
|
202
|
+
quarantined: std::sync::atomic::AtomicBool::new(false),
|
|
95
203
|
}
|
|
96
204
|
}
|
|
97
205
|
}
|
|
@@ -188,11 +296,16 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
|
|
|
188
296
|
request_timeout_ms: 0,
|
|
189
297
|
max_body_size: 0,
|
|
190
298
|
timeouts: AtomicU64::new(0),
|
|
299
|
+
state: std::sync::atomic::AtomicU8::new(STATE_BOOTING),
|
|
300
|
+
respawns: AtomicU64::new(0),
|
|
301
|
+
quarantine_replacements: AtomicU64::new(0),
|
|
302
|
+
topology: Topology { mode: "threaded".to_string(), workers: 0, threads: 0, batch: 1 },
|
|
191
303
|
https: false,
|
|
192
304
|
access_log: None,
|
|
193
305
|
lanes,
|
|
194
306
|
lane_cursor: AtomicUsize::new(0),
|
|
195
307
|
pin_slab: Arc::new(crate::pin::PinSlab::new()),
|
|
308
|
+
queue_histogram: QueueHistogram::new(),
|
|
196
309
|
})
|
|
197
310
|
}
|
|
198
311
|
|
|
@@ -273,4 +386,46 @@ mod tests {
|
|
|
273
386
|
let b = next_server_id();
|
|
274
387
|
assert_ne!(a, b);
|
|
275
388
|
}
|
|
389
|
+
|
|
390
|
+
#[test]
|
|
391
|
+
fn servers_boot_in_the_booting_state_with_zero_respawns() {
|
|
392
|
+
let server = test_server(false, 4);
|
|
393
|
+
assert_eq!(server.state.load(Ordering::Relaxed), STATE_BOOTING);
|
|
394
|
+
assert_eq!(server.respawns.load(Ordering::Relaxed), 0);
|
|
395
|
+
assert_eq!(server.topology.batch, 1);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#[test]
|
|
399
|
+
fn fresh_slot_has_zeroed_per_worker_sensors() {
|
|
400
|
+
let server = test_server(false, 4);
|
|
401
|
+
server.register_worker();
|
|
402
|
+
let slots = server.slots.read();
|
|
403
|
+
let slot = &slots[0];
|
|
404
|
+
assert_eq!(slot.served.load(Ordering::Relaxed), 0);
|
|
405
|
+
assert_eq!(slot.in_flight.load(Ordering::Relaxed), 0);
|
|
406
|
+
assert_eq!(slot.last_started_ms.load(Ordering::Relaxed), 0);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#[test]
|
|
410
|
+
fn fresh_slot_is_not_quarantined() {
|
|
411
|
+
let server = test_server(false, 4);
|
|
412
|
+
server.register_worker();
|
|
413
|
+
assert!(!server.slots.read()[0].quarantined.load(Ordering::Relaxed));
|
|
414
|
+
assert_eq!(server.quarantine_replacements.load(Ordering::Relaxed), 0);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#[test]
|
|
418
|
+
fn queue_histogram_buckets_by_wait() {
|
|
419
|
+
let h = QueueHistogram::new();
|
|
420
|
+
h.record(400); // <= 500 -> bucket 0
|
|
421
|
+
h.record(500); // == 500 -> bucket 0 (inclusive)
|
|
422
|
+
h.record(600); // (500, 1000] -> bucket 1
|
|
423
|
+
h.record(20_000_000); // > last bound -> overflow
|
|
424
|
+
let s = h.snapshot();
|
|
425
|
+
assert_eq!(s.buckets[0], 2);
|
|
426
|
+
assert_eq!(s.buckets[1], 1);
|
|
427
|
+
assert_eq!(s.overflow, 1);
|
|
428
|
+
assert_eq!(s.count, 4);
|
|
429
|
+
assert_eq!(s.sum_us, 400 + 500 + 600 + 20_000_000);
|
|
430
|
+
}
|
|
276
431
|
}
|
data/ext/kino/src/request.rs
CHANGED
|
@@ -41,6 +41,9 @@ pub struct RequestCtx {
|
|
|
41
41
|
/// here and ride to hyper without a copy (pin.rs).
|
|
42
42
|
pub pin_slab: Arc<crate::pin::PinSlab>,
|
|
43
43
|
pub responder: Arc<Responder>,
|
|
44
|
+
/// When this request entered the queue, for the queue-wait histogram.
|
|
45
|
+
/// Stamped at ctx creation; read once at admit (queue.rs).
|
|
46
|
+
pub enqueued_at: std::time::Instant,
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
impl Drop for RequestCtx {
|
|
@@ -425,6 +428,7 @@ pub fn test_ctx() -> crate::registry::BoxedCtx {
|
|
|
425
428
|
slot: None,
|
|
426
429
|
pin_slab: Arc::new(crate::pin::PinSlab::new()),
|
|
427
430
|
responder: Arc::new(Responder::new(head_tx)),
|
|
431
|
+
enqueued_at: std::time::Instant::now(),
|
|
428
432
|
})
|
|
429
433
|
}
|
|
430
434
|
|
data/ext/kino/src/server.rs
CHANGED
|
@@ -44,8 +44,10 @@ fn cfg_opt<T: magnus::TryConvert>(
|
|
|
44
44
|
/// at boot, so Hash-lookup cost is irrelevant and the interface stays
|
|
45
45
|
/// extensible. Binding is synchronous so address errors raise in Ruby at
|
|
46
46
|
/// `start` time; returns the actual port for `port: 0`. TLS config errors
|
|
47
|
-
/// (bad cert/key) also raise here, before any traffic.
|
|
48
|
-
|
|
47
|
+
/// (bad cert/key) also raise here, before any traffic. The third element
|
|
48
|
+
/// of the return tuple is the control-plane port (nil unless control_bind
|
|
49
|
+
/// is configured).
|
|
50
|
+
pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Option<u16>), Error> {
|
|
49
51
|
let bind: String = cfg(ruby, config, "bind")?;
|
|
50
52
|
let port: u16 = cfg(ruby, config, "port")?;
|
|
51
53
|
let queue_depth: usize = cfg(ruby, config, "queue_depth")?;
|
|
@@ -58,6 +60,10 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
|
|
|
58
60
|
let tls_key: Option<String> = cfg_opt(ruby, config, "tls_key")?;
|
|
59
61
|
let lanes: bool = cfg_opt(ruby, config, "lanes")?.unwrap_or(false);
|
|
60
62
|
let log_requests: bool = cfg_opt(ruby, config, "log_requests")?.unwrap_or(false);
|
|
63
|
+
let mode: String = cfg_opt(ruby, config, "mode")?.unwrap_or_else(|| "threaded".to_string());
|
|
64
|
+
let workers: usize = cfg_opt(ruby, config, "workers")?.unwrap_or(0);
|
|
65
|
+
let threads: usize = cfg_opt(ruby, config, "threads")?.unwrap_or(0);
|
|
66
|
+
let batch: usize = cfg_opt(ruby, config, "batch")?.unwrap_or(1);
|
|
61
67
|
let acceptor = match (&tls_cert, &tls_key) {
|
|
62
68
|
(Some(cert), Some(key)) => Some(
|
|
63
69
|
crate::tls::build_acceptor(cert, key)
|
|
@@ -82,6 +88,15 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
|
|
|
82
88
|
.map_err(|e| io_error(ruby, "listener setup failed", e))?
|
|
83
89
|
.port();
|
|
84
90
|
|
|
91
|
+
let control_bind_addr: Option<String> = cfg_opt(ruby, config, "control_bind")?;
|
|
92
|
+
let control_token: Option<String> = cfg_opt(ruby, config, "control_token")?;
|
|
93
|
+
let control_bind = control_bind_addr
|
|
94
|
+
.as_deref()
|
|
95
|
+
.map(|addr| {
|
|
96
|
+
crate::control::bind_control(addr).map_err(|e| io_error(ruby, "control bind failed", e))
|
|
97
|
+
})
|
|
98
|
+
.transpose()?;
|
|
99
|
+
|
|
85
100
|
let mut builder = tokio::runtime::Builder::new_multi_thread();
|
|
86
101
|
builder.enable_all().thread_name("kino-tokio");
|
|
87
102
|
if tokio_threads > 0 {
|
|
@@ -108,11 +123,16 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
|
|
|
108
123
|
request_timeout_ms,
|
|
109
124
|
max_body_size,
|
|
110
125
|
timeouts: std::sync::atomic::AtomicU64::new(0),
|
|
126
|
+
state: std::sync::atomic::AtomicU8::new(registry::STATE_BOOTING),
|
|
127
|
+
respawns: std::sync::atomic::AtomicU64::new(0),
|
|
128
|
+
quarantine_replacements: std::sync::atomic::AtomicU64::new(0),
|
|
129
|
+
topology: registry::Topology { mode, workers, threads, batch },
|
|
111
130
|
https: acceptor.is_some(),
|
|
112
131
|
access_log: log_requests.then(|| crate::logsink::Sink::new(std::io::stdout())),
|
|
113
132
|
lanes,
|
|
114
133
|
lane_cursor: std::sync::atomic::AtomicUsize::new(0),
|
|
115
134
|
pin_slab: Arc::new(crate::pin::PinSlab::new()),
|
|
135
|
+
queue_histogram: registry::QueueHistogram::new(),
|
|
116
136
|
});
|
|
117
137
|
|
|
118
138
|
let tokio_listener = {
|
|
@@ -130,8 +150,28 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
|
|
|
130
150
|
*server.runtime.lock() = Some(runtime);
|
|
131
151
|
|
|
132
152
|
let id = server.id;
|
|
153
|
+
let control_port = match control_bind {
|
|
154
|
+
// Not yet in the registry: on failure Ruby never learns this id, so
|
|
155
|
+
// nothing could ever reach it to shut it down. The accept loop's
|
|
156
|
+
// task holds its own Arc back to `server` (stored inside its own
|
|
157
|
+
// `runtime` field), so just dropping our handle would leak the
|
|
158
|
+
// runtime forever; take it out and stop it explicitly instead.
|
|
159
|
+
// Safe to block here: this is the plain Ruby thread, no async
|
|
160
|
+
// context above it.
|
|
161
|
+
Some(bind) => match crate::control::start(bind, server.clone(), control_token) {
|
|
162
|
+
Ok(port) => port,
|
|
163
|
+
Err(e) => {
|
|
164
|
+
// A plain drop blocks until the accept loop's task (its
|
|
165
|
+
// only task, idling on accept/shutdown) is torn down; the
|
|
166
|
+
// runtime only ever had this one thing to cancel.
|
|
167
|
+
drop(server.runtime.lock().take());
|
|
168
|
+
return Err(io_error(ruby, "control start failed", e));
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
None => None,
|
|
172
|
+
};
|
|
133
173
|
registry::insert(server);
|
|
134
|
-
Ok((id, local_port))
|
|
174
|
+
Ok((id, local_port, control_port))
|
|
135
175
|
}
|
|
136
176
|
|
|
137
177
|
/// Slowloris guard for TLS: a client that completes the TCP connect but then
|
|
@@ -353,6 +393,7 @@ async fn handle_request(
|
|
|
353
393
|
slot: None,
|
|
354
394
|
pin_slab: server.pin_slab.clone(),
|
|
355
395
|
responder,
|
|
396
|
+
enqueued_at: std::time::Instant::now(),
|
|
356
397
|
});
|
|
357
398
|
|
|
358
399
|
// Drop guard, not manual decrement: when a client aborts mid-request,
|
|
@@ -518,11 +559,28 @@ pub fn register_worker(ruby: &Ruby, server_id: u64) -> Result<usize, Error> {
|
|
|
518
559
|
|
|
519
560
|
pub fn stop_accepting(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
520
561
|
if let Some(server) = registry::try_get(server_id) {
|
|
562
|
+
server.state.store(registry::STATE_DRAINING, Ordering::Relaxed);
|
|
521
563
|
let _ = server.shutdown_tx.send(true);
|
|
522
564
|
}
|
|
523
565
|
Ok(())
|
|
524
566
|
}
|
|
525
567
|
|
|
568
|
+
/// Ruby reports the worker pool up; /ready starts answering 200.
|
|
569
|
+
pub fn control_ready(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
570
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
571
|
+
server.state.store(registry::STATE_READY, Ordering::Relaxed);
|
|
572
|
+
}
|
|
573
|
+
Ok(())
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/// One worker respawn, recorded by the Ruby supervisor.
|
|
577
|
+
pub fn record_respawn(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
578
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
579
|
+
server.respawns.fetch_add(1, Ordering::Relaxed);
|
|
580
|
+
}
|
|
581
|
+
Ok(())
|
|
582
|
+
}
|
|
583
|
+
|
|
526
584
|
pub fn close_queue(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
527
585
|
if let Some(server) = registry::try_get(server_id) {
|
|
528
586
|
server.req_tx.lock().take();
|
|
@@ -546,6 +604,12 @@ fn abort_slot(slot: &WorkerSlot) {
|
|
|
546
604
|
responder.respond_500_if_unsent();
|
|
547
605
|
}
|
|
548
606
|
}
|
|
607
|
+
// A dead worker holds nothing: every request it had is answered above
|
|
608
|
+
// (or already was), so the slot is quiescent from here on. Without
|
|
609
|
+
// this, a crashed worker's slot reports in_flight>=1 forever (the
|
|
610
|
+
// supervisor never reuses a slot after a crash), wedging /stats,
|
|
611
|
+
// /metrics and server.stats with a phantom busy worker.
|
|
612
|
+
slot.in_flight.store(0, Ordering::Relaxed);
|
|
549
613
|
// Lane mode: this worker is dead. Close its lane so the dispatcher
|
|
550
614
|
// skips it, and drain anything queued; dropping each ctx fires the
|
|
551
615
|
// Drop-500 backstop so those clients aren't left hanging.
|
|
@@ -608,14 +672,14 @@ pub fn log_error(message: String) {
|
|
|
608
672
|
}
|
|
609
673
|
|
|
610
674
|
/// Full stats snapshot: [queued, in_flight, served, rejected, timeouts,
|
|
611
|
-
/// lane_depths]. lane_depths is nil unless lane dispatch is on.
|
|
675
|
+
/// respawns, lane_depths]. lane_depths is nil unless lane dispatch is on.
|
|
612
676
|
#[allow(clippy::type_complexity)]
|
|
613
677
|
pub fn server_stats(
|
|
614
678
|
_ruby: &Ruby,
|
|
615
679
|
server_id: u64,
|
|
616
|
-
) -> Result<(usize, usize, u64, u64, u64, Option<Vec<usize>>), Error> {
|
|
680
|
+
) -> Result<(usize, usize, u64, u64, u64, u64, Option<Vec<usize>>), Error> {
|
|
617
681
|
let Some(server) = registry::try_get(server_id) else {
|
|
618
|
-
return Ok((0, 0, 0, 0, 0, None));
|
|
682
|
+
return Ok((0, 0, 0, 0, 0, 0, None));
|
|
619
683
|
};
|
|
620
684
|
let lane_depths = server.lane_depths();
|
|
621
685
|
let queued = server.req_rx.len() + lane_depths.as_ref().map_or(0, |d| d.iter().sum::<usize>());
|
|
@@ -625,10 +689,56 @@ pub fn server_stats(
|
|
|
625
689
|
server.served.load(Ordering::Relaxed),
|
|
626
690
|
server.rejected.load(Ordering::Relaxed),
|
|
627
691
|
server.timeouts.load(Ordering::Relaxed),
|
|
692
|
+
server.respawns.load(Ordering::Relaxed),
|
|
628
693
|
lane_depths,
|
|
629
694
|
))
|
|
630
695
|
}
|
|
631
696
|
|
|
697
|
+
/// Queue-wait count and summed seconds for Server#stats parity. Zeros when
|
|
698
|
+
/// the server is gone.
|
|
699
|
+
pub fn queue_time(_ruby: &Ruby, server_id: u64) -> Result<(u64, f64), Error> {
|
|
700
|
+
let Some(server) = registry::try_get(server_id) else {
|
|
701
|
+
return Ok((0, 0.0));
|
|
702
|
+
};
|
|
703
|
+
let h = server.queue_histogram.snapshot();
|
|
704
|
+
Ok((h.count, h.sum_seconds()))
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/// One worker slot's [index, served, in_flight, busy_ms, quarantined] row.
|
|
708
|
+
pub type WorkerStatRow = (usize, u64, usize, u64, bool);
|
|
709
|
+
|
|
710
|
+
/// Per-slot rows for Server#stats parity: [index, served, in_flight,
|
|
711
|
+
/// busy_ms, quarantined] each. Empty when the server is gone.
|
|
712
|
+
pub fn worker_stats(
|
|
713
|
+
_ruby: &Ruby,
|
|
714
|
+
server_id: u64,
|
|
715
|
+
) -> Result<Vec<WorkerStatRow>, Error> {
|
|
716
|
+
let Some(server) = registry::try_get(server_id) else {
|
|
717
|
+
return Ok(Vec::new());
|
|
718
|
+
};
|
|
719
|
+
Ok(crate::control::collect_worker_status(&server)
|
|
720
|
+
.into_iter()
|
|
721
|
+
.map(|w| (w.index, w.served, w.in_flight, w.busy_ms, w.quarantined))
|
|
722
|
+
.collect())
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/// Mark a slot quarantined (the monitor has abandoned it as wedged).
|
|
726
|
+
pub fn quarantine_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
|
|
727
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
728
|
+
let slot = server.slot(ruby, worker_id)?;
|
|
729
|
+
slot.quarantined.store(true, Ordering::Relaxed);
|
|
730
|
+
}
|
|
731
|
+
Ok(())
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/// One replacement spawned by the quarantine monitor.
|
|
735
|
+
pub fn record_quarantine_replacement(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
736
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
737
|
+
server.quarantine_replacements.fetch_add(1, Ordering::Relaxed);
|
|
738
|
+
}
|
|
739
|
+
Ok(())
|
|
740
|
+
}
|
|
741
|
+
|
|
632
742
|
#[cfg(test)]
|
|
633
743
|
mod tests {
|
|
634
744
|
use super::*;
|
data/lib/kino/cli.rb
CHANGED
|
@@ -100,11 +100,14 @@ module Kino
|
|
|
100
100
|
end.join
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
# One-line stats dump (the SIGUSR1 handler's output).
|
|
103
|
+
# One-line stats dump (the SIGUSR1 handler's output). Excludes
|
|
104
|
+
# worker_status: it's an array with one entry per execution slot, and
|
|
105
|
+
# printing it inline would break the one-line contract (see /stats for
|
|
106
|
+
# per-worker detail).
|
|
104
107
|
# @param stats [Hash{Symbol => Object}] see {Kino::Server#stats}
|
|
105
108
|
# @return [String]
|
|
106
109
|
def stats_line(stats)
|
|
107
|
-
dim("Kino stats: #{stats.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}")
|
|
110
|
+
dim("Kino stats: #{stats.except(:worker_status).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}")
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
# The two banner halves around Server#start: credits before, the ready
|
data/lib/kino/configuration.rb
CHANGED
|
@@ -23,11 +23,19 @@ module Kino
|
|
|
23
23
|
lanes: false,
|
|
24
24
|
log_requests: false,
|
|
25
25
|
on_error: nil,
|
|
26
|
+
after_boot: nil,
|
|
27
|
+
after_worker_boot: nil,
|
|
28
|
+
after_request_complete: nil,
|
|
29
|
+
on_worker_exit: nil,
|
|
26
30
|
shutdown_timeout: 30,
|
|
27
31
|
tokio_threads: nil,
|
|
28
32
|
tls: nil,
|
|
29
33
|
environment: nil,
|
|
30
34
|
pidfile: nil,
|
|
35
|
+
control_bind: nil,
|
|
36
|
+
control_token: nil,
|
|
37
|
+
quarantine_timeout: nil,
|
|
38
|
+
quarantine_max: nil,
|
|
31
39
|
rackup: nil
|
|
32
40
|
}.freeze
|
|
33
41
|
|
|
@@ -185,6 +193,24 @@ module Kino
|
|
|
185
193
|
# or a block. Must be Ractor-shareable in :ractor mode.
|
|
186
194
|
def on_error(handler = nil, &block) = @config.set(:on_error, handler || block)
|
|
187
195
|
|
|
196
|
+
# Called once on the main thread after the worker pool is up. The
|
|
197
|
+
# readiness seam (wire sd_notify or a "server ready" metric here).
|
|
198
|
+
def after_boot(handler = nil, &block) = @config.set(:after_boot, handler || block)
|
|
199
|
+
|
|
200
|
+
# Called once inside each worker (a ractor in :ractor mode) before it
|
|
201
|
+
# serves, with the worker's slot id. Must be Ractor-shareable in
|
|
202
|
+
# :ractor mode (build it with Ractor.shareable_proc).
|
|
203
|
+
def after_worker_boot(handler = nil, &block) = @config.set(:after_worker_boot, handler || block)
|
|
204
|
+
|
|
205
|
+
# Called inside the worker after each successful response with
|
|
206
|
+
# (env, status). Hot path: leave unset for zero cost. Must be
|
|
207
|
+
# Ractor-shareable in :ractor mode.
|
|
208
|
+
def after_request_complete(handler = nil, &block) = @config.set(:after_request_complete, handler || block)
|
|
209
|
+
|
|
210
|
+
# Called on the main thread when a worker exits, with (worker_index,
|
|
211
|
+
# error_or_nil). error is the crash cause, or nil on a clean exit.
|
|
212
|
+
def on_worker_exit(handler = nil, &block) = @config.set(:on_worker_exit, handler || block)
|
|
213
|
+
|
|
188
214
|
# Graceful-shutdown drain deadline in seconds.
|
|
189
215
|
def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
|
|
190
216
|
|
|
@@ -200,6 +226,37 @@ module Kino
|
|
|
200
226
|
# Write the master PID here on start.
|
|
201
227
|
def pidfile(path) = @config.set(:pidfile, path.to_s)
|
|
202
228
|
|
|
229
|
+
# Serve the read-only control plane (live stats as JSON at /stats,
|
|
230
|
+
# Prometheus text at /metrics, /ready and /live probes) on this
|
|
231
|
+
# address: "host:port" or "unix://path". Off unless set.
|
|
232
|
+
def control_bind(addr) = @config.set(:control_bind, addr.to_s)
|
|
233
|
+
|
|
234
|
+
# When set, /stats and /metrics require "Authorization: Bearer <token>".
|
|
235
|
+
# The probes stay open; they carry no data.
|
|
236
|
+
def control_token(token) = @config.set(:control_token, token.to_s)
|
|
237
|
+
|
|
238
|
+
# Quarantine a dispatch slot whose current request has run longer
|
|
239
|
+
# than this many seconds, spawning a replacement to restore capacity.
|
|
240
|
+
# Off unless set. Set it above your slowest legitimate endpoint (and
|
|
241
|
+
# typically above request_timeout).
|
|
242
|
+
def quarantine_timeout(seconds)
|
|
243
|
+
seconds &&= Float(seconds)
|
|
244
|
+
if seconds && seconds <= 0
|
|
245
|
+
raise ArgumentError, "quarantine_timeout must be greater than 0 (got #{seconds})"
|
|
246
|
+
end
|
|
247
|
+
@config.set(:quarantine_timeout, seconds)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Cap on the total number of replacement events over the process
|
|
251
|
+
# lifetime. Past the cap the monitor stops replacing and the server
|
|
252
|
+
# runs at reduced capacity. Default: the worker count in :ractor
|
|
253
|
+
# mode, workers x threads in :threaded.
|
|
254
|
+
def quarantine_max(count)
|
|
255
|
+
count = Integer(count)
|
|
256
|
+
raise ArgumentError, "quarantine_max must be >= 1 (got #{count})" if count < 1
|
|
257
|
+
@config.set(:quarantine_max, count)
|
|
258
|
+
end
|
|
259
|
+
|
|
203
260
|
# Rackup file the `kino` CLI loads (positional argument wins).
|
|
204
261
|
def rackup(path) = @config.set(:rackup, path.to_s)
|
|
205
262
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kino
|
|
4
|
+
# @private
|
|
5
|
+
# Fires a lifecycle hook and turns a raise into a logged line instead of
|
|
6
|
+
# letting it escape. Stateless and touches only its arguments plus
|
|
7
|
+
# Native.log_error (already called from inside worker ractors today), so
|
|
8
|
+
# it is safe to call from worker context: no main-ractor state is
|
|
9
|
+
# captured.
|
|
10
|
+
module HookFire
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def fire(hook, name, *args)
|
|
14
|
+
return unless hook
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
hook.call(*args)
|
|
18
|
+
rescue => e
|
|
19
|
+
Native.log_error("#{name} hook raised #{e.class}: #{e.message}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|