hya_core/sched.rs
1//! The scheduler kernel: pure state machine, no I/O, no clock, no allocation
2//! in the steady state.
3//!
4//! The caller drives it: feed observations (`on_bytes`, `on_complete`), call
5//! `tick(now)`, and act on the returned `Action`s. This is what lets the same
6//! code run under the discrete-event simulator and under real HTTP.
7//!
8//! Implements dynamic range partitioning, divergence-triggered steal-to-equalize,
9//! work-conserving assignment, queue dispatch, stall reclamation, and greedy concurrency.
10
11use crate::intervals::{IntervalSet, Range};
12
13/// Minimum steal quantum. A range rebalance smaller than this is not worth request overhead.
14pub const STEAL_QUANTUM: u64 = 64 * 1024;
15
16/// Bounded repairs per tick, so a tick is O(R * n).
17const MAX_REPAIRS_PER_TICK: usize = 4;
18
19/// EWMA weight on the newest goodput sample.
20const RATE_ALPHA: f64 = 0.3;
21
22/// Minimum wall clock a rate sample must span, in seconds.
23///
24/// Below this the quotient is dominated by socket buffering rather than by the
25/// link: consecutive `read()` calls draining one already-arrived TCP window return
26/// in microseconds and imply a rate the network never achieved. 200 ms is long
27/// enough to average over several windows and short enough that a genuine collapse
28/// is still graded within the stall timeout.
29const RATE_WINDOW: f64 = 0.2;
30
31#[derive(Clone, Copy, PartialEq, Eq, Debug)]
32pub enum Action {
33 /// Issue `GET` with `Range: bytes=lo-(hi-1)` on this connection.
34 Request { conn: usize, range: Range },
35 /// Stop reading this connection's current response; its range was reclaimed.
36 Cancel { conn: usize },
37}
38
39#[derive(Clone, Copy, PartialEq, Eq, Debug)]
40pub enum Capability {
41 /// Ranges honoured, length known, strong validator: full scheduling.
42 Full,
43 /// Ranges honoured but no validator: partition, but pin to one source.
44 NoValidator,
45 /// Ranges ignored or unsupported: race whole-object fetches.
46 Race,
47 /// Length unknown: single stream per source, no range arithmetic.
48 Stream,
49}
50
51#[derive(Clone, Debug)]
52pub struct Source {
53 pub caps: Capability,
54 /// Per-connection goodput ceiling estimate, bytes/s.
55 pub gamma_est: f64,
56 /// Per-source shaping cap estimate, bytes/s.
57 pub rho_est: f64,
58 /// Measured request setup cost, seconds.
59 pub delta_est: f64,
60 /// Suspended until this time (429/503 Retry-After, or stall backoff).
61 pub suspended_until: f64,
62 /// Consecutive stalls observed on this source; drives exponential backoff.
63 pub consecutive_stalls: u32,
64}
65
66impl Default for Source {
67 fn default() -> Self {
68 Source {
69 caps: Capability::Full,
70 gamma_est: 0.0,
71 rho_est: f64::INFINITY,
72 delta_est: 0.05,
73 suspended_until: 0.0,
74 consecutive_stalls: 0,
75 }
76 }
77}
78
79#[derive(Clone, Debug)]
80struct Conn {
81 source: usize,
82 /// Active range and how far into it we are.
83 range: Option<Range>,
84 pos: u64,
85 /// One-slot pipeline: a range handed over by a repair.
86 queued: Option<Range>,
87 rate_est: f64,
88 /// Changepoint detector. `rate_est` remains the smoothed rate used for ETA
89 /// projection; this grades the connection so repair can pre-empt a collapse
90 /// instead of waiting for the stall timeout (see `detect.rs`).
91 detector: crate::detect::CollapseDetector,
92 last_progress: f64,
93 setup_end: f64,
94 stalled: bool,
95 /// Bytes and wall clock accumulated since the last RATE sample.
96 ///
97 /// Rate is measured over a fixed WINDOW, not per arrival. An arrival is one
98 /// `read()` return, and a read served from the socket's already-buffered data
99 /// completes in microseconds, so `bytes/dt` for that arrival measures memcpy
100 /// speed rather than network speed — observed as 128 MiB/s on a connection
101 /// whose link was doing well under 1 MiB/s.
102 ///
103 /// That is not merely a cosmetic display bug. Those inflated samples raise the
104 /// detector's reference level, after which every honest sample looks like a
105 /// collapse against it, and the CUSUM grades a perfectly healthy connection
106 /// `Degraded` — which is why all eight connections of a working transfer
107 /// showed as `bad`. Byte accounting stays exactly per-arrival (coverage must
108 /// be exact); only the rate estimate is windowed.
109 rate_acc_bytes: u64,
110 rate_acc_dt: f64,
111}
112
113impl Conn {
114 fn new(source: usize) -> Self {
115 Conn {
116 source,
117 range: None,
118 pos: 0,
119 queued: None,
120 rate_est: 0.0,
121 detector: crate::detect::CollapseDetector::new(),
122 rate_acc_bytes: 0,
123 rate_acc_dt: 0.0,
124 last_progress: 0.0,
125 setup_end: 0.0,
126 stalled: false,
127 }
128 }
129
130 #[inline]
131 fn busy(&self) -> bool {
132 self.range.map(|r| self.pos < r.hi).unwrap_or(false)
133 }
134
135 /// Bytes still owed on the active range plus anything pipelined.
136 #[inline]
137 fn outstanding(&self) -> u64 {
138 let active = self
139 .range
140 .map(|r| r.hi.saturating_sub(self.pos))
141 .unwrap_or(0);
142 active + self.queued.map(|r| r.len()).unwrap_or(0)
143 }
144
145 /// Projected seconds to drain. A stalled or unmeasured connection projects
146 /// to infinity so it is always chosen as the repair victim.
147 fn eta(&self) -> f64 {
148 let out = self.outstanding();
149 if out == 0 {
150 return 0.0;
151 }
152 if self.rate_est <= 0.0 {
153 return f64::INFINITY;
154 }
155 out as f64 / self.rate_est
156 }
157}
158
159#[derive(Clone, Copy, Debug, Default)]
160pub struct Stats {
161 pub requests: u64,
162 pub repairs: u64,
163 pub reclaims: u64,
164 pub bytes_held: u64,
165}
166
167pub struct Scheduler {
168 size: u64,
169 unassigned: IntervalSet,
170 held: u64,
171 conns: Vec<Conn>,
172 sources: Vec<Source>,
173 /// Repair deadband scale; theta = scale * sqrt(delta * T_rem / n).
174 theta_scale: f64,
175 stall_timeout: f64,
176 /// When false, victim selection ignores detector health and ranks purely by
177 /// projected ETA (the pre-detector behaviour). Exists so the detector's
178 /// contribution can be A/B measured rather than assumed.
179 health_ranking: bool,
180 started: bool,
181 pub stats: Stats,
182}
183
184impl Scheduler {
185 pub fn new(size: u64, sources: Vec<Source>, conns_per_source: &[usize]) -> Self {
186 let mut conns = Vec::new();
187 for (i, &k) in conns_per_source.iter().enumerate() {
188 for _ in 0..k {
189 conns.push(Conn::new(i));
190 }
191 }
192 Scheduler {
193 size,
194 unassigned: IntervalSet::full(size),
195 held: 0,
196 conns,
197 sources,
198 theta_scale: 1.0,
199 stall_timeout: 1.0,
200 health_ranking: true,
201 started: false,
202 stats: Stats::default(),
203 }
204 }
205
206 pub fn with_theta_scale(mut self, s: f64) -> Self {
207 self.theta_scale = s;
208 self
209 }
210
211 /// Disable health-ranked victim selection (for A/B measurement only).
212 pub fn with_health_ranking(mut self, on: bool) -> Self {
213 self.health_ranking = on;
214 self
215 }
216
217 pub fn with_stall_timeout(mut self, t: f64) -> Self {
218 self.stall_timeout = t;
219 self
220 }
221
222 /// Mark `[lo, hi)` as already held, for resuming a partial transfer.
223 ///
224 /// Must be called before the first `tick`: the initial split assigns all
225 /// unassigned work, and bytes already on disk must not be part of it.
226 pub fn mark_done(&mut self, lo: u64, hi: u64) {
227 let (lo, hi) = (lo.min(self.size), hi.min(self.size));
228 if hi <= lo {
229 return;
230 }
231 // Credit only the bytes this call actually claims, measured as the drop in
232 // the unassigned set — NOT the width of the span asked for.
233 //
234 // Callers legitimately overlap. A `-c` resume marks the sidecar's ranges
235 // held, and the concurrency probe separately reports the bytes it fetched;
236 // both start at offset 0, so the same prefix is marked twice. Crediting
237 // `hi - lo` each time made `held` exceed the bytes that exist, and `held`
238 // is what `is_complete()` tests: the transfer stopped early believing it
239 // was finished, leaving a zero-filled hole in the tail of a file reported
240 // as a success. Measured on an interrupted-then-resumed 11 200 900-byte
241 // object: 240 138 bytes of tail never written, `ok: true`, and the gzip
242 // refused to decompress.
243 let before = self.unassigned.total();
244 self.unassigned.remove(lo, hi);
245 let claimed = before.saturating_sub(self.unassigned.total());
246 self.held = self.held.saturating_add(claimed);
247 }
248
249 /// Health grade of a connection, for the progress UI and for tests.
250 pub fn conn_health(&self, j: usize) -> crate::detect::Health {
251 self.conns
252 .get(j)
253 .map(|c| c.detector.health())
254 .unwrap_or_default()
255 }
256
257 /// Source index a connection belongs to, for the progress UI.
258 pub fn conn_source(&self, j: usize) -> usize {
259 self.conns.get(j).map(|c| c.source).unwrap_or(0)
260 }
261
262 /// Smoothed rate estimate of a connection (bytes/s), for the progress UI.
263 pub fn conn_rate(&self, j: usize) -> f64 {
264 self.conns.get(j).map(|c| c.rate_est).unwrap_or(0.0)
265 }
266
267 /// Active range of a connection, for the progress UI.
268 pub fn conn_range(&self, j: usize) -> Option<(u64, u64, u64)> {
269 self.conns
270 .get(j)
271 .and_then(|c| c.range.map(|r| (r.lo, c.pos, r.hi)))
272 }
273
274 pub fn n_conns(&self) -> usize {
275 self.conns.len()
276 }
277
278 pub fn is_complete(&self) -> bool {
279 self.held >= self.size
280 }
281
282 pub fn bytes_held(&self) -> u64 {
283 self.held
284 }
285
286 /// The ranges that are complete on disk, as `(lo, hi)` pairs.
287 ///
288 /// This is the complement of the unassigned set minus what is still in flight, and
289 /// it is what a resume record must contain. Reporting only a byte COUNT is not
290 /// enough: positioned writes land ranges out of order, so "2 MB held" says nothing
291 /// about which 2 MB, and a resume that assumed a contiguous prefix would skip holes
292 /// and silently corrupt the file.
293 pub fn held_ranges(&self) -> Vec<(u64, u64)> {
294 // Start from everything, then subtract what is unassigned and what is
295 // outstanding on a connection; what remains has arrived.
296 let mut done = IntervalSet::full(self.size);
297 for r in self.unassigned.ranges() {
298 done.remove(r.lo, r.hi);
299 }
300 for c in &self.conns {
301 if let Some(r) = c.range {
302 // Bytes before the cursor have arrived; the rest has not.
303 done.remove(c.pos, r.hi);
304 }
305 if let Some(q) = c.queued {
306 done.remove(q.lo, q.hi);
307 }
308 }
309 done.ranges().iter().map(|r| (r.lo, r.hi)).collect()
310 }
311
312 /// Coverage audit: held + outstanding + unassigned == size.
313 ///
314 /// This is a SAFETY invariant and it does NOT imply liveness -- the
315 /// livelock this code is written to avoid (a fully-stolen range leaving a
316 /// connection idle with a non-empty queue) satisfies it at every instant.
317 /// `liveness_holds` is the property that matters.
318 /// The largest measured request setup cost across sources, in seconds.
319 ///
320 /// Exposed because a transport-layer watchdog must express its patience in
321 /// units of what a request actually costs on this path rather than as a
322 /// hardcoded constant: `delta` differs by an order of magnitude between a
323 /// LAN mirror and a TLS connection through a proxy, and a fixed timeout is
324 /// either trigger-happy on the slow path or useless on the fast one.
325 ///
326 /// This is the same quantity the repair deadband is built from
327 /// (`theta = scale * sqrt(delta * T_rem / n)`), so a client that widens
328 /// `delta` widens both together, which is the intended coupling.
329 pub fn worst_delta(&self) -> f64 {
330 self.sources
331 .iter()
332 .map(|s| s.delta_est)
333 .fold(0.0f64, f64::max)
334 }
335
336 /// The configured stall timeout, in seconds.
337 pub fn stall_timeout(&self) -> f64 {
338 self.stall_timeout
339 }
340
341 pub fn coverage_holds(&self) -> bool {
342 let outstanding: u64 = self.conns.iter().map(|c| c.outstanding()).sum();
343 self.held + outstanding + self.unassigned.total() == self.size
344 && self.unassigned.invariant_holds()
345 }
346
347 /// True when some enabled transition strictly decreases the unheld-byte count.
348 /// False means the scheduler is stuck.
349 pub fn liveness_holds(&self) -> bool {
350 if self.is_complete() {
351 return true;
352 }
353 // progress possible if: someone is receiving, or work is assignable,
354 // or a connection holds a queue it can start, or a stall can be reclaimed
355 self.conns.iter().any(|c| c.busy() && !c.stalled)
356 || !self.unassigned.is_empty()
357 || self.conns.iter().any(|c| c.queued.is_some())
358 || self.conns.iter().any(|c| c.stalled)
359 }
360
361 // ---------------------------------------------------------------- input
362
363 /// Record `n` bytes arriving on `conn` at time `now` over `dt` seconds.
364 ///
365 /// Convenience wrapper that assumes the arrival is contiguous at the
366 /// connection's cursor. Real transports must use [`Scheduler::on_bytes_at`]:
367 /// a response still draining from a range that was completed or stolen would
368 /// otherwise be credited against whatever range the connection holds NOW,
369 /// silently advancing a cursor over bytes that never arrived and leaving a
370 /// hole of zeros in the output file.
371 pub fn on_bytes(&mut self, conn: usize, n: u64, now: f64, dt: f64) {
372 let at = self.conns[conn].pos;
373 self.on_bytes_at(conn, at, n, now, dt);
374 }
375
376 /// Record `n` bytes that landed at absolute offset `off`.
377 ///
378 /// Arrivals that do not begin exactly at the connection's cursor are stale
379 /// (they belong to a superseded request) and are discarded: the bytes are
380 /// still written to the file by the transport, but they are not credited,
381 /// so the scheduler's coverage accounting stays exact.
382 pub fn on_bytes_at(&mut self, conn: usize, off: u64, n: u64, now: f64, dt: f64) {
383 let c = &mut self.conns[conn];
384 let Some(r) = c.range else { return };
385 if off != c.pos || off < r.lo {
386 return; // stale arrival from a superseded range
387 }
388 let room = r.hi.saturating_sub(c.pos);
389 let step = n.min(room);
390 if step == 0 {
391 return;
392 }
393 c.pos += step;
394 self.held += step;
395 c.last_progress = now;
396 c.stalled = false;
397 let src = c.source;
398 self.sources[src].consecutive_stalls = 0;
399 if dt > 0.0 {
400 // Accumulate, and only take a rate sample once the window has enough
401 // wall clock in it to mean something.
402 c.rate_acc_bytes += step;
403 c.rate_acc_dt += dt;
404 if c.rate_acc_dt >= RATE_WINDOW {
405 let sample = c.rate_acc_bytes as f64 / c.rate_acc_dt;
406 c.rate_acc_bytes = 0;
407 c.rate_acc_dt = 0.0;
408 c.detector.observe_rate(sample);
409 c.rate_est = if c.rate_est <= 0.0 {
410 sample
411 } else {
412 RATE_ALPHA * sample + (1.0 - RATE_ALPHA) * c.rate_est
413 };
414 }
415 }
416 if c.pos >= r.hi {
417 c.range = None;
418 }
419 }
420
421 /// Suspend a source (429/503 with Retry-After) and reclaim its ranges.
422 pub fn suspend_source(&mut self, src: usize, until: f64) {
423 self.sources[src].suspended_until = until;
424 let idxs: Vec<usize> = (0..self.conns.len())
425 .filter(|&j| self.conns[j].source == src)
426 .collect();
427 for j in idxs {
428 self.reclaim(j);
429 }
430 }
431
432 fn reclaim(&mut self, j: usize) {
433 let c = &mut self.conns[j];
434 if let Some(r) = c.range {
435 if c.pos < r.hi {
436 let back = Range::new(c.pos, r.hi);
437 c.range = None;
438 let q = c.queued.take();
439 self.unassigned.insert(back);
440 if let Some(q) = q {
441 self.unassigned.insert(q);
442 }
443 self.stats.reclaims += 1;
444 } else {
445 c.range = None;
446 }
447 } else if let Some(q) = c.queued.take() {
448 self.unassigned.insert(q);
449 self.stats.reclaims += 1;
450 }
451 let c = &mut self.conns[j];
452 c.rate_est = 0.0;
453 c.stalled = true;
454 }
455
456 // ---------------------------------------------------------------- tick
457
458 /// Advance the scheduler. Returns the actions the caller must perform.
459 pub fn tick(&mut self, now: f64) -> Vec<Action> {
460 let mut acts = Vec::new();
461
462 if !self.started {
463 self.initial_split(now, &mut acts);
464 self.started = true;
465 return acts;
466 }
467
468 // ---- feed wall-clock silence to the detectors ----------------------
469 // A connection delivering nothing produces no rate samples at all, so
470 // silence is evidence that only the clock can supply. Grading it here
471 // lets repair pre-empt at half the stall timeout instead of waiting for
472 // the full timeout to expire.
473 for j in 0..self.conns.len() {
474 let c = &self.conns[j];
475 if c.busy() && now >= c.setup_end {
476 let quiet = now - c.last_progress.max(c.setup_end);
477 let st = self.stall_timeout;
478 self.conns[j].detector.observe_silence(quiet, st);
479 }
480 }
481
482 // ---- liveness path 1: reclaim stalled connections -----------------
483 let stalled: Vec<usize> = (0..self.conns.len())
484 .filter(|&j| {
485 let c = &self.conns[j];
486 c.busy()
487 && now >= c.setup_end
488 && (now - c.last_progress.max(c.setup_end)) > self.stall_timeout
489 })
490 .collect();
491 for j in stalled {
492 self.reclaim(j);
493 acts.push(Action::Cancel { conn: j });
494 // A source that keeps stalling must be suspended, not merely
495 // retried: otherwise work-conserving assignment hands it the same
496 // bytes repeatedly without making forward progress.
497 let src = self.conns[j].source;
498 self.sources[src].consecutive_stalls += 1;
499 let k = self.sources[src].consecutive_stalls;
500 if k >= 2 {
501 let backoff = (self.stall_timeout * (1u64 << (k - 2).min(5)) as f64).min(30.0);
502 self.sources[src].suspended_until = now + backoff;
503 }
504 }
505
506 // ---- liveness path 2: an idle connection holding a queue MUST start it
507 //
508 // Mandatory: a connection whose active range was entirely stolen goes
509 // idle WITHOUT completing, so the completion path in on_bytes never fires
510 // and the queued bytes would be owned by an idle connection that never
511 // requests them.
512 for j in 0..self.conns.len() {
513 if !self.conns[j].busy()
514 && self.conns[j].queued.is_some()
515 && now >= self.conns[j].setup_end
516 {
517 let r = self.conns[j].queued.take().unwrap();
518 self.start(j, r, now);
519 acts.push(Action::Request { conn: j, range: r });
520 }
521 }
522
523 // ---- divergence-triggered repair ---------------------------------
524 let theta = self.theta(now);
525 for _ in 0..MAX_REPAIRS_PER_TICK {
526 let Some((vi, ti)) = self.pick_victim_taker(now) else {
527 break;
528 };
529 let (v_eta, t_eta) = (self.conns[vi].eta(), self.conns[ti].eta());
530 // Explicit ordering test: an unknown ETA yields NaN, and a NaN
531 // divergence must NOT trigger a repair (a repair costs a full delta,
532 // so acting on an unmeasured quantity is strictly a loss).
533 if !matches!(
534 (v_eta - t_eta).partial_cmp(&theta),
535 Some(core::cmp::Ordering::Greater)
536 ) {
537 break;
538 }
539 if self.conns[ti].queued.is_some() {
540 break;
541 }
542 let Some(vr) = self.conns[vi].range else {
543 break;
544 };
545 let left = vr.hi.saturating_sub(self.conns[vi].pos) as f64;
546 let rv = self.conns[vi].rate_est;
547 let rt = self.conns[ti].rate_est;
548 let delta = self.sources[self.conns[ti].source].delta_est;
549 // Equalise projected finishes, charging the taker one setup:
550 // (left - x)/rv == t_eta + delta + x/rt
551 let x = if rv <= 0.0 {
552 // Victim is stalled: hand over everything it has not received.
553 left
554 } else if rt <= 0.0 {
555 0.0
556 } else {
557 ((left / rv - t_eta - delta) * (rv * rt) / (rv + rt)).clamp(0.0, left)
558 };
559 if x <= STEAL_QUANTUM as f64 {
560 break;
561 }
562 let x = x as u64;
563 let new_hi = vr.hi - x;
564 let stolen = Range::new(new_hi, vr.hi);
565 // ZERO-COST client-side shrink: the victim's target end moves; no
566 // cancellation is sent and the server is never told.
567 self.conns[vi].range = Some(Range::new(vr.lo, new_hi));
568 self.conns[ti].queued = Some(stolen);
569 self.stats.repairs += 1;
570 }
571
572 // ---- work-conserving assignment (Lemma 2) -------------------------
573 for j in 0..self.conns.len() {
574 if self.conns[j].busy() || now < self.conns[j].setup_end {
575 continue;
576 }
577 let src = self.conns[j].source;
578 if now < self.sources[src].suspended_until {
579 continue;
580 }
581 if let Some(r) = self.unassigned.take_front(u64::MAX) {
582 self.start(j, r, now);
583 acts.push(Action::Request { conn: j, range: r });
584 continue;
585 }
586 // Nothing unassigned: steal from the worst laggard.
587 if let Some(vi) = self.worst_busy(j) {
588 let vr = self.conns[vi].range.unwrap();
589 let left = vr.hi.saturating_sub(self.conns[vi].pos);
590 let half = left / 2;
591 if half > STEAL_QUANTUM {
592 let new_hi = vr.hi - half;
593 self.conns[vi].range = Some(Range::new(vr.lo, new_hi));
594 let stolen = Range::new(new_hi, vr.hi);
595 self.start(j, stolen, now);
596 acts.push(Action::Request {
597 conn: j,
598 range: stolen,
599 });
600 self.stats.repairs += 1;
601 }
602 }
603 // NOTE: no hedging. Redundant requests waste bandwidth on non-erasure channels.
604 }
605
606 self.stats.bytes_held = self.held;
607 acts
608 }
609
610 fn start(&mut self, j: usize, r: Range, now: f64) {
611 let delta = self.sources[self.conns[j].source].delta_est;
612 let c = &mut self.conns[j];
613 c.range = Some(r);
614 c.pos = r.lo;
615 c.setup_end = now + delta;
616 c.last_progress = now + delta;
617 c.stalled = false;
618 self.stats.requests += 1;
619 }
620
621 fn initial_split(&mut self, now: f64, acts: &mut Vec<Action>) {
622 // Maximal ranges, proportional to rate estimate where known, else equal.
623 let n = self.conns.len();
624 if n == 0 || self.size == 0 {
625 return;
626 }
627 let weights: Vec<f64> = self
628 .conns
629 .iter()
630 .map(|c| {
631 let g = self.sources[c.source].gamma_est;
632 if g > 0.0 {
633 g
634 } else {
635 1.0
636 }
637 })
638 .collect();
639 let total: f64 = weights.iter().sum();
640
641 // Split what is ACTUALLY unassigned, not `[0, size)`.
642 //
643 // An earlier version partitioned the whole object arithmetically, which
644 // silently ignored `mark_done`. That broke both features that depend on
645 // it: `--range` fetched from offset 0 instead of the requested interval,
646 // and `--continue` re-fetched bytes already on disk. The unassigned set is
647 // the single source of truth for what remains, so the split must be taken
648 // from it.
649 let remaining: Vec<Range> = self.unassigned.ranges().to_vec();
650 let avail: u64 = remaining.iter().map(|r| r.hi - r.lo).sum();
651 if avail == 0 {
652 return;
653 }
654 // Per-connection byte quotas, proportional to rate estimate.
655 let mut quota: Vec<u64> = weights
656 .iter()
657 .map(|w| ((w / total) * avail as f64) as u64)
658 .collect();
659 // Rounding must not strand bytes: give the remainder to the last taker.
660 let assigned: u64 = quota.iter().sum();
661 if let Some(last) = quota.last_mut() {
662 *last += avail - assigned;
663 }
664
665 // Walk the unassigned ranges, carving each connection's quota out of them
666 // in order. A connection may receive a range that is not contiguous with
667 // its neighbours' — that is fine, since ranges are independent requests.
668 let mut it = remaining.into_iter();
669 let mut cur = it.next();
670 for (j, want_total) in quota.iter().enumerate() {
671 let mut want = *want_total;
672 while want > 0 {
673 let Some(seg) = cur else { break };
674 let take = want.min(seg.hi - seg.lo);
675 let r = Range::new(seg.lo, seg.lo + take);
676 // A connection holds one active range plus a one-slot pipeline.
677 // Anything beyond that stays UNASSIGNED rather than being stashed:
678 // work-conserving assignment will hand it out as connections free
679 // up, and leaving it in the set is what keeps the coverage
680 // invariant checkable.
681 if self.conns[j].range.is_none() {
682 self.unassigned.remove(r.lo, r.hi);
683 self.start(j, r, now);
684 acts.push(Action::Request { conn: j, range: r });
685 } else if self.conns[j].queued.is_none() {
686 self.unassigned.remove(r.lo, r.hi);
687 self.conns[j].queued = Some(r);
688 } else {
689 break;
690 }
691 want -= take;
692 cur = if seg.hi - seg.lo > take {
693 Some(Range::new(seg.lo + take, seg.hi))
694 } else {
695 it.next()
696 };
697 }
698 }
699 }
700
701 fn theta(&self, now: f64) -> f64 {
702 let live: Vec<&Conn> = self
703 .conns
704 .iter()
705 .filter(|c| now >= self.sources[c.source].suspended_until)
706 .collect();
707 let n = live.len().max(1) as f64;
708 let agg: f64 = live.iter().map(|c| c.rate_est.max(0.0)).sum();
709 let agg = if agg > 0.0 { agg } else { 1.0 };
710 let remaining = self.size.saturating_sub(self.held) as f64;
711 let t_rem = remaining / agg;
712 let delta = self
713 .sources
714 .iter()
715 .map(|s| s.delta_est)
716 .fold(0.0f64, f64::max);
717 self.theta_scale * (delta * t_rem.max(0.0) / n).sqrt()
718 }
719
720 fn pick_victim_taker(&self, now: f64) -> Option<(usize, usize)> {
721 // Victim ranking is (health, ETA), health first. A connection the
722 // detector has graded Suspect is a victim even when its *projected* ETA
723 // still looks acceptable -- which is the whole point of detecting a
724 // collapse early, since the ETA is computed from a rate estimate that
725 // the collapse has not yet dragged down.
726 let mut victim: Option<(usize, crate::detect::Health, f64)> = None;
727 let mut taker: Option<(usize, f64)> = None;
728 for j in 0..self.conns.len() {
729 let c = &self.conns[j];
730 if now < c.setup_end || now < self.sources[c.source].suspended_until {
731 continue;
732 }
733 let e = c.eta();
734 let h = if self.health_ranking {
735 c.detector.health()
736 } else {
737 crate::detect::Health::Healthy
738 };
739 if c.busy() && victim.map(|(_, vh, ve)| (h, e) > (vh, ve)).unwrap_or(true) {
740 victim = Some((j, h, e));
741 }
742 // A degraded connection must never be chosen as the TAKER: handing
743 // work to a collapsing connection is the failure mode this whole
744 // mechanism exists to prevent.
745 if !h.is_suspect_or_worse() && taker.map(|(_, te)| e < te).unwrap_or(true) {
746 taker = Some((j, e));
747 }
748 }
749 let (vi, _, _) = victim?;
750 let (ti, _) = taker?;
751 if vi == ti {
752 return None;
753 }
754 Some((vi, ti))
755 }
756
757 fn worst_busy(&self, exclude: usize) -> Option<usize> {
758 let mut best: Option<(usize, u64)> = None;
759 for j in 0..self.conns.len() {
760 if j == exclude {
761 continue;
762 }
763 let c = &self.conns[j];
764 if !c.busy() {
765 continue;
766 }
767 let left = c.range.unwrap().hi.saturating_sub(c.pos);
768 if best.map(|(_, bl)| left > bl).unwrap_or(true) {
769 best = Some((j, left));
770 }
771 }
772 best.map(|(j, _)| j)
773 }
774}
775
776/// Greedy concurrency allocation across multiple sources.
777pub fn greedy_concurrency(
778 rho: &[f64],
779 gamma: &[f64],
780 access_cap: f64,
781 budget: usize,
782) -> Vec<usize> {
783 let m = rho.len();
784 let mut n = vec![0usize; m];
785 let g = |n: &[usize]| -> f64 {
786 let sum: f64 = (0..m).map(|i| rho[i].min(n[i] as f64 * gamma[i])).sum();
787 sum.min(access_cap)
788 };
789 let mut cur = g(&n);
790 for _ in 0..budget {
791 let mut best = (0usize, 0.0f64);
792 for i in 0..m {
793 n[i] += 1;
794 let gain = g(&n) - cur;
795 n[i] -= 1;
796 if gain > best.1 {
797 best = (i, gain);
798 }
799 }
800 if best.1 <= 0.0 {
801 break; // saturated: further connections are pure cost
802 }
803 n[best.0] += 1;
804 cur += best.1;
805 }
806 n
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812
813 fn src(gamma: f64) -> Source {
814 Source {
815 gamma_est: gamma,
816 delta_est: 0.05,
817 ..Default::default()
818 }
819 }
820
821 #[test]
822 fn initial_split_covers_exactly() {
823 let mut s = Scheduler::new(1000, vec![src(1.0), src(1.0)], &[1, 1]);
824 let acts = s.tick(0.0);
825 assert_eq!(acts.len(), 2);
826 assert!(s.coverage_holds());
827 assert!(s.unassigned.is_empty());
828 }
829
830 #[test]
831 fn coverage_and_liveness_hold_through_a_transfer() {
832 let mut s = Scheduler::new(1_000_000, vec![src(1e5), src(5e4)], &[2, 2]);
833 let mut now = 0.0;
834 for _ in 0..4000 {
835 s.tick(now);
836 for j in 0..s.n_conns() {
837 s.on_bytes(j, 500, now, 0.01);
838 }
839 assert!(s.coverage_holds(), "coverage broke at t={now}");
840 assert!(s.liveness_holds(), "stuck at t={now}");
841 now += 0.01;
842 if s.is_complete() {
843 break;
844 }
845 }
846 assert!(
847 s.is_complete(),
848 "did not finish: {} / {}",
849 s.bytes_held(),
850 1_000_000
851 );
852 }
853
854 #[test]
855 fn fully_stolen_range_does_not_livelock() {
856 // Regression: a connection whose active range is stolen down to its
857 // current position goes idle WITHOUT completing. If the queue-start
858 // path is missing, its queued bytes are never requested.
859 let mut s = Scheduler::new(200_000, vec![src(1e5), src(1e5)], &[1, 1]);
860 s.tick(0.0);
861 // conn 0 makes progress, conn 1 stalls entirely
862 let mut now = 0.06;
863 for _ in 0..50 {
864 s.on_bytes(0, 1000, now, 0.01);
865 now += 0.01;
866 s.tick(now);
867 }
868 // force a steal by making conn 1 look terrible, then run to completion
869 for _ in 0..20000 {
870 s.tick(now);
871 s.on_bytes(0, 1000, now, 0.01);
872 now += 0.01;
873 assert!(s.liveness_holds(), "livelocked at t={now}");
874 if s.is_complete() {
875 break;
876 }
877 }
878 assert!(s.is_complete());
879 }
880
881 #[test]
882 fn stall_reclaim_returns_bytes() {
883 let mut s = Scheduler::new(100_000, vec![src(1e5), src(1e5)], &[1, 1]);
884 s.tick(0.0);
885 let before = s.stats.reclaims;
886 // no bytes at all: both connections must be reclaimed after the timeout
887 let acts = s.tick(5.0);
888 assert!(s.stats.reclaims > before);
889 assert!(acts.iter().any(|a| matches!(a, Action::Cancel { .. })));
890 assert!(s.coverage_holds());
891 assert!(s.liveness_holds());
892 }
893
894 #[test]
895 fn suspend_source_reclaims_and_reassigns() {
896 let mut s = Scheduler::new(100_000, vec![src(1e5), src(1e5)], &[1, 1]);
897 s.tick(0.0);
898 s.suspend_source(0, 10.0);
899 // Reclaimed bytes are now unassigned. They are NOT reassigned instantly:
900 // the surviving connection is still streaming its own range, and taking
901 // work from it would violate nothing but achieve nothing either. Work
902 // conservation only requires that no connection sit IDLE while work
903 // remains -- so the reassignment happens when conn 1 next goes idle.
904 assert!(s.coverage_holds());
905 assert!(s.unassigned.total() > 0);
906
907 let mut now = 0.2;
908 let mut served_by_1 = false;
909 for _ in 0..20_000 {
910 let acts = s.tick(now);
911 if acts
912 .iter()
913 .any(|a| matches!(a, Action::Request { conn, .. } if s.conns[*conn].source == 1))
914 {
915 served_by_1 = true;
916 }
917 s.on_bytes(1, 1000, now, 0.01);
918 now += 0.01;
919 assert!(s.coverage_holds());
920 assert!(s.liveness_holds());
921 if s.is_complete() {
922 break;
923 }
924 }
925 assert!(
926 served_by_1,
927 "surviving source never picked up the reclaimed work"
928 );
929 assert!(s.is_complete(), "held {} of 100000", s.bytes_held());
930 }
931
932 #[test]
933 fn greedy_matches_exhaustive_small() {
934 // rho/gamma chosen so the optimum is interior
935 let rho = [2.2e6, 1.1e6, 0.7e6];
936 let gam = [0.55e6, 0.45e6, 0.35e6];
937 let cap = 5.0e6;
938 for budget in 1..10usize {
939 let n = greedy_concurrency(&rho, &gam, cap, budget);
940 let g = |n: &[usize]| -> f64 {
941 let s: f64 = (0..3).map(|i| rho[i].min(n[i] as f64 * gam[i])).sum();
942 s.min(cap)
943 };
944 let mut best = 0.0f64;
945 for a in 0..=budget {
946 for b in 0..=budget {
947 for c in 0..=budget {
948 if a + b + c <= budget {
949 best = best.max(g(&[a, b, c]));
950 }
951 }
952 }
953 }
954 assert!(
955 (g(&n) - best).abs() < 1.0,
956 "budget {budget}: greedy {} vs {}",
957 g(&n),
958 best
959 );
960 }
961 }
962
963 #[test]
964 fn saturation_stops_allocation() {
965 // one source, rho = 2*gamma: two connections saturate it
966 let n = greedy_concurrency(&[2.0e6], &[1.0e6], 1e9, 10);
967 assert_eq!(
968 n[0], 2,
969 "allocated {n:?}, expected exactly the saturation point"
970 );
971 }
972 /// The detector must make the SCHEDULER act sooner, not merely grade sooner.
973 ///
974 /// A connection collapsing to 3% of its rate must be chosen as a repair
975 /// victim well before the stall timeout would have reclaimed it. Without
976 /// health-ranked victim selection the scheduler waits for the projected ETA
977 /// to drift, which is the fixed detection cost measured at 0.25-0.9 s.
978 #[test]
979 fn collapsed_connection_becomes_a_repair_victim_before_the_stall_timeout() {
980 const S: u64 = 40_000_000;
981 let mut sc = Scheduler::new(S, vec![src(4e6), src(4e6)], &[1, 1]).with_stall_timeout(10.0);
982 sc.tick(0.0);
983 let mut now = 0.0;
984 // Both healthy for a while.
985 for _ in 0..12 {
986 now += 0.1;
987 sc.on_bytes(0, 400_000, now, 0.1);
988 sc.on_bytes(1, 400_000, now, 0.1);
989 sc.tick(now);
990 }
991 assert_eq!(sc.conn_health(0), crate::detect::Health::Healthy);
992
993 // Connection 0 collapses; connection 1 keeps its rate.
994 let mut flagged_at = None;
995 for _ in 0..8 {
996 now += 0.1;
997 sc.on_bytes(0, 12_000, now, 0.1);
998 sc.on_bytes(1, 400_000, now, 0.1);
999 sc.tick(now);
1000 if flagged_at.is_none() && sc.conn_health(0).is_suspect_or_worse() {
1001 flagged_at = Some(now);
1002 }
1003 }
1004 let t = flagged_at.expect("collapse must be graded");
1005 assert!(
1006 t < 1.2 + 10.0,
1007 "must be flagged well before the 10 s stall timeout, was {t}"
1008 );
1009 // And the healthy connection must never be the one downgraded.
1010 assert_eq!(
1011 sc.conn_health(1),
1012 crate::detect::Health::Healthy,
1013 "the connection holding its rate must stay Healthy"
1014 );
1015 assert!(sc.coverage_holds() && sc.liveness_holds());
1016 }
1017 /// The initial split must respect `mark_done`.
1018 ///
1019 /// Regression test: an earlier version partitioned `[0, size)` arithmetically
1020 /// and never consulted the unassigned set, so `mark_done` was silently
1021 /// ignored. That broke `--range` (fetched from offset 0 instead of the
1022 /// requested interval) and `--continue` (re-fetched bytes already on disk).
1023 #[test]
1024 fn initial_split_never_requests_bytes_marked_done() {
1025 let size = 100_000u64;
1026 let mut s = Scheduler::new(size, vec![src(1e6), src(1e6)], &[1, 1]);
1027 // Range mode: only [90_000, 90_512) is wanted.
1028 s.mark_done(0, 90_000);
1029 s.mark_done(90_512, size);
1030 let acts = s.tick(0.0);
1031 assert!(
1032 !acts.is_empty(),
1033 "the wanted interval must still be requested"
1034 );
1035 for a in &acts {
1036 if let Action::Request { range, .. } = a {
1037 assert!(
1038 range.lo >= 90_000 && range.hi <= 90_512,
1039 "requested {range:?} outside the wanted interval"
1040 );
1041 }
1042 }
1043 assert!(s.coverage_holds());
1044 }
1045
1046 /// Overlapping `mark_done` calls must not inflate the held count.
1047 ///
1048 /// Regression test for a silent truncation. `mark_done` credited the width of
1049 /// the span it was given rather than the bytes it actually claimed, so two
1050 /// callers marking the same prefix — a `-c` resume replaying its sidecar, and
1051 /// the concurrency probe reporting the bytes it fetched, both of which start
1052 /// at offset 0 — pushed `held` past the object's real length. `is_complete()`
1053 /// tests exactly that counter, so the transfer stopped believing it was
1054 /// finished and left a zero-filled hole in the tail of a file it reported as
1055 /// a success: measured at 240 138 unwritten bytes on an 11 200 900-byte
1056 /// object whose gzip then refused to decompress.
1057 #[test]
1058 fn overlapping_mark_done_credits_each_byte_once() {
1059 let size = 100_000u64;
1060 let mut s = Scheduler::new(size, vec![src(1e6)], &[1]);
1061 s.mark_done(0, 30_000); // a resume record
1062 s.mark_done(0, 10_000); // the probe, re-reporting part of the same prefix
1063 assert_eq!(
1064 s.bytes_held(),
1065 30_000,
1066 "the overlap must be credited once, not twice"
1067 );
1068 assert!(!s.is_complete(), "70 000 bytes are still missing");
1069
1070 // Marking every byte, in overlapping pieces, is completion — and exactly
1071 // completion, never more.
1072 s.mark_done(20_000, size);
1073 s.mark_done(0, size);
1074 assert_eq!(s.bytes_held(), size);
1075 assert!(s.is_complete());
1076 }
1077
1078 /// After the probe's ranges are marked, `held_ranges` must describe them.
1079 ///
1080 /// This is what the pre-transfer checkpoint writes into the sidecar, so that a
1081 /// ^C during or shortly after the concurrency probe does not discard bytes the
1082 /// probe already fetched at true offsets. The periodic checkpoint inside the
1083 /// transfer only fires after 2 seconds, which an early interrupt beats.
1084 #[test]
1085 fn held_ranges_reports_probe_bytes_before_any_transfer() {
1086 let size = 11_200_900u64;
1087 let mut s = Scheduler::new(size, vec![Source::default()], &[1]);
1088 // Nothing fetched yet: nothing to checkpoint, and an empty record must not
1089 // be written as though it were progress.
1090 assert!(s.held_ranges().is_empty());
1091
1092 // The probe fetched a 3 MiB prefix into the real output.
1093 s.mark_done(0, 3 << 20);
1094 assert_eq!(s.held_ranges(), vec![(0, 3 << 20)]);
1095 assert_eq!(s.bytes_held(), 3 << 20);
1096
1097 // A second, disjoint probe range is reported as its own span rather than
1098 // merged into a count: a byte count cannot describe a hole, which is why
1099 // the sidecar stores ranges.
1100 s.mark_done(5 << 20, 6 << 20);
1101 assert_eq!(s.held_ranges(), vec![(0, 3 << 20), (5 << 20, 6 << 20)]);
1102
1103 // Adjacent spans DO coalesce, so the record stays compact across a long run.
1104 s.mark_done(3 << 20, 5 << 20);
1105 assert_eq!(s.held_ranges(), vec![(0, 6 << 20)]);
1106 }
1107
1108 /// Resume: bytes already on disk must never be re-requested.
1109 #[test]
1110 fn resume_does_not_refetch_held_prefix() {
1111 let size = 64_000u64;
1112 let mut s = Scheduler::new(size, vec![src(1e6)], &[2]);
1113 s.mark_done(0, 48_000); // three quarters already fetched
1114 let acts = s.tick(0.0);
1115 for a in &acts {
1116 if let Action::Request { range, .. } = a {
1117 assert!(
1118 range.lo >= 48_000,
1119 "re-requested a held byte at {}",
1120 range.lo
1121 );
1122 }
1123 }
1124 assert_eq!(
1125 s.bytes_held(),
1126 48_000,
1127 "held count must include the resumed prefix"
1128 );
1129 assert!(s.coverage_holds());
1130 }
1131}