kino 0.5.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 +67 -0
- data/Cargo.lock +92 -1
- data/README.md +153 -44
- data/doc/architecture.md +64 -3
- data/doc/benchmarks.md +120 -26
- data/ext/kino/Cargo.toml +8 -3
- data/ext/kino/src/control.rs +103 -6
- data/ext/kino/src/env_strings.rs +350 -69
- data/ext/kino/src/lib.rs +22 -7
- data/ext/kino/src/pin.rs +21 -9
- data/ext/kino/src/queue.rs +158 -46
- data/ext/kino/src/registry.rs +145 -0
- data/ext/kino/src/request.rs +151 -70
- data/ext/kino/src/response.rs +39 -5
- data/ext/kino/src/server.rs +864 -33
- data/ext/kino/src/test_support.rs +44 -0
- data/ext/kino/src/tls.rs +119 -9
- data/lib/kino/cli.rb +12 -4
- data/lib/kino/configuration.rb +22 -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 +66 -85
- data/lib/kino/slot_bank.rb +31 -0
- data/lib/kino/templates/kino.rb.tt +20 -0
- data/lib/kino/threaded_pool.rb +186 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +14 -11
- data/lib/kino.rb +4 -0
- data/lib/rackup/handler/kino.rb +4 -4
- data/sig/kino.rbs +3 -0
- metadata +19 -1
data/ext/kino/src/pin.rs
CHANGED
|
@@ -22,6 +22,10 @@
|
|
|
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.
|
|
25
29
|
|
|
26
30
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
|
27
31
|
use std::sync::Arc;
|
|
@@ -50,8 +54,9 @@ extern "C" {
|
|
|
50
54
|
fn rb_str_tmp_frozen_acquire(orig: rb_sys::VALUE) -> rb_sys::VALUE;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
///
|
|
54
|
-
/// VALUE of 0 is Qfalse, which can
|
|
57
|
+
/// A fixed slab of atomic VALUE slots, each a pinning GC root while it
|
|
58
|
+
/// is non-zero. Slot value 0 = empty (a VALUE of 0 is Qfalse, which can
|
|
59
|
+
/// never be a rooted string).
|
|
55
60
|
pub struct PinSlab {
|
|
56
61
|
slots: Box<[AtomicU64]>,
|
|
57
62
|
/// Rotating claim cursor: keeps the free-slot scan O(1) amortized.
|
|
@@ -59,15 +64,21 @@ pub struct PinSlab {
|
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
impl PinSlab {
|
|
67
|
+
/// A slab for one server's in-flight response buffers.
|
|
62
68
|
pub fn new() -> Self {
|
|
69
|
+
Self::with_capacity(SLAB_CAPACITY)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
pub fn with_capacity(capacity: usize) -> Self {
|
|
63
73
|
PinSlab {
|
|
64
|
-
slots: (0..
|
|
74
|
+
slots: (0..capacity).map(|_| AtomicU64::new(0)).collect(),
|
|
65
75
|
cursor: AtomicUsize::new(0),
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
/// Root `value`; None when the slab is full (caller
|
|
70
|
-
|
|
79
|
+
/// Root `value`; None when the slab is full (the caller falls back to
|
|
80
|
+
/// a copy, or to an uncached string).
|
|
81
|
+
pub(crate) fn insert(&self, value: rb_sys::VALUE) -> Option<usize> {
|
|
71
82
|
let start = self.cursor.fetch_add(1, Ordering::Relaxed);
|
|
72
83
|
for offset in 0..self.slots.len() {
|
|
73
84
|
let index = (start + offset) % self.slots.len();
|
|
@@ -83,7 +94,7 @@ impl PinSlab {
|
|
|
83
94
|
|
|
84
95
|
/// Drop the root. Called from tokio threads: a plain atomic store,
|
|
85
96
|
/// no Ruby API. The string stays alive until the next GC sweep.
|
|
86
|
-
fn release(&self, index: usize) {
|
|
97
|
+
pub(crate) fn release(&self, index: usize) {
|
|
87
98
|
self.slots[index].store(0, Ordering::SeqCst);
|
|
88
99
|
}
|
|
89
100
|
|
|
@@ -113,9 +124,10 @@ impl PinSlab {
|
|
|
113
124
|
}
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
/// The GC-visible face of a
|
|
117
|
-
///
|
|
118
|
-
///
|
|
127
|
+
/// The GC-visible face of a PinSlab: Ruby's GC marks every rooted string
|
|
128
|
+
/// through it. `Kino::Server` holds one for its response-buffer slab for
|
|
129
|
+
/// the server's lifetime (surviving worker-ractor crashes); env_strings
|
|
130
|
+
/// registers one for its cache slab as an immortal root at init.
|
|
119
131
|
#[derive(magnus::TypedData)]
|
|
120
132
|
#[magnus(class = "Kino::Native::PinKeeper", free_immediately, mark)]
|
|
121
133
|
pub struct PinKeeper(pub Arc<PinSlab>);
|
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
|
});
|
|
@@ -138,54 +155,73 @@ fn admit(
|
|
|
138
155
|
Ok(env)
|
|
139
156
|
}
|
|
140
157
|
|
|
141
|
-
|
|
158
|
+
/// Per-worker native handle: the registry lookup and slot resolution are
|
|
159
|
+
/// paid once at worker boot instead of on every take. Created inside the
|
|
160
|
+
/// worker thread/ractor that uses it (like Request handles), so ownership
|
|
161
|
+
/// is correct by construction. Holds the server weakly: after teardown the
|
|
162
|
+
/// upgrade fails, which is the same clean shutdown signal the per-take
|
|
163
|
+
/// registry lookup used to give.
|
|
164
|
+
#[magnus::wrap(class = "Kino::Native::Worker", free_immediately)]
|
|
165
|
+
pub struct Worker {
|
|
166
|
+
server: std::sync::Weak<ServerInner>,
|
|
167
|
+
slot: Arc<WorkerSlot>,
|
|
168
|
+
}
|
|
142
169
|
|
|
143
|
-
|
|
170
|
+
/// Resolve a (server, worker) pair into a Worker handle; nil when the
|
|
171
|
+
/// server is already gone (the caller treats that as shutdown).
|
|
172
|
+
pub fn worker(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<Worker>, Error> {
|
|
144
173
|
let Some(server) = registry::try_get(server_id) else {
|
|
145
|
-
return Ok(None);
|
|
174
|
+
return Ok(None);
|
|
146
175
|
};
|
|
147
176
|
let slot = server.slot(ruby, worker_id)?;
|
|
177
|
+
Ok(Some(Worker {
|
|
178
|
+
server: Arc::downgrade(&server),
|
|
179
|
+
slot,
|
|
180
|
+
}))
|
|
181
|
+
}
|
|
148
182
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
183
|
+
impl Worker {
|
|
184
|
+
fn checkout(&self) -> Result<Option<(Arc<ServerInner>, BoxedCtx)>, Error> {
|
|
185
|
+
let Some(server) = self.server.upgrade() else {
|
|
186
|
+
return Ok(None); // server torn down → clean shutdown signal
|
|
187
|
+
};
|
|
153
188
|
|
|
154
|
-
|
|
155
|
-
|
|
189
|
+
// The previous batch is fully answered once the worker comes back.
|
|
190
|
+
self.slot.current.lock().clear();
|
|
191
|
+
self.slot.in_flight.store(0, Ordering::Relaxed);
|
|
192
|
+
self.slot.interrupted.store(false, Ordering::SeqCst);
|
|
156
193
|
|
|
157
|
-
|
|
158
|
-
/// "kino.request") or nil on shutdown. The batch-of-one hot path: no
|
|
159
|
-
/// arrays allocated at all.
|
|
160
|
-
pub fn take_one(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<RHash>, Error> {
|
|
161
|
-
match checkout(ruby, server_id, worker_id)? {
|
|
162
|
-
Some((server, slot, ctx)) => Ok(Some(admit(ruby, &server, &slot, ctx)?)),
|
|
163
|
-
None => Ok(None),
|
|
194
|
+
Ok(block_take(&server, &self.slot)?.map(|ctx| (server, ctx)))
|
|
164
195
|
}
|
|
165
|
-
}
|
|
166
196
|
|
|
167
|
-
/// Take
|
|
168
|
-
///
|
|
169
|
-
///
|
|
170
|
-
pub fn
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
197
|
+
/// Take one request; returns its env Hash (request handle inside under
|
|
198
|
+
/// "kino.request") or nil on shutdown. The batch-of-one hot path: no
|
|
199
|
+
/// arrays allocated at all.
|
|
200
|
+
pub fn take_one(ruby: &Ruby, rb_self: &Worker) -> Result<Option<RHash>, Error> {
|
|
201
|
+
match rb_self.checkout()? {
|
|
202
|
+
Some((server, ctx)) => Ok(Some(admit(ruby, &server, &rb_self.slot, ctx)?)),
|
|
203
|
+
None => Ok(None),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// Take up to `max` requests: block for the first, drain the rest
|
|
208
|
+
/// non-blocking (they only batch when the queue is already deep).
|
|
209
|
+
/// Returns nil on shutdown; otherwise an Array of env Hashes.
|
|
210
|
+
pub fn take_batch(ruby: &Ruby, rb_self: &Worker, max: usize) -> Result<Option<RArray>, Error> {
|
|
211
|
+
let Some((server, first)) = rb_self.checkout()? else {
|
|
212
|
+
return Ok(None);
|
|
213
|
+
};
|
|
179
214
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
215
|
+
let batch = ruby.ary_new_capa(max.max(1));
|
|
216
|
+
batch.push(admit(ruby, &server, &rb_self.slot, first)?)?;
|
|
217
|
+
for _ in 1..max {
|
|
218
|
+
match server.req_rx.try_recv() {
|
|
219
|
+
Ok(ctx) => batch.push(admit(ruby, &server, &rb_self.slot, ctx)?)?,
|
|
220
|
+
Err(_) => break,
|
|
221
|
+
}
|
|
186
222
|
}
|
|
223
|
+
Ok(Some(batch))
|
|
187
224
|
}
|
|
188
|
-
Ok(Some(batch))
|
|
189
225
|
}
|
|
190
226
|
|
|
191
227
|
/// The fused hot path: answer `request` (complete response in one shot)
|
|
@@ -194,28 +230,104 @@ pub fn take_batch(
|
|
|
194
230
|
pub fn respond_and_take_one(
|
|
195
231
|
ruby: &Ruby,
|
|
196
232
|
request: &Request,
|
|
197
|
-
|
|
198
|
-
worker_id: usize,
|
|
233
|
+
worker: magnus::typed_data::Obj<Worker>,
|
|
199
234
|
status: u16,
|
|
200
235
|
headers: RHash,
|
|
201
236
|
body: RString,
|
|
202
237
|
) -> Result<Option<RHash>, Error> {
|
|
203
238
|
crate::request::respond_simple(ruby, request, status, headers, body)?;
|
|
204
|
-
take_one(ruby,
|
|
239
|
+
Worker::take_one(ruby, &worker)
|
|
205
240
|
}
|
|
206
241
|
|
|
207
242
|
/// Batch variant of the fused call.
|
|
208
|
-
#[allow(clippy::too_many_arguments)]
|
|
209
243
|
pub fn respond_and_take(
|
|
210
244
|
ruby: &Ruby,
|
|
211
245
|
request: &Request,
|
|
212
|
-
|
|
213
|
-
worker_id: usize,
|
|
246
|
+
worker: magnus::typed_data::Obj<Worker>,
|
|
214
247
|
max: usize,
|
|
215
248
|
status: u16,
|
|
216
249
|
headers: RHash,
|
|
217
250
|
body: RString,
|
|
218
251
|
) -> Result<Option<RArray>, Error> {
|
|
219
252
|
crate::request::respond_simple(ruby, request, status, headers, body)?;
|
|
220
|
-
take_batch(ruby,
|
|
253
|
+
Worker::take_batch(ruby, &worker, max)
|
|
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
|
+
}
|
|
221
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,8 +117,18 @@ 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,
|
|
129
|
+
/// HTTP/2 serving (ALPN over TLS, prior-knowledge h2c on plaintext);
|
|
130
|
+
/// false pins every connection to HTTP/1.
|
|
131
|
+
pub http2: bool,
|
|
118
132
|
/// The socket file of a `unix://` bind, removed at shutdown.
|
|
119
133
|
pub unix_path: Option<std::path::PathBuf>,
|
|
120
134
|
/// Native access log sink (None unless log_requests is on).
|
|
@@ -155,6 +169,11 @@ pub struct WorkerSlot {
|
|
|
155
169
|
/// Set by the quarantine monitor when this slot is abandoned as wedged:
|
|
156
170
|
/// excluded from wedge detection, and its busy_ms is reported as 0.
|
|
157
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,
|
|
158
177
|
}
|
|
159
178
|
|
|
160
179
|
/// Per-lane depth cap: small, so a slow handler can only ever delay this
|
|
@@ -248,8 +267,35 @@ impl WorkerSlot {
|
|
|
248
267
|
in_flight: AtomicUsize::new(0),
|
|
249
268
|
last_started_ms: AtomicU64::new(0),
|
|
250
269
|
quarantined: std::sync::atomic::AtomicBool::new(false),
|
|
270
|
+
retired: std::sync::atomic::AtomicBool::new(false),
|
|
251
271
|
}
|
|
252
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
|
+
}
|
|
253
299
|
}
|
|
254
300
|
|
|
255
301
|
static REGISTRY: OnceLock<RwLock<HashMap<u64, Arc<ServerInner>>>> = OnceLock::new();
|
|
@@ -347,13 +393,18 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
|
|
|
347
393
|
state: std::sync::atomic::AtomicU8::new(STATE_BOOTING),
|
|
348
394
|
respawns: AtomicU64::new(0),
|
|
349
395
|
quarantine_replacements: AtomicU64::new(0),
|
|
396
|
+
active_workers: AtomicUsize::new(0),
|
|
397
|
+
scale_ups: AtomicU64::new(0),
|
|
398
|
+
scale_downs: AtomicU64::new(0),
|
|
350
399
|
topology: Topology {
|
|
351
400
|
mode: "threaded".to_string(),
|
|
352
401
|
workers: 0,
|
|
402
|
+
max_workers: 0,
|
|
353
403
|
threads: 0,
|
|
354
404
|
batch: 1,
|
|
355
405
|
},
|
|
356
406
|
https: false,
|
|
407
|
+
http2: true,
|
|
357
408
|
unix_path: None,
|
|
358
409
|
access_log: None,
|
|
359
410
|
lanes,
|
|
@@ -487,6 +538,18 @@ mod tests {
|
|
|
487
538
|
assert_eq!(server.topology.batch, 1);
|
|
488
539
|
}
|
|
489
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
|
+
|
|
490
553
|
#[test]
|
|
491
554
|
fn fresh_slot_has_zeroed_per_worker_sensors() {
|
|
492
555
|
let server = test_server(false, 4);
|
|
@@ -506,6 +569,88 @@ mod tests {
|
|
|
506
569
|
assert_eq!(server.quarantine_replacements.load(Ordering::Relaxed), 0);
|
|
507
570
|
}
|
|
508
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
|
+
|
|
509
654
|
#[test]
|
|
510
655
|
fn queue_histogram_buckets_by_wait() {
|
|
511
656
|
let h = QueueHistogram::new();
|