kino 0.1.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.
@@ -0,0 +1,207 @@
1
+ //! The worker-side bridge: batched request intake and the fused
2
+ //! respond-and-take call. This is where FFI crossings per request went
3
+ //! from three in early designs (take, env, respond) to amortized ~one.
4
+ //!
5
+ //! Blocking discipline (everywhere in this crate): bounded `recv_timeout`
6
+ //! ticks + an AtomicBool interrupt flag. No flume::Selector: it loses
7
+ //! wakeups under churn, observed as workers going permanently deaf to a
8
+ //! non-empty queue after ~100k requests.
9
+
10
+ use std::cell::RefCell;
11
+ use std::sync::atomic::Ordering;
12
+ use std::sync::Arc;
13
+ use std::time::Duration;
14
+
15
+ use magnus::prelude::*;
16
+ use magnus::{Error, RArray, RHash, RString, Ruby};
17
+
18
+ use crate::gvl;
19
+ use crate::registry::{self, BoxedCtx, ServerInner, WorkerSlot};
20
+ use crate::request::Request;
21
+
22
+ pub const TICK: Duration = Duration::from_millis(50);
23
+
24
+ /// None = shutdown (queue closed or interrupted); the caller can't tell
25
+ /// the difference and doesn't need to.
26
+ type Taken = Option<BoxedCtx>;
27
+
28
+ /// Block until one request arrives (GVL released, interruptible).
29
+ /// No busy-poll before parking, deliberately: the wake-per-request futex
30
+ /// cost is real (~20% of cycles at saturation, per perf), but a measured
31
+ /// 20µs spin made things WORSE on oversubscribed cores: spinners steal
32
+ /// exactly the CPU the tokio threads need.
33
+ fn block_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Taken {
34
+ if slot.lane_rx.is_some() {
35
+ return lane_take(server, slot);
36
+ }
37
+ let req_rx = &server.req_rx;
38
+
39
+ // Fast path: a request is already queued (the common case under load).
40
+ // try_recv never blocks, so the whole GVL release/reacquire (two
41
+ // scheduler round-trips per request) is skipped entirely.
42
+ match req_rx.try_recv() {
43
+ Ok(ctx) => Some(ctx),
44
+ Err(flume::TryRecvError::Disconnected) => None,
45
+ Err(flume::TryRecvError::Empty) => {
46
+ gvl::interruptible(&slot.interrupted, || match req_rx.recv_timeout(TICK) {
47
+ Ok(ctx) => Some(Some(ctx)),
48
+ Err(flume::RecvTimeoutError::Timeout) => None,
49
+ Err(flume::RecvTimeoutError::Disconnected) => Some(None),
50
+ })
51
+ .flatten()
52
+ }
53
+ }
54
+ }
55
+
56
+ /// Lane-mode take: own lane first (no wake needed while the dispatcher
57
+ /// keeps feeding an awake lane), then steal from siblings, then park on
58
+ /// the own lane with the parked flag raised so the dispatcher avoids it.
59
+ fn lane_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Taken {
60
+ let lane_rx = slot.lane_rx.as_ref().expect("lane_take without lane");
61
+
62
+ let steal = || -> Option<BoxedCtx> {
63
+ let slots = server.slots.read();
64
+ for other in slots.iter() {
65
+ if Arc::ptr_eq(other, slot) {
66
+ continue;
67
+ }
68
+ if let Some(rx) = other.lane_rx.as_ref() {
69
+ if let Ok(ctx) = rx.try_recv() {
70
+ return Some(ctx);
71
+ }
72
+ }
73
+ }
74
+ None
75
+ };
76
+
77
+ // Hot path, GVL still held: own lane, then a steal sweep.
78
+ match lane_rx.try_recv() {
79
+ Ok(ctx) => return Some(ctx),
80
+ Err(flume::TryRecvError::Disconnected) => return None,
81
+ Err(flume::TryRecvError::Empty) => {}
82
+ }
83
+ if let Some(ctx) = steal() {
84
+ return Some(ctx);
85
+ }
86
+
87
+ // Park. The flag-then-recheck order closes the race with a dispatcher
88
+ // that read parked=false just before we set it: anything it sent lands
89
+ // in the lane, and recv_timeout checks the queue before sleeping.
90
+ slot.parked.store(true, Ordering::SeqCst);
91
+ let taken = gvl::interruptible(&slot.interrupted, || {
92
+ match lane_rx.recv_timeout(TICK) {
93
+ Ok(ctx) => Some(Some(ctx)),
94
+ // Periodic steal so a backlog behind a slow sibling can't
95
+ // outlive a tick.
96
+ Err(flume::RecvTimeoutError::Timeout) => steal().map(Some),
97
+ Err(flume::RecvTimeoutError::Disconnected) => Some(None),
98
+ }
99
+ })
100
+ .flatten();
101
+ slot.parked.store(false, Ordering::SeqCst);
102
+ taken
103
+ }
104
+
105
+ /// Wrap a ctx into its env Hash, with the Ruby request handle embedded
106
+ /// under the frozen "kino.request" key (one Hash carries everything, no
107
+ /// per-request pair array). Registered in the slot's in-flight list;
108
+ /// created inside the calling ractor, so handle ownership is correct by
109
+ /// construction.
110
+ fn admit(
111
+ ruby: &Ruby,
112
+ server: &ServerInner,
113
+ slot: &Arc<WorkerSlot>,
114
+ mut ctx: BoxedCtx,
115
+ ) -> Result<RHash, Error> {
116
+ server.served.fetch_add(1, Ordering::Relaxed);
117
+ slot.current.lock().push(Arc::downgrade(&ctx.responder));
118
+ // Wire the slot into the request so blocked body reads/writes are
119
+ // interruptible the same way the queue pop is.
120
+ ctx.slot = Some(slot.clone());
121
+ let env = crate::request::build_env(ruby, &ctx)?;
122
+ let request = ruby.obj_wrap(Request(RefCell::new(*ctx)));
123
+ let key = ruby.get_inner(crate::env_strings::get().kino_request);
124
+ env.aset(key, request.as_value())?;
125
+ Ok(env)
126
+ }
127
+
128
+ type Checkout = (Arc<ServerInner>, Arc<WorkerSlot>, BoxedCtx);
129
+
130
+ fn checkout(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<Checkout>, Error> {
131
+ let Some(server) = registry::try_get(server_id) else {
132
+ return Ok(None); // server torn down → clean shutdown signal
133
+ };
134
+ let slot = server.slot(ruby, worker_id)?;
135
+
136
+ // The previous batch is fully answered once the worker comes back.
137
+ slot.current.lock().clear();
138
+ slot.interrupted.store(false, Ordering::SeqCst);
139
+
140
+ Ok(block_take(&server, &slot).map(|ctx| (server, slot, ctx)))
141
+ }
142
+
143
+ /// Take one request; returns its env Hash (request handle inside under
144
+ /// "kino.request") or nil on shutdown. The batch-of-one hot path: no
145
+ /// arrays allocated at all.
146
+ pub fn take_one(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<RHash>, Error> {
147
+ match checkout(ruby, server_id, worker_id)? {
148
+ Some((server, slot, ctx)) => Ok(Some(admit(ruby, &server, &slot, ctx)?)),
149
+ None => Ok(None),
150
+ }
151
+ }
152
+
153
+ /// Take up to `max` requests: block for the first, drain the rest
154
+ /// non-blocking (they only batch when the queue is already deep).
155
+ /// Returns nil on shutdown; otherwise an Array of env Hashes.
156
+ pub fn take_batch(
157
+ ruby: &Ruby,
158
+ server_id: u64,
159
+ worker_id: usize,
160
+ max: usize,
161
+ ) -> Result<Option<RArray>, Error> {
162
+ let Some((server, slot, first)) = checkout(ruby, server_id, worker_id)? else {
163
+ return Ok(None);
164
+ };
165
+
166
+ let batch = ruby.ary_new_capa(max.max(1));
167
+ batch.push(admit(ruby, &server, &slot, first)?)?;
168
+ for _ in 1..max {
169
+ match server.req_rx.try_recv() {
170
+ Ok(ctx) => batch.push(admit(ruby, &server, &slot, ctx)?)?,
171
+ Err(_) => break,
172
+ }
173
+ }
174
+ Ok(Some(batch))
175
+ }
176
+
177
+ /// The fused hot path: answer `request` (complete response in one shot)
178
+ /// and immediately take the next request. One FFI crossing per request
179
+ /// once the loop is warm.
180
+ pub fn respond_and_take_one(
181
+ ruby: &Ruby,
182
+ request: &Request,
183
+ server_id: u64,
184
+ worker_id: usize,
185
+ status: u16,
186
+ headers: RHash,
187
+ body: RString,
188
+ ) -> Result<Option<RHash>, Error> {
189
+ crate::request::respond_simple(ruby, request, status, headers, body)?;
190
+ take_one(ruby, server_id, worker_id)
191
+ }
192
+
193
+ /// Batch variant of the fused call.
194
+ #[allow(clippy::too_many_arguments)]
195
+ pub fn respond_and_take(
196
+ ruby: &Ruby,
197
+ request: &Request,
198
+ server_id: u64,
199
+ worker_id: usize,
200
+ max: usize,
201
+ status: u16,
202
+ headers: RHash,
203
+ body: RString,
204
+ ) -> Result<Option<RArray>, Error> {
205
+ crate::request::respond_simple(ruby, request, status, headers, body)?;
206
+ take_batch(ruby, server_id, worker_id, max)
207
+ }
@@ -0,0 +1,268 @@
1
+ //! Global server registry. Ruby never holds a pointer to native state:
2
+ //! workers receive plain integers (server id, worker id), both
3
+ //! Ractor-shareable, and every native call resolves them here. This is what
4
+ //! keeps TypedData objects from ever crossing a ractor boundary.
5
+
6
+ use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
7
+ use std::sync::{Arc, OnceLock, Weak};
8
+
9
+ use parking_lot::{Mutex, RwLock};
10
+
11
+ use crate::request::RequestCtx;
12
+ use crate::response::Responder;
13
+
14
+ /// Requests travel through channels boxed: one heap allocation at accept
15
+ /// time instead of moving ~300 bytes by value through every channel hop.
16
+ pub type BoxedCtx = Box<RequestCtx>;
17
+
18
+ /// Probed on every take; keys are our own ids, so ahash over SipHash.
19
+ type HashMap<K, V> = std::collections::HashMap<K, V, ahash::RandomState>;
20
+
21
+ /// One per `Kino::Server`. Owns the tokio runtime, the request queue and the
22
+ /// worker slots.
23
+ pub struct ServerInner {
24
+ pub id: u64,
25
+ /// Senders' side of the request queue. `close_queue` takes it; once all
26
+ /// clones drop, blocked workers see Disconnected and exit their loops.
27
+ pub req_tx: Mutex<Option<flume::Sender<BoxedCtx>>>,
28
+ pub req_rx: flume::Receiver<BoxedCtx>,
29
+ /// Signals the accept loop to stop. Watch channel: `true` = draining.
30
+ pub shutdown_tx: tokio::sync::watch::Sender<bool>,
31
+ /// Runtime is kept so we can shut it down explicitly; in an Option so
32
+ /// `shutdown_runtime` can take ownership out of the Arc.
33
+ pub runtime: Mutex<Option<tokio::runtime::Runtime>>,
34
+ pub slots: RwLock<Vec<Arc<WorkerSlot>>>,
35
+ pub in_flight: AtomicUsize,
36
+ /// Requests handed to Ruby workers (admitted), and requests rejected
37
+ /// with a 503 (queue full / draining). Relaxed: stats-only counters.
38
+ pub served: AtomicU64,
39
+ pub rejected: AtomicU64,
40
+ pub queue_timeout_ms: u64,
41
+ /// 0 = no request timeout; otherwise the response head must arrive
42
+ /// within this many ms or the client gets a 504.
43
+ pub request_timeout_ms: u64,
44
+ pub timeouts: AtomicU64,
45
+ pub https: bool,
46
+ /// Native access log sink (None unless log_requests is on).
47
+ pub access_log: Option<crate::logsink::Sink>,
48
+ /// Lane-dispatch mode: per-worker queues, awake-preferring dispatch.
49
+ pub lanes: bool,
50
+ /// Round-robin cursor for lane dispatch.
51
+ pub lane_cursor: AtomicUsize,
52
+ }
53
+
54
+ /// One per worker *thread* (slot count = workers × threads). The interrupt
55
+ /// flag is the UBF target and the shutdown kick: blocking natives poll it
56
+ /// between bounded waits (flume::Selector proved to lose wakeups under
57
+ /// churn, so no select-style blocking anywhere). The in-flight list lets
58
+ /// the supervisor 500 every request a dead ractor was holding; workers
59
+ /// take requests in small batches, so there can be several.
60
+ pub struct WorkerSlot {
61
+ pub interrupted: std::sync::atomic::AtomicBool,
62
+ pub current: Mutex<smallvec::SmallVec<[Weak<Responder>; 8]>>,
63
+ /// Lane mode only: this worker's private queue and its parked flag.
64
+ /// The dispatcher prefers awake (non-parked) lanes so a hot worker
65
+ /// keeps taking without ever paying the futex wake.
66
+ pub lane_tx: Mutex<Option<flume::Sender<BoxedCtx>>>,
67
+ pub lane_rx: Option<flume::Receiver<BoxedCtx>>,
68
+ pub parked: std::sync::atomic::AtomicBool,
69
+ }
70
+
71
+ /// Per-lane depth cap: small, so a slow handler can only ever delay this
72
+ /// many queued neighbors (work stealing rescues them anyway).
73
+ pub const LANE_DEPTH: usize = 4;
74
+
75
+ impl WorkerSlot {
76
+ fn new(lanes: bool) -> Self {
77
+ let (lane_tx, lane_rx) = if lanes {
78
+ let (tx, rx) = flume::bounded(LANE_DEPTH);
79
+ (Some(tx), Some(rx))
80
+ } else {
81
+ (None, None)
82
+ };
83
+ WorkerSlot {
84
+ interrupted: std::sync::atomic::AtomicBool::new(false),
85
+ current: Mutex::new(smallvec::SmallVec::new()),
86
+ lane_tx: Mutex::new(lane_tx),
87
+ lane_rx,
88
+ parked: std::sync::atomic::AtomicBool::new(false),
89
+ }
90
+ }
91
+ }
92
+
93
+ static REGISTRY: OnceLock<RwLock<HashMap<u64, Arc<ServerInner>>>> = OnceLock::new();
94
+ static NEXT_SERVER_ID: AtomicU64 = AtomicU64::new(1);
95
+
96
+ fn registry() -> &'static RwLock<HashMap<u64, Arc<ServerInner>>> {
97
+ REGISTRY.get_or_init(|| RwLock::new(HashMap::default()))
98
+ }
99
+
100
+ pub fn next_server_id() -> u64 {
101
+ NEXT_SERVER_ID.fetch_add(1, Ordering::Relaxed)
102
+ }
103
+
104
+ pub fn insert(server: Arc<ServerInner>) {
105
+ registry().write().insert(server.id, server);
106
+ }
107
+
108
+ pub fn remove(id: u64) -> Option<Arc<ServerInner>> {
109
+ registry().write().remove(&id)
110
+ }
111
+
112
+ pub fn get(ruby: &magnus::Ruby, id: u64) -> Result<Arc<ServerInner>, magnus::Error> {
113
+ registry().read().get(&id).cloned().ok_or_else(|| {
114
+ magnus::Error::new(ruby.exception_arg_error(), format!("unknown server {id}"))
115
+ })
116
+ }
117
+
118
+ /// Tolerant lookup for lifecycle paths: a worker waking up after teardown
119
+ /// must see "server gone" as a clean shutdown signal, not an exception.
120
+ pub fn try_get(id: u64) -> Option<Arc<ServerInner>> {
121
+ registry().read().get(&id).cloned()
122
+ }
123
+
124
+ impl ServerInner {
125
+ /// Per-lane queue depths; None unless lane dispatch is on.
126
+ pub fn lane_depths(&self) -> Option<Vec<usize>> {
127
+ if !self.lanes {
128
+ return None;
129
+ }
130
+ Some(
131
+ self.slots
132
+ .read()
133
+ .iter()
134
+ .filter_map(|s| s.lane_rx.as_ref().map(|rx| rx.len()))
135
+ .collect(),
136
+ )
137
+ }
138
+
139
+ /// Requests waiting anywhere: the global queue plus any open lanes.
140
+ pub fn queued(&self) -> usize {
141
+ self.req_rx.len() + self.lane_depths().map_or(0, |d| d.iter().sum())
142
+ }
143
+
144
+ pub fn register_worker(&self) -> usize {
145
+ let mut slots = self.slots.write();
146
+ slots.push(Arc::new(WorkerSlot::new(self.lanes)));
147
+ slots.len() - 1
148
+ }
149
+
150
+ pub fn slot(
151
+ &self,
152
+ ruby: &magnus::Ruby,
153
+ worker_id: usize,
154
+ ) -> Result<Arc<WorkerSlot>, magnus::Error> {
155
+ self.slots.read().get(worker_id).cloned().ok_or_else(|| {
156
+ magnus::Error::new(
157
+ ruby.exception_arg_error(),
158
+ format!("unknown worker {worker_id}"),
159
+ )
160
+ })
161
+ }
162
+ }
163
+
164
+ /// A ServerInner with no runtime and no registry entry, for pure-Rust
165
+ /// tests of queue accounting and dispatch. Ids are unique so tests that
166
+ /// do insert into the global registry can run in parallel.
167
+ #[cfg(test)]
168
+ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
169
+ let (req_tx, req_rx) = flume::bounded(queue_depth);
170
+ let (shutdown_tx, _shutdown_rx) = tokio::sync::watch::channel(false);
171
+ Arc::new(ServerInner {
172
+ id: next_server_id(),
173
+ req_tx: Mutex::new(Some(req_tx)),
174
+ req_rx,
175
+ shutdown_tx,
176
+ runtime: Mutex::new(None),
177
+ slots: RwLock::new(Vec::new()),
178
+ in_flight: AtomicUsize::new(0),
179
+ served: AtomicU64::new(0),
180
+ rejected: AtomicU64::new(0),
181
+ queue_timeout_ms: 10,
182
+ request_timeout_ms: 0,
183
+ timeouts: AtomicU64::new(0),
184
+ https: false,
185
+ access_log: None,
186
+ lanes,
187
+ lane_cursor: AtomicUsize::new(0),
188
+ })
189
+ }
190
+
191
+ #[cfg(test)]
192
+ mod tests {
193
+ use super::*;
194
+ use crate::request::test_ctx;
195
+
196
+ #[test]
197
+ fn worker_registration_hands_out_sequential_slot_ids() {
198
+ let server = test_server(false, 4);
199
+
200
+ assert_eq!(server.register_worker(), 0);
201
+ assert_eq!(server.register_worker(), 1);
202
+ assert_eq!(server.slots.read().len(), 2);
203
+ // Shared-queue mode creates no lane channels.
204
+ assert!(server.slots.read()[0].lane_rx.is_none());
205
+ }
206
+
207
+ #[test]
208
+ fn lane_mode_slots_get_bounded_lanes() {
209
+ let server = test_server(true, 4);
210
+ server.register_worker();
211
+
212
+ let slots = server.slots.read();
213
+ let lane_rx = slots[0].lane_rx.as_ref().expect("lane created");
214
+ assert_eq!(lane_rx.capacity(), Some(LANE_DEPTH));
215
+ assert!(slots[0].lane_tx.lock().is_some());
216
+ }
217
+
218
+ #[test]
219
+ fn queued_counts_the_shared_queue() {
220
+ let server = test_server(false, 4);
221
+ assert_eq!(server.queued(), 0);
222
+
223
+ let tx = server.req_tx.lock().clone().expect("queue open");
224
+ tx.send(test_ctx()).expect("queue has room");
225
+ tx.send(test_ctx()).expect("queue has room");
226
+
227
+ assert_eq!(server.queued(), 2);
228
+ assert!(server.lane_depths().is_none());
229
+ }
230
+
231
+ #[test]
232
+ fn queued_includes_lanes_and_lane_depths_reports_per_slot() {
233
+ let server = test_server(true, 4);
234
+ server.register_worker();
235
+ server.register_worker();
236
+
237
+ let slots = server.slots.read();
238
+ let lane0 = slots[0].lane_tx.lock().clone().expect("lane open");
239
+ lane0.send(test_ctx()).expect("lane has room");
240
+ drop(slots);
241
+
242
+ assert_eq!(server.lane_depths(), Some(vec![1, 0]));
243
+ assert_eq!(server.queued(), 1);
244
+ }
245
+
246
+ #[test]
247
+ fn registry_lifecycle_insert_lookup_remove() {
248
+ let server = test_server(false, 1);
249
+ let id = server.id;
250
+
251
+ assert!(try_get(id).is_none());
252
+ insert(server);
253
+ assert!(try_get(id).is_some());
254
+
255
+ let removed = remove(id).expect("was registered");
256
+ assert_eq!(removed.id, id);
257
+ // Late wakers see "gone" as a clean shutdown signal, not a panic.
258
+ assert!(try_get(id).is_none());
259
+ assert!(remove(id).is_none());
260
+ }
261
+
262
+ #[test]
263
+ fn server_ids_are_unique() {
264
+ let a = next_server_id();
265
+ let b = next_server_id();
266
+ assert_ne!(a, b);
267
+ }
268
+ }