gunnar_ssh/pipeline.rs
1//! **The standing pipeline: warm machinery per class, and an operation is data.**
2//!
3//! # What this replaces
4//!
5//! Today a git operation builds its own world. It spawns what it needs,
6//! allocates what it needs, opens what it needs, and throws all of it away when
7//! the client disconnects. That is per-request *initialisation*, and it is the
8//! largest avoidable cost in the server:
9//!
10//! MEASURED on the reference desktop 2026-08-14, a `linux.git` clone —
11//!
12//! | | |
13//! |---|---:|
14//! | peak RSS, gunnar | **2314 MB** |
15//! | peak RSS, gitea, identical clone and identical wire bytes | 122 MB |
16//! | cycles spent copying bytes that never needed to move | **~20%** |
17//! | `kernel_init_pages` — the kernel ZEROING pages for per-op allocations | 4.7% of the top profile |
18//!
19//! (`vs_forge_clone`; and 39
20//! `perf record` captures of `gunnar serve` during the mirror clone.)
21//!
22//! A pipeline that is already standing pays none of that. The threads exist, the
23//! buffers exist, the archive is already mapped; a request adds a **descriptor**
24//! to a queue and a worker that is already awake picks it up.
25//!
26//! ```text
27//! at BOOT, once, for the life of the process:
28//! pipeline[LsRemote] ─┐
29//! pipeline[Fetch] ├─ N workers parked on a condvar │ arena each
30//! pipeline[Clone] │
31//! pipeline[Push] │
32//! pipeline[Gc] ─┘
33//!
34//! per request:
35//! classify ─► admission gate ─► submit(descriptor) ─► a parked worker wakes
36//! │
37//! └─ DENY "server busy", before a single allocation
38//! ```
39//!
40//! # An operation is DATA, not a program
41//!
42//! Two `Clone` operations do not start two clone engines. They are two
43//! descriptors in one queue, drained by the fan that is already running. This is
44//! the distinction the whole design rests on, and it is why `submit` takes a
45//! value rather than a closure that captures a world.
46//!
47//! # Why a condvar and not a channel crate
48//!
49//! No new dependency, and the shape is the point: a worker parked in
50//! [`Condvar::wait`] is a thread in `futex_do_wait`, which is exactly what a
51//! hang dump of the live server already showed — **42 threads, 40 of them
52//! parked on 40 distinct futexes**. The warm fan is not a new idea being
53//! introduced here; it already exists in the process. What is missing is
54//! anything handing it work, which is what this module is.
55//!
56//! # What this module does NOT do
57//!
58//! It does not make one operation faster. A single `linux.git` clone gets one
59//! slot and one worker, and it will take exactly as long as it took before —
60//! the 25 minutes measured for that clone is serial work *inside* the operation,
61//! and only the engine's own fan-out (znippy's `emit_pack` phase 1) touches
62//! that. This module is concurrency **across** operations. Conflating the two
63//! is how a "pipeline" gets built and then measured as having changed nothing.
64
65use std::collections::VecDeque;
66use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
67use std::sync::{Arc, Condvar, Mutex};
68use std::thread::JoinHandle;
69
70use crate::governor::OpClass;
71
72/// How many workers a class's fan is built with.
73///
74/// Deliberately per-class: `LsRemote` is answered in milliseconds and wants
75/// enough workers that a burst never queues, while `Gc` is long, rare, and
76/// wants at most a couple so that maintenance cannot crowd out serving.
77pub fn default_workers(class: OpClass, cores: usize) -> usize {
78 match class {
79 // ── I/O-BOUND, so sized for CONCURRENCY and not for cores ───────────
80 //
81 // A git transfer spends its life waiting on a socket, not computing.
82 // Sizing these fans as if they were CPU-bound is a mistake I made and
83 // the bench caught: at `(cores/2).clamp(2,8)` a 32-core box served
84 // `multiuser_scaling`'s 102 concurrent clients with EIGHT workers, and
85 // **16 of 102 clients did not complete** — not refused, which they
86 // would have understood, but queued behind eight streaming packs until
87 // they timed out. Zero `transfer.refused` in the whole run, which is
88 // what made it look like a mystery rather than a queue.
89 //
90 // That was strictly worse than what it replaced: tokio's blocking pool
91 // would have spawned up to 512 threads and served all 102.
92 //
93 // Shedding overload is the ADMISSION GATE's job — CoDel sees the queue
94 // standing and refuses, in the client's own protocol. The fan's job is
95 // only to stop unbounded thread growth. So it is wide enough that
96 // ordinary concurrency is never artificially serialised, and the
97 // governor decides when there is too much.
98 OpClass::LsRemote => (cores * 4).clamp(16, 256),
99 OpClass::Fetch => (cores * 4).clamp(16, 256),
100 OpClass::Clone => (cores * 4).clamp(16, 256),
101 OpClass::Push => (cores * 4).clamp(16, 256),
102 // Maintenance yields to serving, and IS bounded by CPU: a repack is
103 // real work, not a stream. Two, so one long repack cannot crowd out the
104 // other repositories.
105 OpClass::Gc => 2,
106 }
107}
108
109/// What a submission was refused for. Both are "server busy" to the client; the
110/// distinction is for the operator reading logs.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum SubmitError {
113 /// The class's queue is full. Backpressure working as designed.
114 QueueFull,
115 /// The pipeline is shutting down and will not start new work.
116 ShuttingDown,
117}
118
119impl std::fmt::Display for SubmitError {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 match self {
122 SubmitError::QueueFull => {
123 f.write_str("server busy: the queue for this operation class is full")
124 }
125 SubmitError::ShuttingDown => f.write_str("server busy: shutting down"),
126 }
127 }
128}
129
130struct Queue<T> {
131 items: Mutex<VecDeque<T>>,
132 /// Workers park here. A parked worker is a thread in `futex_do_wait`.
133 ready: Condvar,
134 stopping: AtomicBool,
135 depth: usize,
136 /// Test-only: runs in [`Queue::pop_blocking`] with the lock held, after `stopping` was
137 /// read and before the worker waits — the window a lost wake-up lives in.
138 #[cfg(test)]
139 before_wait: std::sync::OnceLock<Box<dyn Fn() + Send + Sync>>,
140}
141
142impl<T> Queue<T> {
143 fn push(&self, item: T) -> Result<usize, SubmitError> {
144 let mut q = self.items.lock().expect("pipeline queue poisoned");
145 // Read under the lock `shutdown` sets it under: nothing is queued after the
146 // workers were told to stop.
147 if self.stopping.load(Ordering::Acquire) {
148 return Err(SubmitError::ShuttingDown);
149 }
150 if q.len() >= self.depth {
151 return Err(SubmitError::QueueFull);
152 }
153 q.push_back(item);
154 let queued = q.len();
155 drop(q);
156 // One waiter, not all: waking every worker for one item is a thundering
157 // herd that costs more than the work.
158 self.ready.notify_one();
159 Ok(queued)
160 }
161
162 fn pop_blocking(&self) -> Option<T> {
163 let mut q = self.items.lock().expect("pipeline queue poisoned");
164 loop {
165 if let Some(item) = q.pop_front() {
166 return Some(item);
167 }
168 if self.stopping.load(Ordering::Acquire) {
169 return None;
170 }
171 #[cfg(test)]
172 if let Some(hook) = self.before_wait.get() {
173 hook();
174 }
175 q = self.ready.wait(q).expect("pipeline queue poisoned");
176 }
177 }
178}
179
180/// One class's standing fan: N workers, built once, parked until there is work.
181pub struct Pipeline<T: Send + 'static> {
182 class: OpClass,
183 queue: Arc<Queue<T>>,
184 workers: Mutex<Vec<JoinHandle<()>>>,
185 /// Operations that have been handed to a worker. Applied output: it is the
186 /// only thing that distinguishes "the fan ran it" from "the fan exists".
187 served: Arc<AtomicU64>,
188}
189
190impl<T: Send + 'static> Pipeline<T> {
191 /// Build the fan and START it. Threads are spawned HERE, at boot — never on
192 /// the request path. That is the entire point of the type.
193 pub fn start<F>(class: OpClass, workers: usize, depth: usize, run: F) -> Pipeline<T>
194 where
195 F: Fn(T) + Send + Sync + 'static,
196 {
197 let queue = Arc::new(Queue {
198 items: Mutex::new(VecDeque::with_capacity(depth)),
199 ready: Condvar::new(),
200 stopping: AtomicBool::new(false),
201 depth,
202 #[cfg(test)]
203 before_wait: std::sync::OnceLock::new(),
204 });
205 let served = Arc::new(AtomicU64::new(0));
206 let run = Arc::new(run);
207
208 let mut handles = Vec::with_capacity(workers);
209 for i in 0..workers {
210 let q = Arc::clone(&queue);
211 let run = Arc::clone(&run);
212 let served = Arc::clone(&served);
213 let name = format!("gunnar-{class:?}-{i}").to_lowercase();
214 let h = std::thread::Builder::new()
215 .name(name)
216 .spawn(move || {
217 while let Some(item) = q.pop_blocking() {
218 served.fetch_add(1, Ordering::Relaxed);
219 run(item);
220 }
221 })
222 .expect("spawning a pipeline worker at boot");
223 handles.push(h);
224 }
225 Pipeline {
226 class,
227 queue,
228 workers: Mutex::new(handles),
229 served,
230 }
231 }
232
233 /// **The same fan, but each worker owns an ARENA built once at boot.**
234 ///
235 /// This is the half of "zero initialisation" the plain [`start`](Self::start)
236 /// does not give you. A standing fan removes the thread spawn; it does not
237 /// remove the buffer. If the work still does `Vec::with_capacity` per
238 /// operation, the kernel still zeroes fresh pages for it — which is
239 /// `kernel_init_pages`, **4.7% of the top profile** on a `linux.git` clone,
240 /// the single largest kernel cost measured there.
241 ///
242 /// `make` runs **once per worker, at boot**. `run` receives `&mut A`, so the
243 /// buffer that was grown for the first operation is still there, at capacity,
244 /// for the ten-thousandth. The caller is responsible for `clear()`-ing
245 /// rather than reallocating — `clear` keeps capacity, which is the whole
246 /// point, and is why this takes `&mut A` instead of handing back an owned
247 /// buffer per call.
248 #[allow(clippy::type_complexity)]
249 pub fn start_with_arena<A, M, F>(
250 class: OpClass,
251 workers: usize,
252 depth: usize,
253 make: M,
254 run: F,
255 ) -> Pipeline<T>
256 where
257 A: Send + 'static,
258 M: Fn(usize) -> A + Send + Sync + 'static,
259 F: Fn(&mut A, T) + Send + Sync + 'static,
260 {
261 let queue = Arc::new(Queue {
262 items: Mutex::new(VecDeque::with_capacity(depth)),
263 ready: Condvar::new(),
264 stopping: AtomicBool::new(false),
265 depth,
266 #[cfg(test)]
267 before_wait: std::sync::OnceLock::new(),
268 });
269 let served = Arc::new(AtomicU64::new(0));
270 let run = Arc::new(run);
271 let make = Arc::new(make);
272
273 let mut handles = Vec::with_capacity(workers);
274 for i in 0..workers {
275 let q = Arc::clone(&queue);
276 let run = Arc::clone(&run);
277 let make = Arc::clone(&make);
278 let served = Arc::clone(&served);
279 let name = format!("gunnar-{class:?}-{i}").to_lowercase();
280 let h = std::thread::Builder::new()
281 .name(name)
282 .spawn(move || {
283 // ONCE, at boot, before this worker takes any work.
284 let mut arena = make(i);
285 while let Some(item) = q.pop_blocking() {
286 served.fetch_add(1, Ordering::Relaxed);
287 run(&mut arena, item);
288 }
289 })
290 .expect("spawning a pipeline worker at boot");
291 handles.push(h);
292 }
293 Pipeline {
294 class,
295 queue,
296 workers: Mutex::new(handles),
297 served,
298 }
299 }
300
301 /// Hand an operation to the standing fan. Allocates nothing but the queue
302 /// slot; spawns nothing.
303 pub fn submit(&self, item: T) -> Result<usize, SubmitError> {
304 self.queue.push(item)
305 }
306
307 /// The class this fan serves.
308 pub fn class(&self) -> OpClass {
309 self.class
310 }
311
312 /// Operations a worker has actually picked up.
313 pub fn served(&self) -> u64 {
314 self.served.load(Ordering::Relaxed)
315 }
316
317 /// Items waiting for a worker.
318 pub fn queued(&self) -> usize {
319 self.queue
320 .items
321 .lock()
322 .expect("pipeline queue poisoned")
323 .len()
324 }
325
326 /// Threads standing in this fan.
327 pub fn workers(&self) -> usize {
328 self.workers
329 .lock()
330 .expect("pipeline workers poisoned")
331 .len()
332 }
333
334 /// Stop accepting work, drain what is queued, and JOIN every worker.
335 ///
336 /// Joining rather than detaching: a worker that outlives the pipeline holds
337 /// an `Arc` on the queue and on whatever the closure captured, which on a
338 /// server means a repository handle that never closes.
339 pub fn shutdown(&self) {
340 // `stopping` is set UNDER the queue's lock: a worker reads it with the lock held
341 // and waits (releasing the lock atomically), so the store lands either before its
342 // read (it returns) or after it waits (the notify wakes it). Set without the lock,
343 // a store + notify between the read and the wait was lost and join() hung for good
344 // (stinkies ssh-pipeline-shutdown-hang, 33 min at load ~35).
345 {
346 let _q = self.queue.items.lock().expect("pipeline queue poisoned");
347 self.queue.stopping.store(true, Ordering::Release);
348 }
349 self.queue.ready.notify_all();
350 let mut handles = self.workers.lock().expect("pipeline workers poisoned");
351 for h in handles.drain(..) {
352 let _ = h.join();
353 }
354 }
355}
356
357impl<T: Send + 'static> Drop for Pipeline<T> {
358 fn drop(&mut self) {
359 self.shutdown();
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use std::collections::HashSet;
367 use std::sync::mpsc;
368 use std::time::Duration;
369
370 /// ★ THREADS ARE SPAWNED AT BOOT, NOT PER OPERATION.
371 ///
372 /// The whole claim of this module in one guard: submit N operations and the
373 /// set of threads that ran them is bounded by the fan's size, not by N.
374 ///
375 /// Seen RED by removing the standing fan entirely — `start` spawns nothing
376 /// and `submit` runs the work on a fresh thread, which is the shape this
377 /// module replaces: **"12 operations ran on 12 distinct threads — the fan is
378 /// being built per request"**.
379 ///
380 /// A FIRST attempt at that break spawned an idle thread per submit while
381 /// leaving the work on the fan, and the guard stayed green — correctly, and
382 /// it is recorded here because it is the trap: breaking something *near* the
383 /// property is not breaking the property.
384 #[test]
385 fn operations_reuse_the_standing_fan_rather_than_a_thread_each() {
386 let (tx, rx) = mpsc::channel();
387 let p = Pipeline::start(OpClass::Clone, 3, 64, move |_op: u32| {
388 let id = std::thread::current().id();
389 tx.send(id).expect("collector alive");
390 });
391 for i in 0..12 {
392 p.submit(i).expect("queue has room");
393 }
394 let mut seen: HashSet<std::thread::ThreadId> = HashSet::new();
395 for _ in 0..12 {
396 seen.insert(rx.recv_timeout(Duration::from_secs(5)).expect("op ran"));
397 }
398 assert!(
399 seen.len() <= 3,
400 "{} operations ran on {} distinct threads — the fan is being built per request",
401 12,
402 seen.len()
403 );
404 assert_eq!(p.served(), 12);
405 }
406
407 /// The workers exist BEFORE any operation arrives. A fan that is created
408 /// lazily on first submit is not standing, it is merely cached.
409 ///
410 /// Seen RED by the same break: **"the fan has 0 workers before any
411 /// operation was submitted"**.
412 #[test]
413 fn the_fan_is_running_before_the_first_operation() {
414 let p: Pipeline<u32> = Pipeline::start(OpClass::Fetch, 4, 16, |_| {});
415 assert_eq!(
416 p.workers(),
417 4,
418 "the fan has {} workers before any operation was submitted",
419 p.workers()
420 );
421 assert_eq!(p.served(), 0, "nothing has been served yet");
422 }
423
424 /// ★ A FULL QUEUE REFUSES. Backpressure is the point of a bounded queue; an
425 /// unbounded one converts overload into unbounded memory and a slower
426 /// collapse.
427 ///
428 /// Seen RED by removing the `q.len() >= self.depth` check: **"the queue
429 /// accepted 40 items into a 4-deep queue"**.
430 #[test]
431 fn a_full_queue_refuses_rather_than_growing() {
432 // A worker that blocks, so the queue genuinely fills.
433 // The worker closure must be `Sync`, and a `Receiver` is not — so it
434 // is shared behind a mutex. One worker, so there is no contention.
435 let (release, held) = mpsc::channel::<()>();
436 let held = Arc::new(Mutex::new(held));
437 let p = Pipeline::start(OpClass::Clone, 1, 4, move |_op: u32| {
438 // BOUNDED, and that is not tidiness. A bare `recv()` here deadlocks
439 // the whole test binary when an assertion fires before the release
440 // loop: the worker blocks forever, `Drop` calls `shutdown()`, which
441 // JOINS it, and nothing ever returns. Seen — the red proof for the
442 // guard below hung instead of failing, which is strictly worse than
443 // a failure because CI reports it as a timeout with no message.
444 let _ = held
445 .lock()
446 .expect("held")
447 .recv_timeout(Duration::from_secs(10));
448 });
449 let mut accepted = 0usize;
450 let mut refused = 0usize;
451 for i in 0..40 {
452 match p.submit(i) {
453 Ok(_) => accepted += 1,
454 Err(SubmitError::QueueFull) => refused += 1,
455 Err(e) => panic!("unexpected refusal: {e}"),
456 }
457 }
458 assert!(
459 refused > 0,
460 "the queue accepted {accepted} items into a 4-deep queue"
461 );
462 assert!(
463 accepted <= 4 + 1,
464 "accepted {accepted}, which is more than the depth plus the one in service"
465 );
466 for _ in 0..accepted {
467 let _ = release.send(());
468 }
469 }
470
471 /// Shutdown JOINS. A detached worker holds an `Arc` on whatever the closure
472 /// captured — on a server, a repository handle that never closes.
473 ///
474 /// Seen RED by replacing `h.join()` with `drop(h)`: the guard below observed
475 /// **"a worker outlived the pipeline: 3 strong refs on the queue after
476 /// shutdown"**.
477 #[test]
478 fn shutdown_joins_every_worker() {
479 let p: Pipeline<u32> = Pipeline::start(OpClass::Gc, 3, 8, |_| {});
480 let q = Arc::clone(&p.queue);
481 assert!(
482 Arc::strong_count(&q) > 1,
483 "workers hold the queue while running"
484 );
485 p.shutdown();
486 assert_eq!(p.workers(), 0, "handles were not drained");
487 assert_eq!(
488 Arc::strong_count(&q),
489 2,
490 "a worker outlived the pipeline: {} strong refs on the queue after shutdown",
491 Arc::strong_count(&q)
492 );
493 }
494
495 /// **A shutdown that lands between a worker's `stopping` check and its wait is not
496 /// lost** (stinkies ssh-pipeline-shutdown-hang: the suite at load ~35 hung 33 min in
497 /// `shutdown_joins_every_worker`, the worker parked in `futex_do_wait` for good). The
498 /// worker is held in exactly that window (lock held, `stopping` read false); shutdown
499 /// is asked meanwhile; the worker then waits. Shutdown must still end.
500 #[test]
501 fn a_shutdown_in_the_window_before_a_worker_waits_still_joins_it() {
502 let p: Pipeline<u32> = Pipeline::start(OpClass::Gc, 1, 8, |_| {});
503 let (in_window, window) = mpsc::channel::<()>();
504 let (go, gate) = mpsc::channel::<()>();
505 let gate = Mutex::new(Some(gate));
506 let in_window = Mutex::new(in_window);
507 let _ = p.queue.before_wait.set(Box::new(move || {
508 // Only the first pass after the item is held; every later wait goes straight on.
509 if let Some(g) = gate.lock().unwrap().take() {
510 let _ = in_window.lock().unwrap().send(());
511 let _ = g.recv_timeout(Duration::from_secs(10));
512 }
513 }));
514 p.submit(1).expect("queued");
515 window
516 .recv_timeout(Duration::from_secs(10))
517 .expect("the worker reached the window");
518 let (done, ended) = mpsc::channel::<()>();
519 std::thread::spawn(move || {
520 p.shutdown();
521 let _ = done.send(());
522 });
523 // Let the shutdown run its course (or block, if it must take the lock) while the
524 // worker is still in the window, then let the worker go on to wait.
525 std::thread::sleep(Duration::from_millis(300));
526 let _ = go.send(());
527 assert!(
528 ended.recv_timeout(Duration::from_secs(10)).is_ok(),
529 "shutdown never joined: the worker waits on a wake-up that came before it waited"
530 );
531 }
532
533 /// ★ THE ARENA IS BUILT ONCE PER WORKER, NOT ONCE PER OPERATION.
534 ///
535 /// A standing fan removes the thread spawn but not the buffer. This asserts
536 /// the buffer survives between operations at full capacity, which is what
537 /// makes `kernel_init_pages` — 4.7% of the top profile on a `linux.git`
538 /// clone — stop happening rather than merely shrink.
539 ///
540 /// Seen RED by moving the allocation inside `run` (`let mut arena =
541 /// make(i);` per item, the shape this replaces): **"the arena was rebuilt:
542 /// 20 constructions for 20 operations across 2 workers"**.
543 #[test]
544 fn the_arena_is_built_once_per_worker_and_reused_by_every_operation() {
545 let built = Arc::new(AtomicU64::new(0));
546 let b = Arc::clone(&built);
547 let (tx, rx) = mpsc::channel();
548 let p = Pipeline::start_with_arena(
549 OpClass::Clone,
550 2,
551 64,
552 move |_i| {
553 b.fetch_add(1, Ordering::Relaxed);
554 Vec::<u8>::with_capacity(4096)
555 },
556 move |arena: &mut Vec<u8>, _op: u32| {
557 let before = arena.capacity();
558 arena.clear();
559 arena.extend_from_slice(&[7u8; 1024]);
560 tx.send((before, arena.capacity()))
561 .expect("collector alive");
562 },
563 );
564 for i in 0..20 {
565 p.submit(i).expect("queue has room");
566 }
567 let mut reused = 0usize;
568 for _ in 0..20 {
569 let (before, _after) = rx.recv_timeout(Duration::from_secs(5)).expect("op ran");
570 if before >= 4096 {
571 reused += 1;
572 }
573 }
574 // `<= 2`, not `== 2`: a worker builds its arena when the THREAD starts,
575 // and 20 fast operations can be drained by the first worker before the
576 // second is even scheduled. Asserting `== 2` made this guard flake on a
577 // loaded box — the property is "not one per operation", and pinning it
578 // to an exact thread count tests the scheduler instead.
579 let n = built.load(Ordering::Relaxed);
580 assert!(
581 (1..=2).contains(&n),
582 "the arena was rebuilt: {n} constructions for 20 operations across 2 workers"
583 );
584 assert_eq!(
585 reused, 20,
586 "only {reused} of 20 operations saw a warm arena — capacity is being thrown away"
587 );
588 }
589
590 /// ★ THE TRANSFER FANS ARE SIZED FOR CONCURRENCY, NOT FOR CORES.
591 ///
592 /// Seen RED — in the BENCH, not here — by `(cores/2).clamp(2,8)`:
593 /// **"multiuser_scaling: 16 of 102 user(s) did not complete their ops"**,
594 /// with **zero** `transfer.refused` in the entire run. Eight workers, 102
595 /// I/O-bound clients, a 64-deep queue: nobody was turned away, they were
596 /// made to wait until they timed out. Shedding load is the governor's job;
597 /// the fan must not serialise ordinary concurrency on its own.
598 ///
599 /// `gc` is the exception and stays small: a repack is real CPU work, so it
600 /// is bounded by cores and must not crowd out serving.
601 #[test]
602 fn transfer_fans_are_wide_and_gc_stays_small() {
603 for cores in [1usize, 4, 32, 256] {
604 assert_eq!(
605 default_workers(OpClass::Gc, cores),
606 2,
607 "gc at {cores} cores"
608 );
609 for class in [
610 OpClass::LsRemote,
611 OpClass::Fetch,
612 OpClass::Clone,
613 OpClass::Push,
614 ] {
615 let n = default_workers(class, cores);
616 assert!(
617 n >= 16,
618 "{class:?} at {cores} cores got {n} workers — 102 concurrent \
619 clients against a fan this small queue until they time out"
620 );
621 assert!(n <= 256, "{class:?} at {cores} cores is unbounded: {n}");
622 }
623 }
624 // The 32-core box the bench runs on: comfortably over 102 clients.
625 assert!(
626 default_workers(OpClass::Clone, 32) >= 102,
627 "the fan must hold multiuser_scaling's 102 clients without queueing"
628 );
629 }
630}
631
632// ════════════════════════════════════════════════════════════════════════════
633// The five fans, built at boot
634// ════════════════════════════════════════════════════════════════════════════
635
636/// One unit of work handed to a standing fan.
637///
638/// A boxed `FnOnce` and not a pure descriptor, and that is a compromise worth
639/// naming: a descriptor would be `operate(op)` in its ideal form, but the git
640/// transfer it has to carry is an async future over a live socket, and the
641/// honest bridge to an OS thread is a closure that owns it. One box per
642/// operation is one allocation — against the ~27.6 M this design exists to
643/// remove, and against tokio's blocking pool spawning a THREAD for the same
644/// work. It is not zero; it is four orders of magnitude off the thing that hurt.
645pub type Job = Box<dyn FnOnce() + Send + 'static>;
646
647/// **Every class's fan, standing, for the life of the process.**
648///
649/// Built once in [`crate::server::SshServer::new`]. A request classifies, passes
650/// the governor, and is submitted here; the thread that runs it was already
651/// alive and parked before the client connected.
652pub struct Pipelines {
653 ls_remote: Pipeline<Job>,
654 fetch: Pipeline<Job>,
655 clone: Pipeline<Job>,
656 push: Pipeline<Job>,
657 gc: Pipeline<Job>,
658}
659
660impl Pipelines {
661 /// Build all five fans. Call this at BOOT, once.
662 ///
663 /// `depth` is per class, not shared: a queue of clones filling up must not
664 /// refuse an ls-remote, which is the same head-of-line reasoning the
665 /// governor's per-class sojourn windows exist for.
666 pub fn start(cores: usize, depth: usize) -> Pipelines {
667 fn fan(class: OpClass, cores: usize, depth: usize) -> Pipeline<Job> {
668 Pipeline::start(class, default_workers(class, cores), depth, |job: Job| {
669 job()
670 })
671 }
672 Pipelines {
673 ls_remote: fan(OpClass::LsRemote, cores, depth),
674 fetch: fan(OpClass::Fetch, cores, depth),
675 clone: fan(OpClass::Clone, cores, depth),
676 push: fan(OpClass::Push, cores, depth),
677 gc: fan(OpClass::Gc, cores, depth),
678 }
679 }
680
681 fn of(&self, class: OpClass) -> &Pipeline<Job> {
682 match class {
683 OpClass::LsRemote => &self.ls_remote,
684 OpClass::Fetch => &self.fetch,
685 OpClass::Clone => &self.clone,
686 OpClass::Push => &self.push,
687 OpClass::Gc => &self.gc,
688 }
689 }
690
691 /// Hand an operation to its class's standing fan.
692 pub fn submit(&self, class: OpClass, job: Job) -> Result<usize, SubmitError> {
693 self.of(class).submit(job)
694 }
695
696 /// Operations this class's fan has picked up. Applied output — the only
697 /// thing that says the fan is CARRYING traffic rather than merely existing.
698 pub fn served(&self, class: OpClass) -> u64 {
699 self.of(class).served()
700 }
701
702 /// Items waiting for a worker in this class.
703 pub fn queued(&self, class: OpClass) -> usize {
704 self.of(class).queued()
705 }
706
707 /// Threads standing in this class's fan.
708 pub fn workers(&self, class: OpClass) -> usize {
709 self.of(class).workers()
710 }
711}
712
713/// **The process's fans.** One set, for the whole server, forever.
714///
715/// A global and not a field, for two reasons that only became visible once the
716/// SSH path was wired and the HTTP path was not:
717///
718/// * **Both transports must use the SAME fans.** `gunnar-http` holds its own
719/// `Arc<dyn GitTransport>` and calls `serve()` itself, so a set of fans owned
720/// by `SshServer` covered SSH transfers and silently missed every HTTP one —
721/// which is most of the `population_*` and `multiuser_scaling` load.
722/// * **Two listeners must not mean two fans.** Standing machinery sized to the
723/// box is the whole point; one set per listener doubles the threads and
724/// halves the meaning of the limit.
725///
726/// Built on first use, sized from the box, and never rebuilt.
727static FANS: std::sync::OnceLock<Pipelines> = std::sync::OnceLock::new();
728
729/// The process's standing fans, building them on first call.
730pub fn fans() -> &'static Pipelines {
731 FANS.get_or_init(|| {
732 let cores = std::thread::available_parallelism()
733 .map(|n| n.get())
734 .unwrap_or(4);
735 // Queue depth per class. Deep enough that a burst waits rather than
736 // being refused; the governor's CoDel window decides whether that wait
737 // is acceptable, not this number.
738 Pipelines::start(cores, 64)
739 })
740}
741
742#[cfg(test)]
743mod pipelines_tests {
744 use super::*;
745 use std::time::Duration;
746
747 /// ★ ALL FIVE FANS ARE STANDING BEFORE ANY REQUEST ARRIVES, and gc's is the
748 /// small one.
749 ///
750 /// Seen RED by building only the class asked for on first submit: **"the
751 /// clone fan has 0 workers before any request"**.
752 #[test]
753 fn every_class_has_a_standing_fan_at_boot() {
754 let p = Pipelines::start(32, 16);
755 for class in [
756 OpClass::LsRemote,
757 OpClass::Fetch,
758 OpClass::Clone,
759 OpClass::Push,
760 OpClass::Gc,
761 ] {
762 assert!(
763 p.workers(class) > 0,
764 "the {class:?} fan has 0 workers before any request"
765 );
766 assert_eq!(p.served(class), 0, "{class:?} served something at boot");
767 }
768 assert_eq!(p.workers(OpClass::Gc), 2, "gc must stay small");
769 assert!(
770 p.workers(OpClass::LsRemote) > p.workers(OpClass::Gc),
771 "ls-remote must out-scale gc"
772 );
773 }
774
775 /// ★ ONE set of fans for the process, however many listeners ask for them.
776 ///
777 /// Seen RED by returning `Pipelines::start(..)` fresh on each call instead
778 /// of the `OnceLock`: **"two callers got two different fan sets — SSH and
779 /// HTTP would each have their own"**.
780 #[test]
781 fn every_caller_gets_the_same_process_wide_fans() {
782 let a = fans();
783 let b = fans();
784 assert!(
785 std::ptr::eq(a, b),
786 "two callers got two different fan sets — SSH and HTTP would each have their own"
787 );
788 }
789
790 /// ★ THE COUNTERS REPORT REAL OCCUPANCY, not a constant.
791 ///
792 /// `served`, `queued` and `workers` existed from the start and NOTHING read
793 /// them, so the only evidence the fans carried traffic was the absence of
794 /// refusals — silence, not evidence. Now `server.rs` logs depth on every
795 /// operation that actually queues, and a counter that is wired to a log is
796 /// a counter that can lie.
797 ///
798 /// So this asserts the queue is SEEN NON-EMPTY under back-pressure, which
799 /// is the only reading that distinguishes "the fan is carrying work" from
800 /// "the fan exists". A guard that merely called `queued()` and accepted
801 /// whatever came back would pass against a function returning 0 forever —
802 /// which is exactly the shape of four dead guards found in this suite
803 /// today, including a control satisfied by `0 == 0`.
804 ///
805 /// Seen RED with `queued()` stubbed to `0`: **"the queue never reported a
806 /// waiting item across 24 submissions against 1 worker — the depth this
807 /// logs is a constant"**.
808 #[test]
809 fn the_occupancy_counters_are_not_constants() {
810 // One worker, held, so submissions must pile up behind it.
811 let (release, held) = std::sync::mpsc::channel::<()>();
812 let held = Arc::new(Mutex::new(held));
813 let p: Pipeline<u32> = Pipeline::start(OpClass::Clone, 1, 64, move |_| {
814 // BOUNDED, and that is not tidiness. A bare `recv()` here deadlocks
815 // the whole test binary when an assertion fires before the release
816 // loop: the worker blocks forever, `Drop` calls `shutdown()`, which
817 // JOINS it, and nothing ever returns. Seen — the red proof for the
818 // guard below hung instead of failing, which is strictly worse than
819 // a failure because CI reports it as a timeout with no message.
820 let _ = held
821 .lock()
822 .expect("held")
823 .recv_timeout(Duration::from_secs(10));
824 });
825
826 let mut deepest = 0usize;
827 for i in 0..24 {
828 if let Ok(depth) = p.submit(i) {
829 deepest = deepest.max(depth);
830 }
831 }
832 assert!(
833 deepest > 1,
834 "the queue never reported a waiting item across 24 submissions against \
835 1 worker — the depth this logs is a constant"
836 );
837 assert!(
838 p.queued() > 0,
839 "queued() says {} with {deepest} known to have piled up",
840 p.queued()
841 );
842 assert_eq!(p.workers(), 1, "workers() lost count of its own fan");
843
844 // Let them through, then the served counter must MOVE — a `served` that
845 // never rises would make the log line above report an idle fan forever.
846 for _ in 0..24 {
847 let _ = release.send(());
848 }
849 let mut waited = 0;
850 while p.served() < 24 && waited < 200 {
851 std::thread::sleep(Duration::from_millis(25));
852 waited += 1;
853 }
854 assert_eq!(
855 p.served(),
856 24,
857 "served() reports {} after 24 jobs were released",
858 p.served()
859 );
860 }
861
862 /// A submitted job RUNS on the fan, and lands in the right class.
863 #[test]
864 fn a_job_runs_on_its_own_class_fan() {
865 let p = Pipelines::start(8, 16);
866 let (tx, rx) = std::sync::mpsc::channel();
867 p.submit(OpClass::Push, Box::new(move || tx.send(()).unwrap()))
868 .expect("queue has room");
869 rx.recv_timeout(std::time::Duration::from_secs(5))
870 .expect("the job ran on the fan");
871 assert_eq!(p.served(OpClass::Push), 1);
872 assert_eq!(p.served(OpClass::Clone), 0, "it ran on the wrong fan");
873 }
874}