kino 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/Cargo.lock +1 -1
- data/README.md +52 -0
- data/doc/architecture.md +10 -0
- data/ext/kino/Cargo.toml +1 -1
- data/ext/kino/src/control.rs +103 -6
- data/ext/kino/src/env_strings.rs +249 -120
- data/ext/kino/src/lib.rs +11 -0
- data/ext/kino/src/pin.rs +21 -9
- data/ext/kino/src/queue.rs +99 -3
- data/ext/kino/src/registry.rs +141 -0
- data/ext/kino/src/request.rs +1 -1
- data/ext/kino/src/server.rs +218 -3
- data/ext/kino/src/test_support.rs +44 -0
- data/lib/kino/cli.rb +12 -4
- data/lib/kino/configuration.rb +16 -2
- data/lib/kino/monitor.rb +52 -0
- data/lib/kino/pool_scaler.rb +103 -0
- data/lib/kino/quarantine_monitor.rb +5 -37
- data/lib/kino/ractor_supervisor.rb +102 -8
- data/lib/kino/server.rb +64 -85
- data/lib/kino/slot_bank.rb +31 -0
- data/lib/kino/templates/kino.rb.tt +15 -0
- data/lib/kino/threaded_pool.rb +186 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino.rb +4 -0
- data/lib/rackup/handler/kino.rb +4 -4
- data/sig/kino.rbs +2 -0
- metadata +5 -1
data/ext/kino/src/queue.rs
CHANGED
|
@@ -25,6 +25,21 @@ pub const TICK: Duration = Duration::from_millis(50);
|
|
|
25
25
|
/// the difference and doesn't need to.
|
|
26
26
|
type Taken = Option<BoxedCtx>;
|
|
27
27
|
|
|
28
|
+
/// What a take does when its bounded wait times out: keep waiting (None),
|
|
29
|
+
/// or end the loop because the pool scaler retired this slot. A retired
|
|
30
|
+
/// lane worker first empties its own lane, one item per timeout: the
|
|
31
|
+
/// dispatcher stopped feeding it when the flag went up (see
|
|
32
|
+
/// registry::WorkerSlot::retire for the ordering), so whatever is
|
|
33
|
+
/// still there is the last of it, and nothing already assigned is
|
|
34
|
+
/// orphaned. Only reached with the queue momentarily empty, so the fast
|
|
35
|
+
/// path pays nothing for it.
|
|
36
|
+
fn after_timeout(slot: &WorkerSlot) -> Option<Taken> {
|
|
37
|
+
if !slot.retired.load(Ordering::SeqCst) {
|
|
38
|
+
return None;
|
|
39
|
+
}
|
|
40
|
+
Some(slot.lane_rx.as_ref().and_then(|rx| rx.try_recv().ok()))
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/// Block until one request arrives (GVL released, interruptible).
|
|
29
44
|
/// No busy-poll before parking, deliberately: the wake-per-request futex
|
|
30
45
|
/// cost is real (~20% of cycles at saturation, per perf), but a measured
|
|
@@ -46,7 +61,7 @@ fn block_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Result<Taken, Err
|
|
|
46
61
|
let taken =
|
|
47
62
|
gvl::interruptible(&slot.interrupted, || match req_rx.recv_timeout(TICK) {
|
|
48
63
|
Ok(ctx) => Some(Some(ctx)),
|
|
49
|
-
Err(flume::RecvTimeoutError::Timeout) =>
|
|
64
|
+
Err(flume::RecvTimeoutError::Timeout) => after_timeout(slot),
|
|
50
65
|
Err(flume::RecvTimeoutError::Disconnected) => Some(None),
|
|
51
66
|
})?;
|
|
52
67
|
Ok(taken.flatten())
|
|
@@ -93,8 +108,10 @@ fn lane_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Result<Taken, Erro
|
|
|
93
108
|
match lane_rx.recv_timeout(TICK) {
|
|
94
109
|
Ok(ctx) => Some(Some(ctx)),
|
|
95
110
|
// Periodic steal so a backlog behind a slow sibling can't
|
|
96
|
-
// outlive a tick.
|
|
97
|
-
Err(flume::RecvTimeoutError::Timeout) =>
|
|
111
|
+
// outlive a tick; a retired worker leaves instead.
|
|
112
|
+
Err(flume::RecvTimeoutError::Timeout) => {
|
|
113
|
+
after_timeout(slot).or_else(|| steal().map(Some))
|
|
114
|
+
}
|
|
98
115
|
Err(flume::RecvTimeoutError::Disconnected) => Some(None),
|
|
99
116
|
}
|
|
100
117
|
});
|
|
@@ -235,3 +252,82 @@ pub fn respond_and_take(
|
|
|
235
252
|
crate::request::respond_simple(ruby, request, status, headers, body)?;
|
|
236
253
|
Worker::take_batch(ruby, &worker, max)
|
|
237
254
|
}
|
|
255
|
+
|
|
256
|
+
#[cfg(test)]
|
|
257
|
+
mod tests {
|
|
258
|
+
use super::*;
|
|
259
|
+
use crate::registry::test_server;
|
|
260
|
+
use crate::request::test_ctx;
|
|
261
|
+
|
|
262
|
+
#[test]
|
|
263
|
+
fn a_take_keeps_waiting_after_a_timeout_unless_the_slot_is_retired() {
|
|
264
|
+
let server = test_server(false, 4);
|
|
265
|
+
server.register_worker();
|
|
266
|
+
let slot = server.slots.read()[0].clone();
|
|
267
|
+
|
|
268
|
+
assert!(after_timeout(&slot).is_none());
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#[test]
|
|
272
|
+
fn a_retired_shared_queue_worker_ends_its_loop_at_the_next_timeout() {
|
|
273
|
+
let server = test_server(false, 4);
|
|
274
|
+
server.register_worker();
|
|
275
|
+
server.slots.read()[0].retire();
|
|
276
|
+
let slot = server.slots.read()[0].clone();
|
|
277
|
+
|
|
278
|
+
assert!(matches!(after_timeout(&slot), Some(None)));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[test]
|
|
282
|
+
fn a_retired_lane_worker_drains_its_own_lane_before_leaving() {
|
|
283
|
+
let server = test_server(true, 4);
|
|
284
|
+
server.register_worker();
|
|
285
|
+
let slot = server.slots.read()[0].clone();
|
|
286
|
+
slot.lane_tx
|
|
287
|
+
.lock()
|
|
288
|
+
.as_ref()
|
|
289
|
+
.expect("lane open")
|
|
290
|
+
.send(test_ctx())
|
|
291
|
+
.expect("lane has room");
|
|
292
|
+
server.slots.read()[0].retire();
|
|
293
|
+
|
|
294
|
+
// One item still assigned to this lane: serve it, not orphan it.
|
|
295
|
+
assert!(matches!(after_timeout(&slot), Some(Some(_))));
|
|
296
|
+
// Lane empty now: leave.
|
|
297
|
+
assert!(matches!(after_timeout(&slot), Some(None)));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn a_retired_lane_worker_drains_every_item_one_per_timeout() {
|
|
302
|
+
let server = test_server(true, 4);
|
|
303
|
+
server.register_worker();
|
|
304
|
+
let slot = server.slots.read()[0].clone();
|
|
305
|
+
for _ in 0..3 {
|
|
306
|
+
slot.lane_tx
|
|
307
|
+
.lock()
|
|
308
|
+
.as_ref()
|
|
309
|
+
.expect("lane open")
|
|
310
|
+
.send(test_ctx())
|
|
311
|
+
.expect("lane has room");
|
|
312
|
+
}
|
|
313
|
+
server.slots.read()[0].retire();
|
|
314
|
+
|
|
315
|
+
for _ in 0..3 {
|
|
316
|
+
assert!(matches!(after_timeout(&slot), Some(Some(_))));
|
|
317
|
+
}
|
|
318
|
+
assert!(matches!(after_timeout(&slot), Some(None)));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#[test]
|
|
322
|
+
fn a_reset_slot_keeps_waiting_again() {
|
|
323
|
+
let server = test_server(false, 4);
|
|
324
|
+
server.register_worker();
|
|
325
|
+
let slot = server.slots.read()[0].clone();
|
|
326
|
+
slot.retire();
|
|
327
|
+
assert!(matches!(after_timeout(&slot), Some(None)));
|
|
328
|
+
|
|
329
|
+
slot.reset();
|
|
330
|
+
|
|
331
|
+
assert!(after_timeout(&slot).is_none());
|
|
332
|
+
}
|
|
333
|
+
}
|
data/ext/kino/src/registry.rs
CHANGED
|
@@ -21,7 +21,11 @@ pub const STATE_DRAINING: u8 = 2;
|
|
|
21
21
|
/// "ractor" or "threaded", never "auto".
|
|
22
22
|
pub struct Topology {
|
|
23
23
|
pub mode: String,
|
|
24
|
+
/// The pool floor: the configured worker count.
|
|
24
25
|
pub workers: usize,
|
|
26
|
+
/// The pool ceiling: equal to `workers` for a fixed pool. HTTP/2
|
|
27
|
+
/// admission (SETTINGS_MAX_CONCURRENT_STREAMS) is advertised from it.
|
|
28
|
+
pub max_workers: usize,
|
|
25
29
|
pub threads: usize,
|
|
26
30
|
pub batch: usize,
|
|
27
31
|
}
|
|
@@ -113,6 +117,13 @@ pub struct ServerInner {
|
|
|
113
117
|
pub respawns: AtomicU64,
|
|
114
118
|
/// Replacements spawned by the quarantine monitor (Relaxed, advisory).
|
|
115
119
|
pub quarantine_replacements: AtomicU64,
|
|
120
|
+
/// Workers staying in the pool (a worker told to retire no longer
|
|
121
|
+
/// counts), reported by the Ruby pool as it grows and shrinks; starts
|
|
122
|
+
/// at the floor. Relaxed, advisory.
|
|
123
|
+
pub active_workers: AtomicUsize,
|
|
124
|
+
/// Pool scaler events (Relaxed, advisory).
|
|
125
|
+
pub scale_ups: AtomicU64,
|
|
126
|
+
pub scale_downs: AtomicU64,
|
|
116
127
|
pub topology: Topology,
|
|
117
128
|
pub https: bool,
|
|
118
129
|
/// HTTP/2 serving (ALPN over TLS, prior-knowledge h2c on plaintext);
|
|
@@ -158,6 +169,11 @@ pub struct WorkerSlot {
|
|
|
158
169
|
/// Set by the quarantine monitor when this slot is abandoned as wedged:
|
|
159
170
|
/// excluded from wedge detection, and its busy_ms is reported as 0.
|
|
160
171
|
pub quarantined: std::sync::atomic::AtomicBool,
|
|
172
|
+
/// Set by the pool scaler to send this slot's worker home: the lane
|
|
173
|
+
/// dispatcher skips it, and the take loop ends at its next idle tick
|
|
174
|
+
/// (a request already taken finishes first). Cleared by `reset` when
|
|
175
|
+
/// the slot is handed to a new worker.
|
|
176
|
+
pub retired: std::sync::atomic::AtomicBool,
|
|
161
177
|
}
|
|
162
178
|
|
|
163
179
|
/// Per-lane depth cap: small, so a slow handler can only ever delay this
|
|
@@ -251,8 +267,35 @@ impl WorkerSlot {
|
|
|
251
267
|
in_flight: AtomicUsize::new(0),
|
|
252
268
|
last_started_ms: AtomicU64::new(0),
|
|
253
269
|
quarantined: std::sync::atomic::AtomicBool::new(false),
|
|
270
|
+
retired: std::sync::atomic::AtomicBool::new(false),
|
|
254
271
|
}
|
|
255
272
|
}
|
|
273
|
+
|
|
274
|
+
/// Send this slot's worker home once it is idle. The flag is raised
|
|
275
|
+
/// under the lane lock, the same lock the lane dispatcher holds while
|
|
276
|
+
/// it checks the flag and sends: any dispatch that saw "not retired"
|
|
277
|
+
/// has landed in the lane before the worker can see the flag and
|
|
278
|
+
/// drain, so no request is orphaned.
|
|
279
|
+
pub fn retire(&self) {
|
|
280
|
+
let _lane = self.lane_tx.lock();
|
|
281
|
+
self.retired.store(true, Ordering::SeqCst);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/// Return a retired slot to its fresh state so a new worker can take
|
|
285
|
+
/// it over (slots are never removed; recycling keeps the registry
|
|
286
|
+
/// from growing with every scale-up). The lane channel stays, the new
|
|
287
|
+
/// occupant simply starts taking from it; the quarantine mark stays
|
|
288
|
+
/// too, a quarantined slot is never handed out.
|
|
289
|
+
pub fn reset(&self) {
|
|
290
|
+
let _lane = self.lane_tx.lock();
|
|
291
|
+
self.current.lock().clear();
|
|
292
|
+
self.retired.store(false, Ordering::SeqCst);
|
|
293
|
+
self.parked.store(false, Ordering::SeqCst);
|
|
294
|
+
self.interrupted.store(false, Ordering::SeqCst);
|
|
295
|
+
self.served.store(0, Ordering::Relaxed);
|
|
296
|
+
self.in_flight.store(0, Ordering::Relaxed);
|
|
297
|
+
self.last_started_ms.store(0, Ordering::Relaxed);
|
|
298
|
+
}
|
|
256
299
|
}
|
|
257
300
|
|
|
258
301
|
static REGISTRY: OnceLock<RwLock<HashMap<u64, Arc<ServerInner>>>> = OnceLock::new();
|
|
@@ -350,9 +393,13 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
|
|
|
350
393
|
state: std::sync::atomic::AtomicU8::new(STATE_BOOTING),
|
|
351
394
|
respawns: AtomicU64::new(0),
|
|
352
395
|
quarantine_replacements: AtomicU64::new(0),
|
|
396
|
+
active_workers: AtomicUsize::new(0),
|
|
397
|
+
scale_ups: AtomicU64::new(0),
|
|
398
|
+
scale_downs: AtomicU64::new(0),
|
|
353
399
|
topology: Topology {
|
|
354
400
|
mode: "threaded".to_string(),
|
|
355
401
|
workers: 0,
|
|
402
|
+
max_workers: 0,
|
|
356
403
|
threads: 0,
|
|
357
404
|
batch: 1,
|
|
358
405
|
},
|
|
@@ -491,6 +538,18 @@ mod tests {
|
|
|
491
538
|
assert_eq!(server.topology.batch, 1);
|
|
492
539
|
}
|
|
493
540
|
|
|
541
|
+
#[test]
|
|
542
|
+
fn pool_counters_start_at_the_configured_floor() {
|
|
543
|
+
let server = test_server(false, 4);
|
|
544
|
+
assert_eq!(
|
|
545
|
+
server.active_workers.load(Ordering::Relaxed),
|
|
546
|
+
server.topology.workers
|
|
547
|
+
);
|
|
548
|
+
assert_eq!(server.topology.max_workers, server.topology.workers);
|
|
549
|
+
assert_eq!(server.scale_ups.load(Ordering::Relaxed), 0);
|
|
550
|
+
assert_eq!(server.scale_downs.load(Ordering::Relaxed), 0);
|
|
551
|
+
}
|
|
552
|
+
|
|
494
553
|
#[test]
|
|
495
554
|
fn fresh_slot_has_zeroed_per_worker_sensors() {
|
|
496
555
|
let server = test_server(false, 4);
|
|
@@ -510,6 +569,88 @@ mod tests {
|
|
|
510
569
|
assert_eq!(server.quarantine_replacements.load(Ordering::Relaxed), 0);
|
|
511
570
|
}
|
|
512
571
|
|
|
572
|
+
#[test]
|
|
573
|
+
fn fresh_slot_is_not_retired() {
|
|
574
|
+
let server = test_server(false, 4);
|
|
575
|
+
server.register_worker();
|
|
576
|
+
assert!(!server.slots.read()[0].retired.load(Ordering::Relaxed));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
#[test]
|
|
580
|
+
fn retire_then_reset_returns_a_slot_to_its_fresh_state() {
|
|
581
|
+
let server = test_server(true, 4);
|
|
582
|
+
server.register_worker();
|
|
583
|
+
let slot = server.slots.read()[0].clone();
|
|
584
|
+
slot.parked.store(true, Ordering::Relaxed);
|
|
585
|
+
slot.interrupted.store(true, Ordering::Relaxed);
|
|
586
|
+
slot.served.store(5, Ordering::Relaxed);
|
|
587
|
+
slot.in_flight.store(1, Ordering::Relaxed);
|
|
588
|
+
slot.last_started_ms.store(9, Ordering::Relaxed);
|
|
589
|
+
slot.current.lock().push(std::sync::Weak::new());
|
|
590
|
+
|
|
591
|
+
slot.retire();
|
|
592
|
+
assert!(slot.retired.load(Ordering::Relaxed));
|
|
593
|
+
|
|
594
|
+
slot.reset();
|
|
595
|
+
assert!(!slot.retired.load(Ordering::Relaxed));
|
|
596
|
+
assert!(!slot.parked.load(Ordering::Relaxed));
|
|
597
|
+
assert!(!slot.interrupted.load(Ordering::Relaxed));
|
|
598
|
+
assert_eq!(slot.served.load(Ordering::Relaxed), 0);
|
|
599
|
+
assert_eq!(slot.in_flight.load(Ordering::Relaxed), 0);
|
|
600
|
+
assert_eq!(slot.last_started_ms.load(Ordering::Relaxed), 0);
|
|
601
|
+
assert!(slot.current.lock().is_empty());
|
|
602
|
+
// The lane survives reuse: the next occupant takes from it.
|
|
603
|
+
assert!(slot.lane_tx.lock().is_some());
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
#[test]
|
|
607
|
+
fn reset_keeps_a_quarantine_mark() {
|
|
608
|
+
let server = test_server(false, 4);
|
|
609
|
+
server.register_worker();
|
|
610
|
+
let slot = server.slots.read()[0].clone();
|
|
611
|
+
slot.quarantined.store(true, Ordering::Relaxed);
|
|
612
|
+
|
|
613
|
+
slot.retire();
|
|
614
|
+
slot.reset();
|
|
615
|
+
|
|
616
|
+
// A wedged slot stays flagged even if it ever came back around.
|
|
617
|
+
assert!(slot.quarantined.load(Ordering::Relaxed));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
#[test]
|
|
621
|
+
fn retire_releases_the_lane_lock() {
|
|
622
|
+
let server = test_server(true, 4);
|
|
623
|
+
server.register_worker();
|
|
624
|
+
let slot = server.slots.read()[0].clone();
|
|
625
|
+
|
|
626
|
+
slot.retire();
|
|
627
|
+
|
|
628
|
+
assert!(slot.lane_tx.try_lock().is_some());
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
#[test]
|
|
632
|
+
fn retire_waits_for_a_dispatcher_holding_the_lane_lock() {
|
|
633
|
+
let server = test_server(true, 4);
|
|
634
|
+
server.register_worker();
|
|
635
|
+
let slot = server.slots.read()[0].clone();
|
|
636
|
+
|
|
637
|
+
// A dispatcher that has already read "not retired" and is sending.
|
|
638
|
+
let sending = slot.lane_tx.lock();
|
|
639
|
+
let retiring = std::thread::spawn({
|
|
640
|
+
let slot = slot.clone();
|
|
641
|
+
move || slot.retire()
|
|
642
|
+
});
|
|
643
|
+
std::thread::sleep(Duration::from_millis(30));
|
|
644
|
+
assert!(
|
|
645
|
+
!slot.retired.load(Ordering::SeqCst),
|
|
646
|
+
"the flag must not go up under a dispatcher's lock"
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
drop(sending);
|
|
650
|
+
retiring.join().expect("retire thread");
|
|
651
|
+
assert!(slot.retired.load(Ordering::SeqCst));
|
|
652
|
+
}
|
|
653
|
+
|
|
513
654
|
#[test]
|
|
514
655
|
fn queue_histogram_buckets_by_wait() {
|
|
515
656
|
let h = QueueHistogram::new();
|
data/ext/kino/src/request.rs
CHANGED
|
@@ -492,7 +492,7 @@ fn coerce_str(value: Value) -> Result<RString, Error> {
|
|
|
492
492
|
}
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
-
fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
|
|
495
|
+
pub(crate) fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
|
|
496
496
|
match host.rsplit_once(':') {
|
|
497
497
|
Some((name, port)) if !name.is_empty() => match port.parse() {
|
|
498
498
|
Ok(p) => (name.to_string(), p),
|
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) {
|
|
@@ -130,9 +134,13 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
|
|
|
130
134
|
state: std::sync::atomic::AtomicU8::new(registry::STATE_BOOTING),
|
|
131
135
|
respawns: std::sync::atomic::AtomicU64::new(0),
|
|
132
136
|
quarantine_replacements: std::sync::atomic::AtomicU64::new(0),
|
|
137
|
+
active_workers: std::sync::atomic::AtomicUsize::new(workers),
|
|
138
|
+
scale_ups: std::sync::atomic::AtomicU64::new(0),
|
|
139
|
+
scale_downs: std::sync::atomic::AtomicU64::new(0),
|
|
133
140
|
topology: registry::Topology {
|
|
134
141
|
mode,
|
|
135
142
|
workers,
|
|
143
|
+
max_workers,
|
|
136
144
|
threads,
|
|
137
145
|
batch,
|
|
138
146
|
},
|
|
@@ -383,6 +391,12 @@ fn advertised_streams(workers: usize, threads: usize) -> u32 {
|
|
|
383
391
|
slots.clamp(8, 1024) as u32
|
|
384
392
|
}
|
|
385
393
|
|
|
394
|
+
/// The pool can grow to its ceiling to meet what an h2 balancer sends, so
|
|
395
|
+
/// the ceiling is the admission to advertise (the floor for a fixed pool).
|
|
396
|
+
fn stream_capacity(topology: ®istry::Topology) -> u32 {
|
|
397
|
+
advertised_streams(topology.max_workers, topology.threads)
|
|
398
|
+
}
|
|
399
|
+
|
|
386
400
|
fn conn_builder(http2: bool, max_streams: u32) -> auto::Builder<TokioExecutor> {
|
|
387
401
|
let mut builder = auto::Builder::new(TokioExecutor::new());
|
|
388
402
|
builder
|
|
@@ -418,7 +432,7 @@ async fn serve_connection<I>(
|
|
|
418
432
|
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
|
419
433
|
{
|
|
420
434
|
let http2 = server.http2;
|
|
421
|
-
let max_streams =
|
|
435
|
+
let max_streams = stream_capacity(&server.topology);
|
|
422
436
|
let mut drain = server.shutdown_tx.subscribe();
|
|
423
437
|
let service =
|
|
424
438
|
service_fn(move |req| handle_request(server.clone(), remote_addr, local_addr, req));
|
|
@@ -712,6 +726,11 @@ fn try_dispatch(server: &ServerInner, mut ctx: BoxedCtx) -> Dispatch {
|
|
|
712
726
|
let guard = slot.lane_tx.lock();
|
|
713
727
|
let Some(tx) = guard.as_ref() else { continue };
|
|
714
728
|
any_open = true;
|
|
729
|
+
// Checked under the lane lock, which WorkerSlot::retire also
|
|
730
|
+
// takes: see there for the ordering.
|
|
731
|
+
if slot.retired.load(Ordering::SeqCst) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
715
734
|
match tx.try_send(ctx) {
|
|
716
735
|
Ok(()) => return Dispatch::Sent,
|
|
717
736
|
Err(flume::TrySendError::Full(c)) | Err(flume::TrySendError::Disconnected(c)) => {
|
|
@@ -900,7 +919,7 @@ pub fn queue_time(_ruby: &Ruby, server_id: u64) -> Result<(u64, f64), Error> {
|
|
|
900
919
|
}
|
|
901
920
|
|
|
902
921
|
/// One worker slot's [index, served, in_flight, busy_ms, quarantined] row.
|
|
903
|
-
pub type WorkerStatRow = (usize, u64, usize, u64, bool);
|
|
922
|
+
pub type WorkerStatRow = (usize, u64, usize, u64, bool, bool);
|
|
904
923
|
|
|
905
924
|
/// Per-slot rows for Server#stats parity: [index, served, in_flight,
|
|
906
925
|
/// busy_ms, quarantined] each. Empty when the server is gone.
|
|
@@ -910,7 +929,16 @@ pub fn worker_stats(_ruby: &Ruby, server_id: u64) -> Result<Vec<WorkerStatRow>,
|
|
|
910
929
|
};
|
|
911
930
|
Ok(crate::control::collect_worker_status(&server)
|
|
912
931
|
.into_iter()
|
|
913
|
-
.map(|w|
|
|
932
|
+
.map(|w| {
|
|
933
|
+
(
|
|
934
|
+
w.index,
|
|
935
|
+
w.served,
|
|
936
|
+
w.in_flight,
|
|
937
|
+
w.busy_ms,
|
|
938
|
+
w.quarantined,
|
|
939
|
+
w.retired,
|
|
940
|
+
)
|
|
941
|
+
})
|
|
914
942
|
.collect())
|
|
915
943
|
}
|
|
916
944
|
|
|
@@ -923,6 +951,61 @@ pub fn quarantine_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<
|
|
|
923
951
|
Ok(())
|
|
924
952
|
}
|
|
925
953
|
|
|
954
|
+
/// Send a slot's worker home once idle (pool scale-down); see
|
|
955
|
+
/// registry::WorkerSlot::retire. An unknown id is a caller bug: raise.
|
|
956
|
+
pub fn retire_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
|
|
957
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
958
|
+
server.slot(ruby, worker_id)?.retire();
|
|
959
|
+
}
|
|
960
|
+
Ok(())
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/// Hand a retired slot to a new worker (pool scale-up reusing a slot).
|
|
964
|
+
pub fn reset_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
|
|
965
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
966
|
+
server.slot(ruby, worker_id)?.reset();
|
|
967
|
+
}
|
|
968
|
+
Ok(())
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/// The Ruby pool reports how many workers are alive and serving.
|
|
972
|
+
pub fn set_active_workers(_ruby: &Ruby, server_id: u64, count: usize) -> Result<(), Error> {
|
|
973
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
974
|
+
server.active_workers.store(count, Ordering::Relaxed);
|
|
975
|
+
}
|
|
976
|
+
Ok(())
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/// One worker added by the pool scaler.
|
|
980
|
+
pub fn record_scale_up(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
981
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
982
|
+
server.scale_ups.fetch_add(1, Ordering::Relaxed);
|
|
983
|
+
}
|
|
984
|
+
Ok(())
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/// One worker retired by the pool scaler.
|
|
988
|
+
pub fn record_scale_down(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
989
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
990
|
+
server.scale_downs.fetch_add(1, Ordering::Relaxed);
|
|
991
|
+
}
|
|
992
|
+
Ok(())
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/// [active_workers, max_workers, scale_ups, scale_downs] for Server#stats.
|
|
996
|
+
/// Zeros when the server is gone.
|
|
997
|
+
pub fn pool_stats(_ruby: &Ruby, server_id: u64) -> Result<(usize, usize, u64, u64), Error> {
|
|
998
|
+
let Some(server) = registry::try_get(server_id) else {
|
|
999
|
+
return Ok((0, 0, 0, 0));
|
|
1000
|
+
};
|
|
1001
|
+
Ok((
|
|
1002
|
+
server.active_workers.load(Ordering::Relaxed),
|
|
1003
|
+
server.topology.max_workers,
|
|
1004
|
+
server.scale_ups.load(Ordering::Relaxed),
|
|
1005
|
+
server.scale_downs.load(Ordering::Relaxed),
|
|
1006
|
+
))
|
|
1007
|
+
}
|
|
1008
|
+
|
|
926
1009
|
/// One replacement spawned by the quarantine monitor.
|
|
927
1010
|
pub fn record_quarantine_replacement(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
928
1011
|
if let Some(server) = registry::try_get(server_id) {
|
|
@@ -1355,6 +1438,32 @@ mod tests {
|
|
|
1355
1438
|
assert_eq!(advertised_streams(8, 0), 200);
|
|
1356
1439
|
}
|
|
1357
1440
|
|
|
1441
|
+
#[test]
|
|
1442
|
+
fn stream_capacity_is_advertised_from_the_pool_ceiling() {
|
|
1443
|
+
// An elastic pool grows to meet load an h2 balancer sends, so the
|
|
1444
|
+
// ceiling, not the floor, is the admission to advertise.
|
|
1445
|
+
let topology = registry::Topology {
|
|
1446
|
+
mode: "ractor".to_string(),
|
|
1447
|
+
workers: 2,
|
|
1448
|
+
max_workers: 8,
|
|
1449
|
+
threads: 3,
|
|
1450
|
+
batch: 1,
|
|
1451
|
+
};
|
|
1452
|
+
assert_eq!(stream_capacity(&topology), 24);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
#[test]
|
|
1456
|
+
fn stream_capacity_of_a_fixed_pool_is_its_slot_count() {
|
|
1457
|
+
let topology = registry::Topology {
|
|
1458
|
+
mode: "threaded".to_string(),
|
|
1459
|
+
workers: 4,
|
|
1460
|
+
max_workers: 4,
|
|
1461
|
+
threads: 3,
|
|
1462
|
+
batch: 1,
|
|
1463
|
+
};
|
|
1464
|
+
assert_eq!(stream_capacity(&topology), 12);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1358
1467
|
#[tokio::test]
|
|
1359
1468
|
async fn streams_beyond_the_advertised_cap_queue_instead_of_failing() {
|
|
1360
1469
|
use http_body_util::Full;
|
|
@@ -1566,4 +1675,110 @@ mod tests {
|
|
|
1566
1675
|
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
1567
1676
|
assert_eq!(server.lane_depths(), Some(vec![0, 2]));
|
|
1568
1677
|
}
|
|
1678
|
+
|
|
1679
|
+
#[test]
|
|
1680
|
+
fn dispatch_skips_retired_lanes() {
|
|
1681
|
+
let server = test_server(true, 4);
|
|
1682
|
+
server.register_worker();
|
|
1683
|
+
server.register_worker();
|
|
1684
|
+
server.slots.read()[0].retire();
|
|
1685
|
+
|
|
1686
|
+
// A retiring worker gets nothing new: both land on slot 1.
|
|
1687
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
1688
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
1689
|
+
assert_eq!(server.lane_depths(), Some(vec![0, 2]));
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
#[test]
|
|
1693
|
+
fn dispatch_retries_rather_than_closing_when_only_retired_lanes_remain() {
|
|
1694
|
+
let server = test_server(true, 4);
|
|
1695
|
+
server.register_worker();
|
|
1696
|
+
server.slots.read()[0].retire();
|
|
1697
|
+
|
|
1698
|
+
// Retired is not draining: the caller keeps retrying until the
|
|
1699
|
+
// queue timeout, so a replacement worker can still pick this up.
|
|
1700
|
+
assert!(matches!(
|
|
1701
|
+
try_dispatch(&server, test_ctx()),
|
|
1702
|
+
Dispatch::Full(_)
|
|
1703
|
+
));
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
#[test]
|
|
1707
|
+
fn dispatch_skips_a_retired_lane_even_when_it_is_the_only_awake_one() {
|
|
1708
|
+
let server = test_server(true, 4);
|
|
1709
|
+
server.register_worker();
|
|
1710
|
+
server.register_worker();
|
|
1711
|
+
server.slots.read()[0].retire();
|
|
1712
|
+
server.slots.read()[1].parked.store(true, Ordering::Relaxed);
|
|
1713
|
+
|
|
1714
|
+
// The second pass (parked lanes allowed) still skips the retired one.
|
|
1715
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
1716
|
+
assert_eq!(server.lane_depths(), Some(vec![0, 1]));
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
#[test]
|
|
1720
|
+
fn dispatch_keeps_retrying_when_the_other_lanes_are_closed() {
|
|
1721
|
+
let server = test_server(true, 4);
|
|
1722
|
+
server.register_worker();
|
|
1723
|
+
server.register_worker();
|
|
1724
|
+
server.slots.read()[0].lane_tx.lock().take(); // a crashed worker's lane
|
|
1725
|
+
server.slots.read()[1].retire();
|
|
1726
|
+
|
|
1727
|
+
assert!(matches!(
|
|
1728
|
+
try_dispatch(&server, test_ctx()),
|
|
1729
|
+
Dispatch::Full(_)
|
|
1730
|
+
));
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
#[test]
|
|
1734
|
+
fn a_reset_lane_receives_dispatches_again() {
|
|
1735
|
+
let server = test_server(true, 4);
|
|
1736
|
+
server.register_worker();
|
|
1737
|
+
server.register_worker();
|
|
1738
|
+
server.slots.read()[0].retire();
|
|
1739
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
1740
|
+
assert_eq!(server.lane_depths(), Some(vec![0, 1]));
|
|
1741
|
+
|
|
1742
|
+
server.slots.read()[0].reset();
|
|
1743
|
+
for _ in 0..2 {
|
|
1744
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
1745
|
+
}
|
|
1746
|
+
// Back in the round-robin.
|
|
1747
|
+
assert_eq!(server.lane_depths(), Some(vec![1, 2]));
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
#[test]
|
|
1751
|
+
fn nothing_lands_in_a_lane_after_its_retirement_and_final_drain() {
|
|
1752
|
+
let server = test_server(true, 4);
|
|
1753
|
+
server.register_worker();
|
|
1754
|
+
server.register_worker();
|
|
1755
|
+
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
1756
|
+
let dispatcher = std::thread::spawn({
|
|
1757
|
+
let server = server.clone();
|
|
1758
|
+
let stop = stop.clone();
|
|
1759
|
+
move || {
|
|
1760
|
+
while !stop.load(Ordering::Relaxed) {
|
|
1761
|
+
let _ = try_dispatch(&server, test_ctx());
|
|
1762
|
+
// Lane 1 keeps draining, so dispatch never stalls on Full.
|
|
1763
|
+
let slots = server.slots.read();
|
|
1764
|
+
if let Some(rx) = slots[1].lane_rx.as_ref() {
|
|
1765
|
+
while rx.try_recv().is_ok() {}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
std::thread::sleep(Duration::from_millis(20));
|
|
1771
|
+
|
|
1772
|
+
// The scaler retires slot 0; its worker then drains what it holds.
|
|
1773
|
+
let slot = server.slots.read()[0].clone();
|
|
1774
|
+
slot.retire();
|
|
1775
|
+
if let Some(rx) = slot.lane_rx.as_ref() {
|
|
1776
|
+
while rx.try_recv().is_ok() {}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
1780
|
+
stop.store(true, Ordering::Relaxed);
|
|
1781
|
+
dispatcher.join().expect("dispatcher thread");
|
|
1782
|
+
assert_eq!(server.lane_depths(), Some(vec![0, 0]));
|
|
1783
|
+
}
|
|
1569
1784
|
}
|
|
@@ -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
|
+
}
|