Skip to main content

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}