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/pin.rs
CHANGED
|
@@ -22,6 +22,22 @@
|
|
|
22
22
|
//! GVL held), release is a single atomic store from whatever tokio
|
|
23
23
|
//! thread drops the buffer, and the mark hook only loads atomics, so
|
|
24
24
|
//! no path takes a lock. A full slab degrades to the copy path.
|
|
25
|
+
//!
|
|
26
|
+
//! The slab is also how env_strings.rs roots its cached env strings (a
|
|
27
|
+
//! second slab, marked through its own keeper), for the same reason: no
|
|
28
|
+
//! GC registration API may be called from parallel ractors.
|
|
29
|
+
//!
|
|
30
|
+
//! Per-ractor GC (Ruby 4.1): each ractor collects its own heap, and a
|
|
31
|
+
//! local collection marks only that ractor's roots. A keeper on the main
|
|
32
|
+
//! ractor is invisible to a worker ractor's local GC, so a string the
|
|
33
|
+
//! worker allocated and rooted only here would be swept while hyper
|
|
34
|
+
//! still reads its bytes. Shareable objects are exempt: only a global
|
|
35
|
+
//! GC, which marks every root including the keeper, may free them. A
|
|
36
|
+
//! slab serving worker ractors on such a Ruby therefore pins only
|
|
37
|
+
//! strings already flagged shareable (typically an app's frozen
|
|
38
|
+
//! constants) and copies the rest; making each per-request body
|
|
39
|
+
//! shareable instead would push it into the population only a
|
|
40
|
+
//! stop-the-world collection reclaims.
|
|
25
41
|
|
|
26
42
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
|
27
43
|
use std::sync::Arc;
|
|
@@ -43,6 +59,10 @@ pub const ZERO_COPY_MIN: usize = 4096;
|
|
|
43
59
|
/// throughput heuristic, not a limit on concurrency.
|
|
44
60
|
const SLAB_CAPACITY: usize = 4096;
|
|
45
61
|
|
|
62
|
+
/// Whether this Ruby collects each ractor's heap separately (see the
|
|
63
|
+
/// module docs). Ruby 4.0 has one heap for every ractor.
|
|
64
|
+
const PER_RACTOR_GC: bool = cfg!(ruby_gte_4_1);
|
|
65
|
+
|
|
46
66
|
extern "C" {
|
|
47
67
|
// Exported by libruby but absent from the public headers: io.c's
|
|
48
68
|
// buffer-stabilizing primitive (see module docs). Signature per
|
|
@@ -50,24 +70,41 @@ extern "C" {
|
|
|
50
70
|
fn rb_str_tmp_frozen_acquire(orig: rb_sys::VALUE) -> rb_sys::VALUE;
|
|
51
71
|
}
|
|
52
72
|
|
|
53
|
-
///
|
|
54
|
-
/// VALUE of 0 is Qfalse, which can
|
|
73
|
+
/// A fixed slab of atomic VALUE slots, each a pinning GC root while it
|
|
74
|
+
/// is non-zero. Slot value 0 = empty (a VALUE of 0 is Qfalse, which can
|
|
75
|
+
/// never be a rooted string).
|
|
55
76
|
pub struct PinSlab {
|
|
56
77
|
slots: Box<[AtomicU64]>,
|
|
57
78
|
/// Rotating claim cursor: keeps the free-slot scan O(1) amortized.
|
|
58
79
|
cursor: AtomicUsize,
|
|
80
|
+
/// Pin only strings flagged shareable (pinned_bytes): set for a
|
|
81
|
+
/// slab that worker ractors fill on a per-ractor-GC Ruby.
|
|
82
|
+
shareable_only: bool,
|
|
59
83
|
}
|
|
60
84
|
|
|
61
85
|
impl PinSlab {
|
|
62
|
-
|
|
86
|
+
/// A slab for one server's in-flight response buffers, filled by
|
|
87
|
+
/// worker ractors when `ractor_mode` (else by main-ractor threads).
|
|
88
|
+
pub fn for_responses(ractor_mode: bool) -> Self {
|
|
63
89
|
PinSlab {
|
|
64
|
-
|
|
90
|
+
shareable_only: ractor_mode && PER_RACTOR_GC,
|
|
91
|
+
..Self::with_capacity(SLAB_CAPACITY)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// A slab for callers that root only shareable values themselves
|
|
96
|
+
/// (env_strings), or for tests.
|
|
97
|
+
pub fn with_capacity(capacity: usize) -> Self {
|
|
98
|
+
PinSlab {
|
|
99
|
+
slots: (0..capacity).map(|_| AtomicU64::new(0)).collect(),
|
|
65
100
|
cursor: AtomicUsize::new(0),
|
|
101
|
+
shareable_only: false,
|
|
66
102
|
}
|
|
67
103
|
}
|
|
68
104
|
|
|
69
|
-
/// Root `value`; None when the slab is full (caller
|
|
70
|
-
|
|
105
|
+
/// Root `value`; None when the slab is full (the caller falls back to
|
|
106
|
+
/// a copy, or to an uncached string).
|
|
107
|
+
pub(crate) fn insert(&self, value: rb_sys::VALUE) -> Option<usize> {
|
|
71
108
|
let start = self.cursor.fetch_add(1, Ordering::Relaxed);
|
|
72
109
|
for offset in 0..self.slots.len() {
|
|
73
110
|
let index = (start + offset) % self.slots.len();
|
|
@@ -83,7 +120,7 @@ impl PinSlab {
|
|
|
83
120
|
|
|
84
121
|
/// Drop the root. Called from tokio threads: a plain atomic store,
|
|
85
122
|
/// no Ruby API. The string stays alive until the next GC sweep.
|
|
86
|
-
fn release(&self, index: usize) {
|
|
123
|
+
pub(crate) fn release(&self, index: usize) {
|
|
87
124
|
self.slots[index].store(0, Ordering::SeqCst);
|
|
88
125
|
}
|
|
89
126
|
|
|
@@ -113,9 +150,10 @@ impl PinSlab {
|
|
|
113
150
|
}
|
|
114
151
|
}
|
|
115
152
|
|
|
116
|
-
/// The GC-visible face of a
|
|
117
|
-
///
|
|
118
|
-
///
|
|
153
|
+
/// The GC-visible face of a PinSlab: Ruby's GC marks every rooted string
|
|
154
|
+
/// through it. `Kino::Server` holds one for its response-buffer slab for
|
|
155
|
+
/// the server's lifetime (surviving worker-ractor crashes); env_strings
|
|
156
|
+
/// registers one for its cache slab as an immortal root at init.
|
|
119
157
|
#[derive(magnus::TypedData)]
|
|
120
158
|
#[magnus(class = "Kino::Native::PinKeeper", free_immediately, mark)]
|
|
121
159
|
pub struct PinKeeper(pub Arc<PinSlab>);
|
|
@@ -157,9 +195,10 @@ impl Drop for PinnedBuf {
|
|
|
157
195
|
}
|
|
158
196
|
|
|
159
197
|
/// Bytes borrowing `body`'s buffer, with the string rooted until hyper
|
|
160
|
-
/// drops it; None when the body is below ZERO_COPY_MIN
|
|
161
|
-
/// full
|
|
162
|
-
///
|
|
198
|
+
/// drops it; None when the body is below ZERO_COPY_MIN, the slab is
|
|
199
|
+
/// full, or the slab takes only shareable strings and this one is not
|
|
200
|
+
/// (caller copies). Requires the calling worker's GVL; safe from any
|
|
201
|
+
/// ractor.
|
|
163
202
|
pub fn pinned_bytes(slab: &Arc<PinSlab>, body: RString) -> Option<Bytes> {
|
|
164
203
|
if body.len() < ZERO_COPY_MIN {
|
|
165
204
|
return None;
|
|
@@ -168,6 +207,9 @@ pub fn pinned_bytes(slab: &Arc<PinSlab>, body: RString) -> Option<Bytes> {
|
|
|
168
207
|
// by the caller. The acquired tmp is rooted by this thread's machine
|
|
169
208
|
// stack (conservatively scanned) until the slab insert publishes it.
|
|
170
209
|
let tmp = unsafe { rb_str_tmp_frozen_acquire(body.as_raw()) };
|
|
210
|
+
if slab.shareable_only && !flagged_shareable(tmp) {
|
|
211
|
+
return None;
|
|
212
|
+
}
|
|
171
213
|
let index = slab.insert(tmp)?;
|
|
172
214
|
// SAFETY: tmp is frozen, alive, and slab-rooted; len >= ZERO_COPY_MIN
|
|
173
215
|
// rules out an embedded buffer, so ptr is a stable heap allocation.
|
|
@@ -185,13 +227,22 @@ pub fn pinned_bytes(slab: &Arc<PinSlab>, body: RString) -> Option<Bytes> {
|
|
|
185
227
|
}))
|
|
186
228
|
}
|
|
187
229
|
|
|
230
|
+
/// Whether `value` (a heap object) carries the shareable flag. A plain
|
|
231
|
+
/// flag read: a frozen string that no one has checked yet may be
|
|
232
|
+
/// shareable in principle but unflagged, and the GC goes by the flag.
|
|
233
|
+
fn flagged_shareable(value: rb_sys::VALUE) -> bool {
|
|
234
|
+
let shareable = rb_sys::ruby_fl_type::RUBY_FL_SHAREABLE as rb_sys::VALUE;
|
|
235
|
+
// SAFETY: value is a live heap object (an RString) under the GVL.
|
|
236
|
+
unsafe { (*(value as *const rb_sys::RBasic)).flags & shareable != 0 }
|
|
237
|
+
}
|
|
238
|
+
|
|
188
239
|
#[cfg(test)]
|
|
189
240
|
mod tests {
|
|
190
241
|
use super::*;
|
|
191
242
|
|
|
192
243
|
#[test]
|
|
193
244
|
fn insert_release_reuse_cycle() {
|
|
194
|
-
let slab = PinSlab::
|
|
245
|
+
let slab = PinSlab::with_capacity(SLAB_CAPACITY);
|
|
195
246
|
let a = slab.insert(0x1000).expect("slot free");
|
|
196
247
|
let b = slab.insert(0x2000).expect("slot free");
|
|
197
248
|
assert_ne!(a, b);
|
|
@@ -210,7 +261,7 @@ mod tests {
|
|
|
210
261
|
|
|
211
262
|
#[test]
|
|
212
263
|
fn full_slab_refuses_instead_of_evicting() {
|
|
213
|
-
let slab = PinSlab::
|
|
264
|
+
let slab = PinSlab::with_capacity(SLAB_CAPACITY);
|
|
214
265
|
let indexes: Vec<usize> = (0..SLAB_CAPACITY)
|
|
215
266
|
.map(|i| slab.insert(0x1000 + i as rb_sys::VALUE).expect("capacity"))
|
|
216
267
|
.collect();
|
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
|
},
|
|
@@ -362,7 +409,7 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
|
|
|
362
409
|
access_log: None,
|
|
363
410
|
lanes,
|
|
364
411
|
lane_cursor: AtomicUsize::new(0),
|
|
365
|
-
pin_slab: Arc::new(crate::pin::PinSlab::
|
|
412
|
+
pin_slab: Arc::new(crate::pin::PinSlab::for_responses(false)),
|
|
366
413
|
queue_histogram: QueueHistogram::new(),
|
|
367
414
|
})
|
|
368
415
|
}
|
|
@@ -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),
|
|
@@ -518,7 +518,7 @@ pub fn test_ctx() -> crate::registry::BoxedCtx {
|
|
|
518
518
|
body_rx: None,
|
|
519
519
|
leftover: None,
|
|
520
520
|
slot: None,
|
|
521
|
-
pin_slab: Arc::new(crate::pin::PinSlab::
|
|
521
|
+
pin_slab: Arc::new(crate::pin::PinSlab::for_responses(false)),
|
|
522
522
|
responder: Arc::new(Responder::new(head_tx)),
|
|
523
523
|
enqueued_at: std::time::Instant::now(),
|
|
524
524
|
timed: false,
|