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 /// The far end of this connection's in-flight range moved DOWN to `hi`: a
38 /// repair handed the tail `[hi, old_hi)` to another connection. Stop reading
39 /// at `hi`.
40 ///
41 /// # Why this action has to exist
42 ///
43 /// The whole claim of this scheduler is that shrinking a laggard's range is
44 /// free, because an HTTP range request names both ends and the far end is
45 /// enforced by the client. That is true of the protocol. It was NOT true of
46 /// this implementation: the repair below moved `conns[vi].range` and emitted
47 /// nothing, while the transport's fetch loop runs `while off < hi` against
48 /// the `hi` it captured when the request was spawned. The victim therefore
49 /// kept pulling the bytes it had just been relieved of, at the same time as
50 /// the taker pulled them, over the same bottleneck.
51 ///
52 /// So each repair cost roughly one stolen span of duplicated traffic instead
53 /// of nothing, and since the duplicate traffic slowed the honest
54 /// connections, it manufactured the very divergence that triggers a repair.
55 /// That positive feedback loop is the measured "repair storm": at n=8 on a
56 /// stationary 5.3 MB transfer, 32-49 repairs where the correct count is 0,
57 /// with in-run throughput decaying 439 -> 306 KiB/s.
58 ///
59 /// A caller that ignores this action is not merely leaving an optimisation
60 /// on the table; it reintroduces the storm.
61 Shrink { conn: usize, hi: u64 },
62}
63
64#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65pub enum Capability {
66 /// Ranges honoured, length known, strong validator: full scheduling.
67 Full,
68 /// Ranges honoured but no validator: partition, but pin to one source.
69 NoValidator,
70 /// Ranges ignored or unsupported: race whole-object fetches.
71 Race,
72 /// Length unknown: single stream per source, no range arithmetic.
73 Stream,
74}
75
76#[derive(Clone, Debug)]
77pub struct Source {
78 pub caps: Capability,
79 /// Per-connection goodput ceiling estimate, bytes/s.
80 pub gamma_est: f64,
81 /// Per-source shaping cap estimate, bytes/s.
82 pub rho_est: f64,
83 /// Measured request setup cost, seconds.
84 pub delta_est: f64,
85 /// Suspended until this time (429/503 Retry-After, or stall backoff).
86 pub suspended_until: f64,
87 /// Consecutive stalls observed on this source; drives exponential backoff.
88 pub consecutive_stalls: u32,
89}
90
91impl Default for Source {
92 fn default() -> Self {
93 Source {
94 caps: Capability::Full,
95 gamma_est: 0.0,
96 rho_est: f64::INFINITY,
97 delta_est: 0.05,
98 suspended_until: 0.0,
99 consecutive_stalls: 0,
100 }
101 }
102}
103
104#[derive(Clone, Debug)]
105struct Conn {
106 source: usize,
107 /// Active range and how far into it we are.
108 range: Option<Range>,
109 pos: u64,
110 /// One-slot pipeline: a range handed over by a repair.
111 queued: Option<Range>,
112 rate_est: f64,
113 /// Changepoint detector. `rate_est` remains the smoothed rate used for ETA
114 /// projection; this grades the connection so repair can pre-empt a collapse
115 /// instead of waiting for the stall timeout (see `detect.rs`).
116 detector: crate::detect::CollapseDetector,
117 last_progress: f64,
118 setup_end: f64,
119 /// When the request this connection is running now was issued.
120 ///
121 /// Arrivals older than this belong to a request that has been superseded —
122 /// reclaimed after a stall, cancelled, or failed — and must not be credited,
123 /// even when they land exactly at the cursor. See `on_bytes_at`.
124 started_at: f64,
125 stalled: bool,
126 /// Bytes and wall clock accumulated since the last RATE sample.
127 ///
128 /// Rate is measured over a fixed WINDOW, not per arrival. An arrival is one
129 /// `read()` return, and a read served from the socket's already-buffered data
130 /// completes in microseconds, so `bytes/dt` for that arrival measures memcpy
131 /// speed rather than network speed — observed as 128 MiB/s on a connection
132 /// whose link was doing well under 1 MiB/s.
133 ///
134 /// That is not merely a cosmetic display bug. Those inflated samples raise the
135 /// detector's reference level, after which every honest sample looks like a
136 /// collapse against it, and the CUSUM grades a perfectly healthy connection
137 /// `Degraded` — which is why all eight connections of a working transfer
138 /// showed as `bad`. Byte accounting stays exactly per-arrival (coverage must
139 /// be exact); only the rate estimate is windowed.
140 rate_acc_bytes: u64,
141 rate_acc_dt: f64,
142}
143
144impl Conn {
145 fn new(source: usize) -> Self {
146 Conn {
147 source,
148 range: None,
149 pos: 0,
150 queued: None,
151 rate_est: 0.0,
152 detector: crate::detect::CollapseDetector::new(),
153 rate_acc_bytes: 0,
154 rate_acc_dt: 0.0,
155 last_progress: 0.0,
156 setup_end: 0.0,
157 started_at: f64::NEG_INFINITY,
158 stalled: false,
159 }
160 }
161
162 #[inline]
163 fn busy(&self) -> bool {
164 self.range.map(|r| self.pos < r.hi).unwrap_or(false)
165 }
166
167 /// Bytes still owed on the active range plus anything pipelined.
168 #[inline]
169 fn outstanding(&self) -> u64 {
170 let active = self
171 .range
172 .map(|r| r.hi.saturating_sub(self.pos))
173 .unwrap_or(0);
174 active + self.queued.map(|r| r.len()).unwrap_or(0)
175 }
176
177 /// Projected seconds to drain. A stalled or unmeasured connection projects
178 /// to infinity so it is always chosen as the repair victim.
179 fn eta(&self) -> f64 {
180 let out = self.outstanding();
181 if out == 0 {
182 return 0.0;
183 }
184 if self.rate_est <= 0.0 {
185 return f64::INFINITY;
186 }
187 out as f64 / self.rate_est
188 }
189}
190
191#[derive(Clone, Copy, Debug, Default)]
192pub struct Stats {
193 pub requests: u64,
194 pub repairs: u64,
195 pub reclaims: u64,
196 pub bytes_held: u64,
197}
198
199pub struct Scheduler {
200 size: u64,
201 unassigned: IntervalSet,
202 held: u64,
203 conns: Vec<Conn>,
204 sources: Vec<Source>,
205 /// Repair deadband scale; theta = scale * sqrt(delta * T_rem / n).
206 /// Reused index buffer for the per-tick stalled-connection scan.
207 ///
208 /// The scan runs 50 times a second at the default tick and allocated a fresh `Vec`
209 /// each time, to hold at most `n_conns` indices. Reusing one buffer costs a field
210 /// and removes the allocation from the hot loop.
211 scratch_idx: Vec<usize>,
212 /// Reused index buffer for the per-tick assignment visit order.
213 ///
214 /// Separate from `scratch_idx` because the stalled scan above is still
215 /// holding that one when this is built, and for the same reason it exists:
216 /// this runs every tick, and a fresh `Vec` per tick is the allocation the
217 /// module header promises not to make.
218 scratch_order: Vec<usize>,
219 theta_scale: f64,
220 stall_timeout: f64,
221 /// How many connections may hold work at once. Adjustable mid-transfer so the
222 /// concurrency search can run on the real transfer rather than on probe
223 /// traffic; see `set_active_limit`.
224 active_limit: usize,
225 /// The largest `active_limit` this transfer can still reach.
226 ///
227 /// Distinct from `conns.len()`, which is the connection BUDGET, because the two
228 /// stopped meaning the same thing once the transport learned to lower its own
229 /// concurrency: an origin answering `429` teaches the transfer a ceiling well
230 /// below the budget, and nothing above that ceiling will ever be admitted.
231 ///
232 /// Assignment reads it. While concurrency can still grow, an idle connection is
233 /// handed a share of the remaining work rather than all of it, so the
234 /// connections admitted later find work waiting instead of having to steal.
235 /// Sizing that share against the budget when the ceiling is a fraction of it
236 /// hands out shares a fraction of the right size, and the transfer pays a
237 /// request — a round trip, and on a refused origin a fresh handshake — for
238 /// every one of them. Measured on a hermetic origin that serves two connections
239 /// and refuses the rest: 31 requests to deliver a 16 MB object at `-x 8`
240 /// against 2 at `-x 2`, and the difference was almost entirely first-byte
241 /// latency.
242 conn_ceiling: usize,
243 /// When false, victim selection ignores detector health and ranks purely by
244 /// projected ETA (the pre-detector behaviour). Exists so the detector's
245 /// contribution can be A/B measured rather than assumed.
246 health_ranking: bool,
247 started: bool,
248 pub stats: Stats,
249}
250
251impl Scheduler {
252 pub fn new(size: u64, sources: Vec<Source>, conns_per_source: &[usize]) -> Self {
253 let mut conns = Vec::new();
254 for (i, &k) in conns_per_source.iter().enumerate() {
255 for _ in 0..k {
256 conns.push(Conn::new(i));
257 }
258 }
259 Scheduler {
260 size,
261 unassigned: IntervalSet::full(size),
262 held: 0,
263 conns,
264 sources,
265 scratch_idx: Vec::new(),
266 scratch_order: Vec::new(),
267 theta_scale: 1.0,
268 stall_timeout: 1.0,
269 health_ranking: true,
270 // Default: every connection active, so nothing changes for callers that
271 // do not opt into the ramp.
272 active_limit: usize::MAX,
273 conn_ceiling: usize::MAX,
274 started: false,
275 stats: Stats::default(),
276 }
277 }
278
279 /// Cap how many connections may hold work at once, adjustable mid-transfer.
280 ///
281 /// # Why the concurrency search belongs here and not in a probe
282 ///
283 /// Finding the useful connection count by *probing* — fetch a slab with one
284 /// connection, then with two, then three, comparing goodput — is the standard
285 /// approach and it is what this client did. HARP (Kim, Yildirim, Kosar, SC'16)
286 /// names the cost directly: probing "may bring too much probing overhead",
287 /// because the samples are extra transfers whose price is paid before the real
288 /// one starts. Measured here on a 3.15 MB object over a live path, the climbing
289 /// probe made the transfer **1.96x slower** than not probing at all
290 /// (paired over 9 interleaved reps, p = 0.004) — the search cost more than the
291 /// concurrency it found could save.
292 ///
293 /// The probe is only necessary because concurrency is fixed when the transfer
294 /// starts. Make it adjustable and the same search runs on the *real* transfer:
295 /// start at one connection, measure aggregate goodput over a short window,
296 /// admit another connection while the marginal gain justifies it, and stop.
297 /// Every byte moved during the search is a byte of the object, so the search
298 /// is free — the object had to be fetched anyway. What HARP buys with a
299 /// historical corpus, this buys by putting the measurement in-band.
300 ///
301 /// Connections above the limit stay dormant: they are not given work and open
302 /// no socket. Raising the limit lets the next tick hand them work through the
303 /// ordinary work-conserving path, so no new admission machinery is needed.
304 pub fn set_active_limit(&mut self, n: usize) {
305 self.active_limit = n.clamp(1, self.conns.len().max(1));
306 }
307
308 /// The current concurrency cap.
309 pub fn active_limit(&self) -> usize {
310 self.active_limit
311 }
312
313 /// Declare the largest concurrency this transfer can still reach.
314 ///
315 /// Lowered when the origin refuses requests, raised when a refusal-free stretch
316 /// earns a connection back. Assignment reserves work only for connections that
317 /// can actually arrive, so telling the scheduler the real ceiling is what stops
318 /// a throttled transfer from carving the object into budget-sized shares nobody
319 /// will ever come for.
320 pub fn set_conn_ceiling(&mut self, n: usize) {
321 self.conn_ceiling = n.clamp(1, self.conns.len().max(1));
322 }
323
324 /// The largest concurrency still reachable, never above the budget.
325 fn ceiling(&self) -> usize {
326 self.conn_ceiling.min(self.conns.len()).max(1)
327 }
328
329 /// When every source is deliberately suspended, the earliest time one returns.
330 ///
331 /// `None` means at least one source is usable now, so a lack of progress is a
332 /// genuine stall. `Some(t)` means the scheduler has *chosen* to pause every
333 /// source until `t` — nothing can move before then, and that silence is planned
334 /// rather than pathological.
335 ///
336 /// # Why a caller must consult this
337 ///
338 /// The transport's no-progress watchdog exists to fail a transfer where nothing
339 /// will ever happen again. A scheduled retry is the opposite of that, and
340 /// conflating the two is not hypothetical: with one source (the common case —
341 /// one URL, one CDN), `stall_timeout` 4.0s gives a watchdog of
342 /// `4 * (4.0 + delta)` = 16.2s, while five consecutive stalls suspend that sole
343 /// source for `min(4.0 * 2^3, 30)` = 30s. The transfer is then killed at 16.2s
344 /// for failing to make progress it had itself forbidden.
345 ///
346 /// Measured consequence on a 121.7 MiB GitHub release asset: 4 of 8 runs at
347 /// `-x 8`/`-x 16` aborted with a digest mismatch, three of them having already
348 /// received 126.9-127.0 MB of 127.6 MB — 99.6% complete, killed during a
349 /// deliberate backoff over the last half-megabyte.
350 pub fn all_sources_suspended_until(&self, now: f64) -> Option<f64> {
351 let mut earliest = f64::INFINITY;
352 for s in &self.sources {
353 if s.suspended_until <= now {
354 return None;
355 }
356 earliest = earliest.min(s.suspended_until);
357 }
358 if earliest.is_finite() {
359 Some(earliest)
360 } else {
361 None
362 }
363 }
364
365 /// Whether any work is still unclaimed by any connection.
366 ///
367 /// Exposed so the ramp's contract is testable: while concurrency is below the
368 /// budget, work must remain here for connections admitted later to pick up.
369 pub fn unassigned_is_empty(&self) -> bool {
370 self.unassigned.is_empty()
371 }
372
373 /// How many bytes are still unclaimed by any connection.
374 ///
375 /// The same contract as [`Self::unassigned_is_empty`], measured rather than
376 /// merely asserted: a reserve that has been whittled down to one sliver is
377 /// not empty and is not a reserve either.
378 pub fn unassigned_total(&self) -> u64 {
379 self.unassigned.total()
380 }
381
382 /// How many connections currently hold a range.
383 pub fn busy_conns(&self) -> usize {
384 self.conns.iter().filter(|c| c.busy()).count()
385 }
386
387 /// Connections that count against `active_limit` right now: busy, or already
388 /// holding queued work one tick from starting.
389 ///
390 /// This is what "dormant" is measured against, not connection index. The
391 /// budget is a COUNT of connections in play, not a privilege attached to
392 /// low indices — a connection above `active_limit` that is still busy is
393 /// not "excess", it is simply already spending the budget it was granted
394 /// when it was admitted, and one at any index is free to spend it once
395 /// something else stops.
396 ///
397 /// Queued connections are counted for the same reason `on_bytes` and
398 /// divergence repair must not both admit into the same headroom in one
399 /// tick: a connection with `queued` set has already been promised a slot,
400 /// even though it has not opened a socket yet.
401 fn admitted(&self) -> usize {
402 self.conns
403 .iter()
404 .filter(|c| c.busy() || c.queued.is_some())
405 .count()
406 }
407
408 /// Start with only `n` connections active, ramping up from there.
409 pub fn with_active_limit(mut self, n: usize) -> Self {
410 self.set_active_limit(n);
411 self
412 }
413
414 pub fn with_theta_scale(mut self, s: f64) -> Self {
415 self.theta_scale = s;
416 self
417 }
418
419 /// Disable health-ranked victim selection (for A/B measurement only).
420 pub fn with_health_ranking(mut self, on: bool) -> Self {
421 self.health_ranking = on;
422 self
423 }
424
425 pub fn with_stall_timeout(mut self, t: f64) -> Self {
426 self.stall_timeout = t;
427 self
428 }
429
430 /// Mark `[lo, hi)` as already held, for resuming a partial transfer.
431 ///
432 /// Must be called before the first `tick`: the initial split assigns all
433 /// unassigned work, and bytes already on disk must not be part of it.
434 pub fn mark_done(&mut self, lo: u64, hi: u64) {
435 let (lo, hi) = (lo.min(self.size), hi.min(self.size));
436 if hi <= lo {
437 return;
438 }
439 // Credit only the bytes this call actually claims, measured as the drop in
440 // the unassigned set — NOT the width of the span asked for.
441 //
442 // Callers legitimately overlap. A `-c` resume marks the sidecar's ranges
443 // held, and the concurrency probe separately reports the bytes it fetched;
444 // both start at offset 0, so the same prefix is marked twice. Crediting
445 // `hi - lo` each time made `held` exceed the bytes that exist, and `held`
446 // is what `is_complete()` tests: the transfer stopped early believing it
447 // was finished, leaving a zero-filled hole in the tail of a file reported
448 // as a success. Measured on an interrupted-then-resumed 11 200 900-byte
449 // object: 240 138 bytes of tail never written, `ok: true`, and the gzip
450 // refused to decompress.
451 let before = self.unassigned.total();
452 self.unassigned.remove(lo, hi);
453 let claimed = before.saturating_sub(self.unassigned.total());
454 self.held = self.held.saturating_add(claimed);
455 }
456
457 /// Health grade of a connection, for the progress UI and for tests.
458 pub fn conn_health(&self, j: usize) -> crate::detect::Health {
459 self.conns
460 .get(j)
461 .map(|c| c.detector.health())
462 .unwrap_or_default()
463 }
464
465 /// Source index a connection belongs to, for the progress UI.
466 pub fn conn_source(&self, j: usize) -> usize {
467 self.conns.get(j).map(|c| c.source).unwrap_or(0)
468 }
469
470 /// Smoothed rate estimate of a connection (bytes/s), for the progress UI.
471 pub fn conn_rate(&self, j: usize) -> f64 {
472 self.conns.get(j).map(|c| c.rate_est).unwrap_or(0.0)
473 }
474
475 /// Active range of a connection, for the progress UI.
476 pub fn conn_range(&self, j: usize) -> Option<(u64, u64, u64)> {
477 self.conns
478 .get(j)
479 .and_then(|c| c.range.map(|r| (r.lo, c.pos, r.hi)))
480 }
481
482 pub fn n_conns(&self) -> usize {
483 self.conns.len()
484 }
485
486 pub fn is_complete(&self) -> bool {
487 self.held >= self.size
488 }
489
490 pub fn bytes_held(&self) -> u64 {
491 self.held
492 }
493
494 /// The ranges that are complete on disk, as `(lo, hi)` pairs.
495 ///
496 /// This is the complement of the unassigned set minus what is still in flight, and
497 /// it is what a resume record must contain. Reporting only a byte COUNT is not
498 /// enough: positioned writes land ranges out of order, so "2 MB held" says nothing
499 /// about which 2 MB, and a resume that assumed a contiguous prefix would skip holes
500 /// and silently corrupt the file.
501 pub fn held_ranges(&self) -> Vec<(u64, u64)> {
502 // Start from everything, then subtract what is unassigned and what is
503 // outstanding on a connection; what remains has arrived.
504 let mut done = IntervalSet::full(self.size);
505 for r in self.unassigned.ranges() {
506 done.remove(r.lo, r.hi);
507 }
508 for c in &self.conns {
509 if let Some(r) = c.range {
510 // Bytes before the cursor have arrived; the rest has not.
511 done.remove(c.pos, r.hi);
512 }
513 if let Some(q) = c.queued {
514 done.remove(q.lo, q.hi);
515 }
516 }
517 done.ranges().iter().map(|r| (r.lo, r.hi)).collect()
518 }
519
520 /// Coverage audit: held + outstanding + unassigned == size.
521 ///
522 /// This is a SAFETY invariant and it does NOT imply liveness -- the
523 /// livelock this code is written to avoid (a fully-stolen range leaving a
524 /// connection idle with a non-empty queue) satisfies it at every instant.
525 /// `liveness_holds` is the property that matters.
526 /// The largest measured request setup cost across sources, in seconds.
527 ///
528 /// Exposed because a transport-layer watchdog must express its patience in
529 /// units of what a request actually costs on this path rather than as a
530 /// hardcoded constant: `delta` differs by an order of magnitude between a
531 /// LAN mirror and a TLS connection through a proxy, and a fixed timeout is
532 /// either trigger-happy on the slow path or useless on the fast one.
533 ///
534 /// This is the same quantity the repair deadband is built from
535 /// (`theta = scale * sqrt(delta * T_rem / n)`), so a client that widens
536 /// `delta` widens both together, which is the intended coupling.
537 pub fn worst_delta(&self) -> f64 {
538 self.sources
539 .iter()
540 .map(|s| s.delta_est)
541 .fold(0.0f64, f64::max)
542 }
543
544 /// The configured stall timeout, in seconds.
545 pub fn stall_timeout(&self) -> f64 {
546 self.stall_timeout
547 }
548
549 pub fn coverage_holds(&self) -> bool {
550 let outstanding: u64 = self.conns.iter().map(|c| c.outstanding()).sum();
551 self.held + outstanding + self.unassigned.total() == self.size
552 && self.unassigned.invariant_holds()
553 }
554
555 /// True when some enabled transition strictly decreases the unheld-byte count.
556 /// False means the scheduler is stuck.
557 pub fn liveness_holds(&self) -> bool {
558 if self.is_complete() {
559 return true;
560 }
561 // progress possible if: someone is receiving, or work is assignable,
562 // or a connection holds a queue it can start, or a stall can be reclaimed
563 self.conns.iter().any(|c| c.busy() && !c.stalled)
564 || !self.unassigned.is_empty()
565 || self.conns.iter().any(|c| c.queued.is_some())
566 || self.conns.iter().any(|c| c.stalled)
567 }
568
569 // ---------------------------------------------------------------- input
570
571 /// Record `n` bytes arriving on `conn` at time `now` over `dt` seconds.
572 ///
573 /// Convenience wrapper that assumes the arrival is contiguous at the
574 /// connection's cursor. Real transports must use [`Scheduler::on_bytes_at`]:
575 /// a response still draining from a range that was completed or stolen would
576 /// otherwise be credited against whatever range the connection holds NOW,
577 /// silently advancing a cursor over bytes that never arrived and leaving a
578 /// hole of zeros in the output file.
579 pub fn on_bytes(&mut self, conn: usize, n: u64, now: f64, dt: f64) {
580 let at = self.conns[conn].pos;
581 self.on_bytes_at(conn, at, n, now, dt);
582 }
583
584 /// Record `n` bytes that landed at absolute offset `off`.
585 ///
586 /// Arrivals that do not begin exactly at the connection's cursor are stale
587 /// (they belong to a superseded request) and are discarded: the bytes are
588 /// still written to the file by the transport, but they are not credited,
589 /// so the scheduler's coverage accounting stays exact.
590 pub fn on_bytes_at(&mut self, conn: usize, off: u64, n: u64, now: f64, dt: f64) {
591 let c = &mut self.conns[conn];
592 let Some(r) = c.range else { return };
593 if off != c.pos || off < r.lo {
594 return; // stale arrival from a superseded range
595 }
596 // ---- and stale by TIME, not only by offset --------------------------
597 //
598 // Matching the cursor is not enough to prove an arrival belongs to the
599 // request in flight. When a connection is reclaimed and re-requested, the
600 // new request starts at exactly the cursor the old one stopped at — so
601 // the last writes of the aborted request, still in the caller's queue,
602 // land at precisely the offset the new request is waiting for.
603 //
604 // Crediting them is not a coverage error (the bytes are on disk) but it
605 // desynchronises the connection: the cursor moves past where the new
606 // response begins, so every arrival that response produces fails the test
607 // above and is discarded. The connection then delivers bytes that are
608 // never counted, reads as silent, and is rescued only by the stall
609 // timeout — seconds of dead air, and the transfer visibly frozen for them
610 // once the endgame has left one connection carrying the remainder.
611 //
612 // A request cannot be answered before it was issued, so the arrival's own
613 // timestamp settles it.
614 if now < c.started_at {
615 return;
616 }
617 let room = r.hi.saturating_sub(c.pos);
618 let step = n.min(room);
619 if step == 0 {
620 return;
621 }
622 c.pos += step;
623 self.held += step;
624 c.last_progress = now;
625 c.stalled = false;
626 let src = c.source;
627 self.sources[src].consecutive_stalls = 0;
628 if dt > 0.0 {
629 // Accumulate, and only take a rate sample once the window has enough
630 // wall clock in it to mean something.
631 c.rate_acc_bytes += step;
632 c.rate_acc_dt += dt;
633 if c.rate_acc_dt >= RATE_WINDOW {
634 let sample = c.rate_acc_bytes as f64 / c.rate_acc_dt;
635 c.rate_acc_bytes = 0;
636 c.rate_acc_dt = 0.0;
637 c.detector.observe_rate(sample);
638 c.rate_est = if c.rate_est <= 0.0 {
639 sample
640 } else {
641 RATE_ALPHA * sample + (1.0 - RATE_ALPHA) * c.rate_est
642 };
643 }
644 }
645 if c.pos >= r.hi {
646 c.range = None;
647 }
648 }
649
650 /// Suspend a source (429/503 with Retry-After) and reclaim its ranges.
651 pub fn suspend_source(&mut self, src: usize, until: f64) {
652 self.sources[src].suspended_until = until;
653 let idxs: Vec<usize> = (0..self.conns.len())
654 .filter(|&j| self.conns[j].source == src)
655 .collect();
656 for j in idxs {
657 self.reclaim(j);
658 }
659 }
660
661 /// A connection's transport failed: reclaim its range NOW, and hold that
662 /// connection back for `retry_after` seconds.
663 ///
664 /// # Why silence is not the right signal for a failure
665 ///
666 /// The stall timeout exists to grade a connection that is *delivering
667 /// nothing*, and it has to be patient — several seconds at least, scaled to
668 /// the measured setup cost, because a slow path is not a broken one. A fetch
669 /// that has already returned an error needs none of that patience: the
670 /// question the timeout is there to answer has been answered, by the
671 /// transport, definitively.
672 ///
673 /// Without this the two are conflated, and the cost is paid in whole stall
674 /// timeouts. A connection whose socket was closed by the peer, whose body was
675 /// truncated, or whose request was refused looks exactly like a slow one, so
676 /// the range is not re-requested for 4-45 s (the range `stall_timeout` covers
677 /// on real paths). Early in a transfer the other connections cover for it and
678 /// nothing is visible; at the end, when the remaining work has concentrated
679 /// onto one or two connections, the whole transfer freezes for it — the
680 /// reported "downloads stall past 90%, transfer rate falls to zero, every
681 /// connection shows disconnected" failure.
682 ///
683 /// `retry_after` is the caller's backoff for THIS connection only. The range
684 /// goes back to the unassigned set immediately either way, so an idle
685 /// connection can pick it up on the next tick without waiting for it.
686 pub fn on_conn_error(&mut self, conn: usize, now: f64, retry_after: f64) {
687 if conn >= self.conns.len() {
688 return;
689 }
690 self.reclaim(conn);
691 let until = now + retry_after.max(0.0);
692 let c = &mut self.conns[conn];
693 c.setup_end = until;
694 // The stall clock starts when the connection is allowed to work again;
695 // otherwise the backoff it was told to take is charged against it as
696 // silence and it is graded stalled the moment it comes back.
697 c.last_progress = until;
698 }
699
700 fn reclaim(&mut self, j: usize) {
701 let c = &mut self.conns[j];
702 if let Some(r) = c.range {
703 if c.pos < r.hi {
704 let back = Range::new(c.pos, r.hi);
705 c.range = None;
706 let q = c.queued.take();
707 self.unassigned.insert(back);
708 if let Some(q) = q {
709 self.unassigned.insert(q);
710 }
711 self.stats.reclaims += 1;
712 } else {
713 c.range = None;
714 }
715 } else if let Some(q) = c.queued.take() {
716 self.unassigned.insert(q);
717 self.stats.reclaims += 1;
718 }
719 let c = &mut self.conns[j];
720 c.rate_est = 0.0;
721 c.stalled = true;
722 }
723
724 // ---------------------------------------------------------------- tick
725
726 /// Advance the scheduler. Returns the actions the caller must perform.
727 pub fn tick(&mut self, now: f64) -> Vec<Action> {
728 let mut acts = Vec::new();
729
730 if !self.started {
731 self.initial_split(now, &mut acts);
732 self.started = true;
733 return acts;
734 }
735
736 // ---- feed wall-clock silence to the detectors ----------------------
737 // A connection delivering nothing produces no rate samples at all, so
738 // silence is evidence that only the clock can supply. Grading it here
739 // lets repair pre-empt at half the stall timeout instead of waiting for
740 // the full timeout to expire.
741 for j in 0..self.conns.len() {
742 let c = &self.conns[j];
743 if c.busy() && now >= c.setup_end {
744 let quiet = now - c.last_progress.max(c.setup_end);
745 let st = self.stall_timeout;
746 self.conns[j].detector.observe_silence(quiet, st);
747 }
748 }
749
750 // ---- liveness path 1: reclaim stalled connections -----------------
751 //
752 // Collected into a reused buffer rather than a fresh `Vec` each tick. The
753 // indices cannot be reclaimed in the same pass that finds them — `reclaim`
754 // takes `&mut self` while the filter borrows `self.conns` — so the two-phase
755 // shape stays, but the allocation does not have to. `std::mem::take` moves the
756 // buffer out so the loop below can hold it while `self` is borrowed mutably,
757 // and it is put back at the end for the next tick.
758 let mut stalled = std::mem::take(&mut self.scratch_idx);
759 stalled.clear();
760 stalled.extend((0..self.conns.len()).filter(|&j| {
761 let c = &self.conns[j];
762 c.busy()
763 && now >= c.setup_end
764 && (now - c.last_progress.max(c.setup_end)) > self.stall_timeout
765 }));
766 for j in stalled.drain(..) {
767 self.reclaim(j);
768 acts.push(Action::Cancel { conn: j });
769 // A source that keeps stalling must be suspended, not merely
770 // retried: otherwise work-conserving assignment hands it the same
771 // bytes repeatedly without making forward progress.
772 let src = self.conns[j].source;
773 self.sources[src].consecutive_stalls += 1;
774 let k = self.sources[src].consecutive_stalls;
775 if k >= 2 {
776 let mut backoff = (self.stall_timeout * (1u64 << (k - 2).min(5)) as f64).min(30.0);
777 // Never suspend the LAST usable source for longer than a caller's
778 // watchdog will wait. Exponential backoff is right when there is
779 // somewhere else to send the work; when this is the only source it
780 // is a self-inflicted outage, and a transport that fails on silence
781 // cannot tell it apart from the source being gone.
782 //
783 // Callers should also consult `all_sources_suspended_until` so a
784 // planned pause is not charged against a no-progress deadline. This
785 // clamp is the second line of defence: it keeps the invariant local
786 // to the scheduler, so a caller that does not know about deliberate
787 // suspension still cannot be starved by it.
788 if self.sources.len() == 1 {
789 backoff = backoff.min(self.stall_timeout.max(1.0));
790 }
791 self.sources[src].suspended_until = now + backoff;
792 }
793 }
794
795 // ---- liveness path 2: an idle connection holding a queue MUST start it
796 //
797 // Mandatory: a connection whose active range was entirely stolen goes
798 // idle WITHOUT completing, so the completion path in on_bytes never fires
799 // and the queued bytes would be owned by an idle connection that never
800 // requests them.
801 for j in 0..self.conns.len() {
802 if !self.conns[j].busy()
803 && self.conns[j].queued.is_some()
804 && now >= self.conns[j].setup_end
805 {
806 let r = self.conns[j].queued.take().unwrap();
807 self.start(j, r, now);
808 acts.push(Action::Request { conn: j, range: r });
809 }
810 }
811
812 // ---- divergence-triggered repair ---------------------------------
813 let theta = self.theta(now);
814 for _ in 0..MAX_REPAIRS_PER_TICK {
815 let Some((vi, ti)) = self.pick_victim_taker(now) else {
816 break;
817 };
818 let (v_eta, t_eta) = (self.conns[vi].eta(), self.conns[ti].eta());
819 // Explicit ordering test: an unknown ETA yields NaN, and a NaN
820 // divergence must NOT trigger a repair (a repair costs a full delta,
821 // so acting on an unmeasured quantity is strictly a loss).
822 if !matches!(
823 (v_eta - t_eta).partial_cmp(&theta),
824 Some(core::cmp::Ordering::Greater)
825 ) {
826 break;
827 }
828 if self.conns[ti].queued.is_some() {
829 break;
830 }
831 let Some(vr) = self.conns[vi].range else {
832 break;
833 };
834 let left = vr.hi.saturating_sub(self.conns[vi].pos) as f64;
835 let rv = self.conns[vi].rate_est;
836 let rt = self.conns[ti].rate_est;
837 let delta = self.sources[self.conns[ti].source].delta_est;
838 // Equalise projected finishes, charging the taker one setup:
839 // (left - x)/rv == t_eta + delta + x/rt
840 let x = if rv <= 0.0 {
841 // Victim is stalled: hand over everything it has not received.
842 left
843 } else if rt <= 0.0 {
844 0.0
845 } else {
846 ((left / rv - t_eta - delta) * (rv * rt) / (rv + rt)).clamp(0.0, left)
847 };
848 if x <= STEAL_QUANTUM as f64 {
849 break;
850 }
851
852 // ---- does this repair actually pay for itself? -------------------
853 //
854 // The equalisation above solves `(left - x)/rv == t_eta + delta + x/rt`,
855 // which treats `rt` as capacity that `x` bytes can be moved ONTO. That
856 // is true when the connections have independent bottlenecks — separate
857 // mirrors, separate paths. It is false in the case that dominates real
858 // use: several connections to one origin, sharing one bottleneck. There
859 // the taker's rate is not spare capacity, it is a share of the same
860 // capacity the victim is using, so moving bytes across does not make
861 // them arrive faster. It only re-labels which connection carries them,
862 // and charges a setup for the privilege.
863 //
864 // Worse, the per-connection rate divergence that triggers the repair is
865 // largely a property of the PATH, not of the assignment: flows sharing
866 // a bottleneck settle at persistently unequal shares (roughly 1/RTT,
867 // with cwnd history making the asymmetry outlive any round trip). A
868 // repair cannot move that. So the divergence survives the repair, and
869 // re-triggers it.
870 //
871 // The test: compare the makespan now against the makespan after, where
872 // "after" charges the setup and credits only the improvement in the
873 // WORST finishing time — because the makespan is a max, not a sum, and
874 // improving anything other than the laggard buys nothing.
875 let makespan_now =
876 self.conns
877 .iter()
878 .map(|c| c.eta())
879 .fold(0.0f64, |a, b| if b > a { b } else { a });
880 // The victim keeps `left - x` at its own rate; the taker takes on `x`
881 // after paying `delta`, on top of what it already owes.
882 let v_after = if rv > 0.0 {
883 (left - x) / rv
884 } else {
885 f64::INFINITY
886 };
887 let t_after = if rt > 0.0 {
888 t_eta + delta + x / rt
889 } else {
890 f64::INFINITY
891 };
892 // Every other connection is unaffected by this particular exchange.
893 let others = self
894 .conns
895 .iter()
896 .enumerate()
897 .filter(|(j, _)| *j != vi && *j != ti)
898 .map(|(_, c)| c.eta())
899 .fold(0.0f64, |a, b| if b > a { b } else { a });
900 let makespan_after = v_after.max(t_after).max(others);
901 // Require the gain to exceed the setup it costs, not merely to be
902 // positive: a repair that improves the projected makespan by less than
903 // one delta has not accounted for its own price. `theta` above is the
904 // hysteresis that stops oscillation; this is the profitability test,
905 // and both are needed — the first keeps jitter from triggering repair,
906 // the second keeps a real-but-unprofitable divergence from doing so.
907 // Explicit ordering, matching the theta test above: an unmeasured rate
908 // makes this difference NaN, and a NaN must REFUSE the repair rather
909 // than fall through either way. Acting on an unmeasured quantity is
910 // strictly a loss, because the setup cost is certain and the gain is not.
911 if !matches!(
912 (makespan_now - makespan_after).partial_cmp(&delta),
913 Some(core::cmp::Ordering::Greater)
914 ) {
915 break;
916 }
917
918 let x = x as u64;
919 let new_hi = vr.hi - x;
920 let stolen = Range::new(new_hi, vr.hi);
921 // Client-side shrink: the victim's target end moves and the server is
922 // never told. Free on the WIRE — no cancellation, no round trip — but
923 // only if the local fetch loop is told, which is what `Shrink` does.
924 // Without it the victim streams the stolen span anyway; see the
925 // `Action::Shrink` docs for what that costs.
926 self.conns[vi].range = Some(Range::new(vr.lo, new_hi));
927 self.conns[ti].queued = Some(stolen);
928 acts.push(Action::Shrink {
929 conn: vi,
930 hi: new_hi,
931 });
932 self.stats.repairs += 1;
933 }
934
935 // ---- work-conserving assignment (Lemma 2) -------------------------
936 //
937 // A connection is DORMANT once the budget is spent: it is skipped here, so
938 // it is never given work and never opens a socket. This is the whole
939 // mechanism behind the in-band concurrency ramp — raising the limit makes
940 // the next tick admit a connection through this ordinary path, and
941 // lowering it lets an already-busy connection finish its range and then go
942 // quiet, with no cancellation and no wasted bytes.
943 //
944 // The budget is spent by COUNT (`admitted`), not by index. All connections
945 // are dispatched at once — a fixed `-x N`, or the opening burst before any
946 // refusal has taught the transfer anything — so which ones an origin
947 // happens to grant is not correlated with index at all. Gating eligibility
948 // on `j < active_limit` let a refusal-driven cap retire a connection the
949 // origin was actively serving just because its index was too high, while
950 // leaving a lower-index connection that was cooling down from its OWN
951 // refusal as the only thing still allowed to pick up new work — collapsing
952 // realised concurrency below what the origin would serve, which is the one
953 // thing this cap exists to prevent. See `admitted` for what counts.
954 //
955 // Candidates are visited proven connections first, unproven ones after —
956 // "proven" meaning `rate_est > 0.0`, which only a connection that has
957 // actually delivered bytes on this source carries; a reclaim resets it to
958 // zero. Plain index order reopens the exact bug above from the other
959 // side: the moment one of two settled, working connections finishes a
960 // chunk and goes idle for the one tick before this loop re-admits it, it
961 // is indistinguishable BY INDEX from a connection that has never
962 // delivered a byte and is only here because its OWN refusal cooldown
963 // happens to have expired on the same tick. Whichever has the lower
964 // index wins the freed slot — sometimes the untested one — and an origin
965 // that only ever grants the same two connections now refuses the
966 // newcomer, while the settled connection that actually earned the slot
967 // sits idle for another tick waiting its turn. Repeated over a transfer's
968 // life this is exactly the churn the ceiling exists to stop, just paid
969 // in requests instead of in stranded concurrency.
970 let mut order = std::mem::take(&mut self.scratch_order);
971 order.clear();
972 // One predicate and its exact complement, rather than `> 0.0` and
973 // `<= 0.0`: the two passes must partition the connections, and BOTH of
974 // those comparisons are false for a NaN rate. A connection that fell into
975 // neither would be dropped from the visit order entirely — never assigned
976 // work, and never reclaimed into service either, since reclaim only fires
977 // on connections that HOLD a range. Silent permanent idleness is not a
978 // failure mode worth leaving to the float rules.
979 let proven = |c: &Conn| c.rate_est > 0.0;
980 order.extend((0..self.conns.len()).filter(|&j| proven(&self.conns[j])));
981 order.extend((0..self.conns.len()).filter(|&j| !proven(&self.conns[j])));
982 // Connections that will want work on a LATER tick: idle, holding nothing,
983 // and held back by their own cooldown or a suspended source rather than by
984 // anything this pass can resolve. These are who the reserve below is for.
985 // Counted once: assigning work in the loop only turns reachable
986 // connections busy, and those were never in this set.
987 let waiting = (0..self.conns.len())
988 .filter(|&k| {
989 let c = &self.conns[k];
990 !c.busy()
991 && c.queued.is_none()
992 && (now < c.setup_end || now < self.sources[c.source].suspended_until)
993 })
994 .count();
995 let mut admitted = self.admitted();
996 for &j in &order {
997 if admitted >= self.active_limit {
998 break;
999 }
1000 if self.conns[j].busy() || now < self.conns[j].setup_end {
1001 continue;
1002 }
1003 let src = self.conns[j].source;
1004 if now < self.sources[src].suspended_until {
1005 continue;
1006 }
1007 // How much to hand this connection.
1008 //
1009 // `u64::MAX` — take everything — is right once concurrency has settled:
1010 // maximal ranges mean the fewest requests, which is the whole point of
1011 // range scheduling. It is wrong while more admissions are still
1012 // expected, because the first idle connection would swallow the
1013 // reserve that connections admitted later are supposed to pick up, and
1014 // they would be left to STEAL from it. That is a repair per admission,
1015 // and the repair undoes a split that had just been made for no reason.
1016 //
1017 // So while room remains, hand out a budget-sized share and leave the
1018 // rest. The cost of being wrong in this direction is one extra request
1019 // later — now nearly free on a pooled connection — against one repair
1020 // per admitted connection the other way.
1021 //
1022 // Reserve only for connections THIS LOOP CANNOT REACH. An idle
1023 // connection that is merely further down the visit order is not one
1024 // of them — it gets its work in this same pass, so holding a share
1025 // back for it just splits one request into two.
1026 //
1027 // Two things put a connection out of reach:
1028 //
1029 // * the ramp has not admitted it yet — `active_limit < ceiling`. This
1030 // is every `--adaptive` transfer, which starts at one connection
1031 // with the rest of the budget ahead of it. Without this clause the
1032 // reserve `initial_split` holds back is swallowed on the first tick
1033 // after that connection drains its quota, and every later admission
1034 // can only steal — see
1035 // `a_ramping_connection_that_drains_its_quota_does_not_swallow_the_reserve`.
1036 //
1037 // * it is `waiting`: idle, but held off by its own cooldown or a
1038 // suspended source, with a seat under the current limit still free.
1039 // This is the throttled case — the cap has just widened on a
1040 // successful probe, and the connections that will fill the new seats
1041 // are still cooling down from the refusal that taught the old one.
1042 // `+ 1` because `j` itself is not yet counted in `admitted`.
1043 //
1044 // Testing `admitted` against `active_limit` ALONE is wrong in a way no
1045 // existing test caught: `active_limit` is `usize::MAX` for every caller
1046 // that never opted into the ramp, so the comparison is vacuously true,
1047 // the share path swallows the fixed `-x N` case whole, and the maximal
1048 // branch below becomes unreachable. Hence `limit`, and hence
1049 // `settled_concurrency_hands_out_maximal_ranges_not_shares`.
1050 let ceiling = self.ceiling();
1051 let limit = self.active_limit.min(self.conns.len());
1052 let want = if self.active_limit < ceiling || (waiting > 0 && admitted + 1 < limit) {
1053 let remaining = self.unassigned.total();
1054 let share = remaining / ceiling as u64;
1055 share.max(STEAL_QUANTUM * 4)
1056 } else {
1057 u64::MAX
1058 };
1059 if let Some(r) = self.unassigned.take_front(want) {
1060 self.start(j, r, now);
1061 acts.push(Action::Request { conn: j, range: r });
1062 admitted += 1;
1063 continue;
1064 }
1065 // Nothing unassigned: steal from the worst laggard.
1066 //
1067 // This is the steal-half heuristic, and it fires on a DIFFERENT
1068 // trigger from the divergence repair above: not "the finishes have
1069 // diverged" but "a connection has gone idle and there is nothing left
1070 // to give it". Splitting the laggard's remainder down the middle is the
1071 // right move when the idle connection has capacity the laggard cannot
1072 // use. It is churn when they share one bottleneck — the same span is
1073 // re-requested, a setup is paid, and the aggregate rate is unchanged
1074 // because it was never the assignment that limited it.
1075 //
1076 // So the same profitability test applies. An idle connection is not a
1077 // reason to move work; it is a reason to ASK whether moving work helps.
1078 if let Some(vi) = self.worst_busy(j) {
1079 let vr = self.conns[vi].range.unwrap();
1080 let left = vr.hi.saturating_sub(self.conns[vi].pos);
1081 let half = left / 2;
1082 // Will the taker, paying one setup, actually finish this half
1083 // sooner than the victim would have finished the whole remainder?
1084 // With `rt` unknown (a connection that has just gone idle may have
1085 // no estimate yet) fall back to the victim's own rate, which makes
1086 // the test neutral rather than optimistic.
1087 let rv = self.conns[vi].rate_est;
1088 let rt = if self.conns[j].rate_est > 0.0 {
1089 self.conns[j].rate_est
1090 } else {
1091 rv
1092 };
1093 let delta = self.sources[self.conns[j].source].delta_est;
1094 let worth_it = if rv <= 0.0 {
1095 // The victim is delivering nothing measurable: anything is better.
1096 true
1097 } else if rt <= 0.0 {
1098 false
1099 } else {
1100 let before = left as f64 / rv;
1101 let after = (half as f64 / rv).max(delta + half as f64 / rt);
1102 before - after > delta
1103 };
1104 if half > STEAL_QUANTUM && worth_it {
1105 let new_hi = vr.hi - half;
1106 self.conns[vi].range = Some(Range::new(vr.lo, new_hi));
1107 let stolen = Range::new(new_hi, vr.hi);
1108 // Same shrink discipline as the divergence repair above: the
1109 // victim must be told its far end moved, or it streams the
1110 // half we just handed away.
1111 acts.push(Action::Shrink {
1112 conn: vi,
1113 hi: new_hi,
1114 });
1115 self.start(j, stolen, now);
1116 acts.push(Action::Request {
1117 conn: j,
1118 range: stolen,
1119 });
1120 admitted += 1;
1121 self.stats.repairs += 1;
1122 }
1123 }
1124 // NOTE: no hedging. Redundant requests waste bandwidth on non-erasure channels.
1125 }
1126
1127 self.stats.bytes_held = self.held;
1128 // Hand the scratch buffers back so their capacity survives to the next tick.
1129 // Without this the `mem::take` above would leave an empty Vec in the field and
1130 // the next tick would allocate again — the reuse would be nominal only.
1131 self.scratch_idx = stalled;
1132 self.scratch_order = order;
1133 acts
1134 }
1135
1136 fn start(&mut self, j: usize, r: Range, now: f64) {
1137 let delta = self.sources[self.conns[j].source].delta_est;
1138 let c = &mut self.conns[j];
1139 c.range = Some(r);
1140 c.pos = r.lo;
1141 c.started_at = now;
1142 c.setup_end = now + delta;
1143 c.last_progress = now + delta;
1144 c.stalled = false;
1145 self.stats.requests += 1;
1146 }
1147
1148 fn initial_split(&mut self, now: f64, acts: &mut Vec<Action>) {
1149 // Maximal ranges, proportional to rate estimate where known, else equal.
1150 //
1151 // Only the ACTIVE prefix takes part. With the ramp enabled the transfer
1152 // opens one connection, and the rest are admitted by `set_active_limit` as
1153 // the in-band search finds them worth their setup cost. Splitting the
1154 // object across connections that will not run would strand those bytes in
1155 // a quota nobody fetches.
1156 let n = self.conns.len().min(self.active_limit);
1157 if n == 0 || self.size == 0 {
1158 return;
1159 }
1160 let weights: Vec<f64> = self
1161 .conns
1162 .iter()
1163 .take(n)
1164 .map(|c| {
1165 let g = self.sources[c.source].gamma_est;
1166 if g > 0.0 {
1167 g
1168 } else {
1169 1.0
1170 }
1171 })
1172 .collect();
1173 let total: f64 = weights.iter().sum();
1174
1175 // Split what is ACTUALLY unassigned, not `[0, size)`.
1176 //
1177 // An earlier version partitioned the whole object arithmetically, which
1178 // silently ignored `mark_done`. That broke both features that depend on
1179 // it: `--range` fetched from offset 0 instead of the requested interval,
1180 // and `--continue` re-fetched bytes already on disk. The unassigned set is
1181 // the single source of truth for what remains, so the split must be taken
1182 // from it.
1183 let remaining: Vec<Range> = self.unassigned.ranges().to_vec();
1184 let avail: u64 = remaining.iter().map(|r| r.hi - r.lo).sum();
1185 if avail == 0 {
1186 return;
1187 }
1188 // Per-connection byte quotas, proportional to rate estimate.
1189 //
1190 // Divided over the FULL connection budget, not just the active prefix, and
1191 // this matters specifically when the ramp is running. With one connection
1192 // active, dividing by the active count alone hands that connection the
1193 // entire object — so a connection admitted later finds the unassigned set
1194 // empty and its only route to work is to STEAL, which pays a repair to
1195 // undo a split that should never have been made. Measured cost of getting
1196 // this wrong: every ramped transfer of a 3.15 MB object took ~21 s against
1197 // 6.3 s for fixed concurrency, and several were reported as failures
1198 // despite delivering byte-exact files.
1199 //
1200 // Quotas over the full budget leave the remainder UNASSIGNED, which is
1201 // exactly where a newly admitted connection takes work from through
1202 // ordinary work-conserving assignment — no repair, no steal, no duplicate
1203 // request. If the ramp never grows, nothing is lost: the active connection
1204 // finishes its quota and work-conserving assignment gives it the next
1205 // piece, which connection reuse now makes nearly free.
1206 let budget = self.ceiling();
1207 let mut quota: Vec<u64> = weights
1208 .iter()
1209 .map(|w| ((w / total) * (avail as f64 / budget as f64) * n as f64) as u64)
1210 .collect();
1211 // Rounding must not strand bytes — but only when every connection is
1212 // active. While ramping, the unclaimed remainder is deliberate.
1213 if n >= budget {
1214 let assigned: u64 = quota.iter().sum();
1215 if let Some(last) = quota.last_mut() {
1216 *last += avail.saturating_sub(assigned);
1217 }
1218 }
1219
1220 // Walk the unassigned ranges, carving each connection's quota out of them
1221 // in order. A connection may receive a range that is not contiguous with
1222 // its neighbours' — that is fine, since ranges are independent requests.
1223 let mut it = remaining.into_iter();
1224 let mut cur = it.next();
1225 for (j, want_total) in quota.iter().enumerate() {
1226 let mut want = *want_total;
1227 while want > 0 {
1228 let Some(seg) = cur else { break };
1229 let take = want.min(seg.hi - seg.lo);
1230 let r = Range::new(seg.lo, seg.lo + take);
1231 // A connection holds one active range plus a one-slot pipeline.
1232 // Anything beyond that stays UNASSIGNED rather than being stashed:
1233 // work-conserving assignment will hand it out as connections free
1234 // up, and leaving it in the set is what keeps the coverage
1235 // invariant checkable.
1236 if self.conns[j].range.is_none() {
1237 self.unassigned.remove(r.lo, r.hi);
1238 self.start(j, r, now);
1239 acts.push(Action::Request { conn: j, range: r });
1240 } else if self.conns[j].queued.is_none() {
1241 self.unassigned.remove(r.lo, r.hi);
1242 self.conns[j].queued = Some(r);
1243 } else {
1244 break;
1245 }
1246 want -= take;
1247 cur = if seg.hi - seg.lo > take {
1248 Some(Range::new(seg.lo + take, seg.hi))
1249 } else {
1250 it.next()
1251 };
1252 }
1253 }
1254 }
1255
1256 /// The current repair deadband, in seconds. Exposed for measurement.
1257 pub fn theta_now(&self, now: f64) -> f64 {
1258 self.theta(now)
1259 }
1260
1261 fn theta(&self, now: f64) -> f64 {
1262 // One fold, no allocation. This is called from the tick loop — 50 times a
1263 // second at the default 20 ms tick, for the whole transfer — and it collected
1264 // a `Vec<&Conn>` on every call only to take its length and sum one field.
1265 // Nothing here needs the intermediate collection.
1266 let (live_count, agg) = self
1267 .conns
1268 .iter()
1269 .filter(|c| now >= self.sources[c.source].suspended_until)
1270 .fold((0usize, 0.0f64), |(k, sum), c| {
1271 (k + 1, sum + c.rate_est.max(0.0))
1272 });
1273 let n = live_count.max(1) as f64;
1274 let agg = if agg > 0.0 { agg } else { 1.0 };
1275 let remaining = self.size.saturating_sub(self.held) as f64;
1276 let t_rem = remaining / agg;
1277 let delta = self
1278 .sources
1279 .iter()
1280 .map(|s| s.delta_est)
1281 .fold(0.0f64, f64::max);
1282 let band = self.theta_scale * (delta * t_rem.max(0.0) / n).sqrt();
1283
1284 // ---- floor the deadband at what a repair actually costs --------------
1285 //
1286 // `sqrt(delta * T_rem / n)` is the right SHAPE — it is the granularity
1287 // trade-off — but it is unbounded below, and it approaches zero from two
1288 // directions that both make repair a worse idea, not a better one:
1289 // `T_rem` shrinks as the transfer finishes, and `n` grows with
1290 // concurrency. So the deadband is narrowest exactly when a repair has the
1291 // least remaining time to earn its cost back and the most competitors to
1292 // pay it against.
1293 //
1294 // Measured on the shared-bottleneck harness (examples/storm.rs, 12 seeds):
1295 // theta reached 0.061-0.081 s against a delta of 0.12 s. Every repair
1296 // triggered in that regime spends one full setup to recover a divergence
1297 // smaller than the setup — a guaranteed loss, taken deliberately, dozens
1298 // of times per transfer.
1299 //
1300 // A repair cannot be worth making unless the divergence it corrects
1301 // exceeds what correcting it costs, so `delta` is the floor. This is not a
1302 // tuning constant: it is the break-even point, and it is measured per
1303 // source rather than guessed, so a high-RTT path widens it automatically.
1304 band.max(delta)
1305 }
1306
1307 fn pick_victim_taker(&self, now: f64) -> Option<(usize, usize)> {
1308 // Victim ranking is (health, ETA), health first. A connection the
1309 // detector has graded Suspect is a victim even when its *projected* ETA
1310 // still looks acceptable -- which is the whole point of detecting a
1311 // collapse early, since the ETA is computed from a rate estimate that
1312 // the collapse has not yet dragged down.
1313 let mut victim: Option<(usize, crate::detect::Health, f64)> = None;
1314 let mut taker: Option<(usize, f64)> = None;
1315 // A dormant connection — idle, and not already counted in `admitted` —
1316 // may only become a taker if the budget has room for it: as taker,
1317 // admitting one would open a socket the concurrency ramp, or a refusal
1318 // that has capped the transfer, has not justified — quietly defeating the
1319 // limit through the repair path. An already-busy connection spends no new
1320 // budget by taking on queued work, so it is never gated on room. Index
1321 // plays no part: the budget is a count (`admitted`), not a privilege
1322 // attached to low indices — see `admitted` for why that distinction is
1323 // the fix, not decoration.
1324 let room = self.admitted() < self.active_limit;
1325 for j in 0..self.conns.len() {
1326 let c = &self.conns[j];
1327 if now < c.setup_end || now < self.sources[c.source].suspended_until {
1328 continue;
1329 }
1330 let e = c.eta();
1331 let h = if self.health_ranking {
1332 c.detector.health()
1333 } else {
1334 crate::detect::Health::Healthy
1335 };
1336 // As victim a connection needs no room check: it already holds a
1337 // range, so it is not being newly admitted, wherever its index falls.
1338 if c.busy() && victim.map(|(_, vh, ve)| (h, e) > (vh, ve)).unwrap_or(true) {
1339 victim = Some((j, h, e));
1340 }
1341 // A degraded connection must never be chosen as the TAKER: handing
1342 // work to a collapsing connection is the failure mode this whole
1343 // mechanism exists to prevent.
1344 if !h.is_suspect_or_worse()
1345 && (c.busy() || room)
1346 && taker.map(|(_, te)| e < te).unwrap_or(true)
1347 {
1348 taker = Some((j, e));
1349 }
1350 }
1351 let (vi, _, _) = victim?;
1352 let (ti, _) = taker?;
1353 if vi == ti {
1354 return None;
1355 }
1356 Some((vi, ti))
1357 }
1358
1359 fn worst_busy(&self, exclude: usize) -> Option<usize> {
1360 let mut best: Option<(usize, u64)> = None;
1361 for j in 0..self.conns.len() {
1362 if j == exclude {
1363 continue;
1364 }
1365 let c = &self.conns[j];
1366 if !c.busy() {
1367 continue;
1368 }
1369 let left = c.range.unwrap().hi.saturating_sub(c.pos);
1370 if best.map(|(_, bl)| left > bl).unwrap_or(true) {
1371 best = Some((j, left));
1372 }
1373 }
1374 best.map(|(j, _)| j)
1375 }
1376}
1377
1378/// Greedy concurrency allocation across multiple sources.
1379pub fn greedy_concurrency(
1380 rho: &[f64],
1381 gamma: &[f64],
1382 access_cap: f64,
1383 budget: usize,
1384) -> Vec<usize> {
1385 let m = rho.len();
1386 let mut n = vec![0usize; m];
1387 let g = |n: &[usize]| -> f64 {
1388 let sum: f64 = (0..m).map(|i| rho[i].min(n[i] as f64 * gamma[i])).sum();
1389 sum.min(access_cap)
1390 };
1391 let mut cur = g(&n);
1392 for _ in 0..budget {
1393 let mut best = (0usize, 0.0f64);
1394 for i in 0..m {
1395 n[i] += 1;
1396 let gain = g(&n) - cur;
1397 n[i] -= 1;
1398 if gain > best.1 {
1399 best = (i, gain);
1400 }
1401 }
1402 if best.1 <= 0.0 {
1403 break; // saturated: further connections are pure cost
1404 }
1405 n[best.0] += 1;
1406 cur += best.1;
1407 }
1408 n
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413 use super::*;
1414
1415 fn src(gamma: f64) -> Source {
1416 Source {
1417 gamma_est: gamma,
1418 delta_est: 0.05,
1419 ..Default::default()
1420 }
1421 }
1422
1423 /// An arrival from a request that has already been superseded must not be
1424 /// credited against the request that replaced it.
1425 ///
1426 /// The bytes are real and on disk, so crediting them looks harmless — but it
1427 /// advances the cursor past where the NEW request starts reading, and every
1428 /// arrival from that request then fails the `off == pos` test and is
1429 /// discarded. The connection delivers bytes the scheduler never counts, so it
1430 /// reads as silent and is only rescued by the stall timeout, seconds later.
1431 /// That is the same dead air the transport's error handling exists to remove,
1432 /// reintroduced through the arrival path.
1433 #[test]
1434 fn a_late_arrival_from_a_superseded_request_is_not_credited() {
1435 let mut s = Scheduler::new(1000, vec![src(1.0)], &[1]);
1436 s.tick(0.0);
1437 assert_eq!(s.conn_range(0), Some((0, 0, 1000)));
1438 // 100 bytes land and are credited.
1439 s.on_bytes_at(0, 0, 100, 1.0, 0.5);
1440 assert_eq!(s.bytes_held(), 100);
1441 // The connection is reclaimed and re-requested from where it got to.
1442 s.on_conn_error(0, 9.0, 0.0);
1443 let acts = s.tick(10.0);
1444 assert!(
1445 matches!(acts.as_slice(), [Action::Request { conn: 0, range }] if range.lo == 100),
1446 "the reclaimed remainder must be re-requested from 100: {acts:?}"
1447 );
1448 // Now the aborted request's last write arrives, timestamped BEFORE the new
1449 // request was issued.
1450 s.on_bytes_at(0, 100, 50, 9.5, 0.1);
1451 assert_eq!(
1452 s.bytes_held(),
1453 100,
1454 "an arrival older than the request in flight was credited to it"
1455 );
1456 // And the new request's own first arrival, at the same offset, must land.
1457 s.on_bytes_at(0, 100, 50, 10.2, 0.1);
1458 assert_eq!(
1459 s.bytes_held(),
1460 150,
1461 "the live request's arrival was discarded as stale"
1462 );
1463 }
1464
1465 #[test]
1466 fn initial_split_covers_exactly() {
1467 let mut s = Scheduler::new(1000, vec![src(1.0), src(1.0)], &[1, 1]);
1468 let acts = s.tick(0.0);
1469 assert_eq!(acts.len(), 2);
1470 assert!(s.coverage_holds());
1471 assert!(s.unassigned.is_empty());
1472 }
1473
1474 #[test]
1475 fn coverage_and_liveness_hold_through_a_transfer() {
1476 let mut s = Scheduler::new(1_000_000, vec![src(1e5), src(5e4)], &[2, 2]);
1477 let mut now = 0.0;
1478 for _ in 0..4000 {
1479 s.tick(now);
1480 for j in 0..s.n_conns() {
1481 s.on_bytes(j, 500, now, 0.01);
1482 }
1483 assert!(s.coverage_holds(), "coverage broke at t={now}");
1484 assert!(s.liveness_holds(), "stuck at t={now}");
1485 now += 0.01;
1486 if s.is_complete() {
1487 break;
1488 }
1489 }
1490 assert!(
1491 s.is_complete(),
1492 "did not finish: {} / {}",
1493 s.bytes_held(),
1494 1_000_000
1495 );
1496 }
1497
1498 #[test]
1499 fn fully_stolen_range_does_not_livelock() {
1500 // Regression: a connection whose active range is stolen down to its
1501 // current position goes idle WITHOUT completing. If the queue-start
1502 // path is missing, its queued bytes are never requested.
1503 let mut s = Scheduler::new(200_000, vec![src(1e5), src(1e5)], &[1, 1]);
1504 s.tick(0.0);
1505 // conn 0 makes progress, conn 1 stalls entirely
1506 let mut now = 0.06;
1507 for _ in 0..50 {
1508 s.on_bytes(0, 1000, now, 0.01);
1509 now += 0.01;
1510 s.tick(now);
1511 }
1512 // force a steal by making conn 1 look terrible, then run to completion
1513 for _ in 0..20000 {
1514 s.tick(now);
1515 s.on_bytes(0, 1000, now, 0.01);
1516 now += 0.01;
1517 assert!(s.liveness_holds(), "livelocked at t={now}");
1518 if s.is_complete() {
1519 break;
1520 }
1521 }
1522 assert!(s.is_complete());
1523 }
1524
1525 #[test]
1526 fn stall_reclaim_returns_bytes() {
1527 let mut s = Scheduler::new(100_000, vec![src(1e5), src(1e5)], &[1, 1]);
1528 s.tick(0.0);
1529 let before = s.stats.reclaims;
1530 // no bytes at all: both connections must be reclaimed after the timeout
1531 let acts = s.tick(5.0);
1532 assert!(s.stats.reclaims > before);
1533 assert!(acts.iter().any(|a| matches!(a, Action::Cancel { .. })));
1534 assert!(s.coverage_holds());
1535 assert!(s.liveness_holds());
1536 }
1537
1538 #[test]
1539 fn suspend_source_reclaims_and_reassigns() {
1540 let mut s = Scheduler::new(100_000, vec![src(1e5), src(1e5)], &[1, 1]);
1541 s.tick(0.0);
1542 s.suspend_source(0, 10.0);
1543 // Reclaimed bytes are now unassigned. They are NOT reassigned instantly:
1544 // the surviving connection is still streaming its own range, and taking
1545 // work from it would violate nothing but achieve nothing either. Work
1546 // conservation only requires that no connection sit IDLE while work
1547 // remains -- so the reassignment happens when conn 1 next goes idle.
1548 assert!(s.coverage_holds());
1549 assert!(s.unassigned.total() > 0);
1550
1551 let mut now = 0.2;
1552 let mut served_by_1 = false;
1553 for _ in 0..20_000 {
1554 let acts = s.tick(now);
1555 if acts
1556 .iter()
1557 .any(|a| matches!(a, Action::Request { conn, .. } if s.conns[*conn].source == 1))
1558 {
1559 served_by_1 = true;
1560 }
1561 s.on_bytes(1, 1000, now, 0.01);
1562 now += 0.01;
1563 assert!(s.coverage_holds());
1564 assert!(s.liveness_holds());
1565 if s.is_complete() {
1566 break;
1567 }
1568 }
1569 assert!(
1570 served_by_1,
1571 "surviving source never picked up the reclaimed work"
1572 );
1573 assert!(s.is_complete(), "held {} of 100000", s.bytes_held());
1574 }
1575
1576 #[test]
1577 fn greedy_matches_exhaustive_small() {
1578 // rho/gamma chosen so the optimum is interior
1579 let rho = [2.2e6, 1.1e6, 0.7e6];
1580 let gam = [0.55e6, 0.45e6, 0.35e6];
1581 let cap = 5.0e6;
1582 for budget in 1..10usize {
1583 let n = greedy_concurrency(&rho, &gam, cap, budget);
1584 let g = |n: &[usize]| -> f64 {
1585 let s: f64 = (0..3).map(|i| rho[i].min(n[i] as f64 * gam[i])).sum();
1586 s.min(cap)
1587 };
1588 let mut best = 0.0f64;
1589 for a in 0..=budget {
1590 for b in 0..=budget {
1591 for c in 0..=budget {
1592 if a + b + c <= budget {
1593 best = best.max(g(&[a, b, c]));
1594 }
1595 }
1596 }
1597 }
1598 assert!(
1599 (g(&n) - best).abs() < 1.0,
1600 "budget {budget}: greedy {} vs {}",
1601 g(&n),
1602 best
1603 );
1604 }
1605 }
1606
1607 #[test]
1608 fn saturation_stops_allocation() {
1609 // one source, rho = 2*gamma: two connections saturate it
1610 let n = greedy_concurrency(&[2.0e6], &[1.0e6], 1e9, 10);
1611 assert_eq!(
1612 n[0], 2,
1613 "allocated {n:?}, expected exactly the saturation point"
1614 );
1615 }
1616 /// The detector must make the SCHEDULER act sooner, not merely grade sooner.
1617 ///
1618 /// A connection collapsing to 3% of its rate must be chosen as a repair
1619 /// victim well before the stall timeout would have reclaimed it. Without
1620 /// health-ranked victim selection the scheduler waits for the projected ETA
1621 /// to drift, which is the fixed detection cost measured at 0.25-0.9 s.
1622 #[test]
1623 fn collapsed_connection_becomes_a_repair_victim_before_the_stall_timeout() {
1624 const S: u64 = 40_000_000;
1625 let mut sc = Scheduler::new(S, vec![src(4e6), src(4e6)], &[1, 1]).with_stall_timeout(10.0);
1626 sc.tick(0.0);
1627 let mut now = 0.0;
1628 // Both healthy for a while.
1629 for _ in 0..12 {
1630 now += 0.1;
1631 sc.on_bytes(0, 400_000, now, 0.1);
1632 sc.on_bytes(1, 400_000, now, 0.1);
1633 sc.tick(now);
1634 }
1635 assert_eq!(sc.conn_health(0), crate::detect::Health::Healthy);
1636
1637 // Connection 0 collapses; connection 1 keeps its rate.
1638 let mut flagged_at = None;
1639 for _ in 0..8 {
1640 now += 0.1;
1641 sc.on_bytes(0, 12_000, now, 0.1);
1642 sc.on_bytes(1, 400_000, now, 0.1);
1643 sc.tick(now);
1644 if flagged_at.is_none() && sc.conn_health(0).is_suspect_or_worse() {
1645 flagged_at = Some(now);
1646 }
1647 }
1648 let t = flagged_at.expect("collapse must be graded");
1649 assert!(
1650 t < 1.2 + 10.0,
1651 "must be flagged well before the 10 s stall timeout, was {t}"
1652 );
1653 // And the healthy connection must never be the one downgraded.
1654 assert_eq!(
1655 sc.conn_health(1),
1656 crate::detect::Health::Healthy,
1657 "the connection holding its rate must stay Healthy"
1658 );
1659 assert!(sc.coverage_holds() && sc.liveness_holds());
1660 }
1661 /// The repair deadband must never fall below what a repair costs.
1662 ///
1663 /// `theta = scale*sqrt(delta*T_rem/n)` has the right shape but is unbounded
1664 /// below, and it approaches zero from two directions that both make repair a
1665 /// worse idea: `T_rem` shrinks as the transfer ends, `n` grows with
1666 /// concurrency. Measured on the shared-bottleneck harness, theta reached
1667 /// 0.061-0.081 s against a delta of 0.12 s — so the scheduler was spending a
1668 /// 0.12 s setup to recover a 0.06 s divergence, dozens of times per transfer.
1669 #[test]
1670 fn the_repair_deadband_never_drops_below_one_setup_cost() {
1671 const S: u64 = 8_000_000;
1672 const D: f64 = 0.12;
1673 let mk = |n: usize| {
1674 let sources = vec![Source {
1675 gamma_est: 1.4e6 / n as f64,
1676 delta_est: D,
1677 ..Default::default()
1678 }];
1679 Scheduler::new(S, sources, &[n])
1680 };
1681 // Sweep concurrency and progress: both drive theta down.
1682 for &n in &[1usize, 2, 4, 8, 16, 64] {
1683 let mut sc = mk(n);
1684 sc.tick(0.0);
1685 let mut now = 0.0;
1686 // Deliver most of the object, so T_rem — and with it the unfloored
1687 // band — becomes small.
1688 for _ in 0..60 {
1689 now += 0.05;
1690 for j in 0..n {
1691 if sc.conn_range(j).is_some() {
1692 sc.on_bytes(j, 100_000 / n as u64, now, 0.05);
1693 }
1694 }
1695 sc.tick(now);
1696 let th = sc.theta_now(now);
1697 assert!(
1698 th >= D - 1e-12,
1699 "theta {th} fell below delta {D} at n={n}, progress {}/{S}: \
1700 the scheduler would pay a full setup to recover a smaller divergence",
1701 sc.bytes_held()
1702 );
1703 }
1704 }
1705 }
1706
1707 /// A stable unequal split settles after ONE equalisation; a collapse still
1708 /// gets answered.
1709 ///
1710 /// These two assertions are one test on purpose. Suppressing spurious repair is
1711 /// trivial in isolation — never repair — and that would be a regression, not a
1712 /// fix: the mechanism exists for the mirror that dies mid-transfer. The
1713 /// property worth pinning is the DISCRIMINATION between the two cases.
1714 ///
1715 /// # What this test does NOT cover
1716 ///
1717 /// It does not reproduce the repair storm, and no test in this crate can. The
1718 /// storm was a feedback loop between the scheduler and the transport: a repair
1719 /// shrank the victim's range, the victim's socket kept streaming the span
1720 /// anyway, the duplicate traffic slowed the honest connections, and that
1721 /// slowdown re-diverged the finish times into another repair. The core cannot
1722 /// see any of that — it has no sockets — so it cannot close the loop. Feeding
1723 /// it a stable unequal split, as here, correctly produces exactly one repair
1724 /// (equalising a persistent 60/40 asymmetry IS profitable) and then stops.
1725 ///
1726 /// The loop itself is tested where it lives, against a served-byte count at the
1727 /// origin: `hydra-net/tests/shrink_e2e.rs`.
1728 #[test]
1729 fn a_stable_unequal_split_settles_and_a_collapse_is_still_answered() {
1730 const S: u64 = 40_000_000;
1731 let src4 = || Source {
1732 gamma_est: 2e6,
1733 delta_est: 0.12,
1734 ..Default::default()
1735 };
1736
1737 // --- stationary: two connections at persistently unequal but stable shares.
1738 // This is what flows sharing one bottleneck look like (share ~ 1/RTT), and
1739 // no repair can change it — the asymmetry is a property of the path.
1740 let mut sc = Scheduler::new(S, vec![src4(), src4()], &[1, 1]);
1741 sc.tick(0.0);
1742 let mut now = 0.0;
1743 for k in 0..60 {
1744 now += 0.1;
1745 // 60/40 split, with a little jitter, conserving the aggregate.
1746 let wobble = if k % 3 == 0 { 12_000 } else { -8_000 };
1747 sc.on_bytes(0, (240_000i64 + wobble) as u64, now, 0.1);
1748 sc.on_bytes(1, (160_000i64 - wobble) as u64, now, 0.1);
1749 sc.tick(now);
1750 }
1751 // One equalisation is correct here and the scheduler must then SETTLE: the
1752 // 60/40 share ratio is a property of the path, so re-equalising cannot
1753 // improve it and every further repair is a pure setup cost. 60 ticks over
1754 // 6 s of simulated transfer would be ample room for a storm.
1755 let stationary_repairs = sc.stats.repairs;
1756 assert!(
1757 stationary_repairs <= 1,
1758 "a stable unequal split provoked {stationary_repairs} repairs over 60 \
1759 ticks; one equalisation is profitable, repeated ones only pay setups"
1760 );
1761
1762 // --- collapse: connection 0 drops to 2% and stays there.
1763 let mut sc = Scheduler::new(S, vec![src4(), src4()], &[1, 1]);
1764 sc.tick(0.0);
1765 let mut now = 0.0;
1766 for _ in 0..20 {
1767 now += 0.1;
1768 sc.on_bytes(0, 200_000, now, 0.1);
1769 sc.on_bytes(1, 200_000, now, 0.1);
1770 sc.tick(now);
1771 }
1772 let before = sc.stats.repairs;
1773 for _ in 0..40 {
1774 now += 0.1;
1775 sc.on_bytes(0, 4_000, now, 0.1);
1776 sc.on_bytes(1, 200_000, now, 0.1);
1777 sc.tick(now);
1778 }
1779 assert!(
1780 sc.stats.repairs > before,
1781 "a connection collapsing to 2% of its rate produced no repair: the \
1782 profitability test is suppressing the case repair exists for"
1783 );
1784 assert!(sc.coverage_holds() && sc.liveness_holds());
1785 }
1786
1787 /// A sole source must never be suspended past a caller's patience.
1788 ///
1789 /// Exponential backoff is right when work can go somewhere else. With one source
1790 /// it is a self-inflicted outage: nothing can move until the suspension expires,
1791 /// and a transport whose watchdog fails on silence cannot distinguish that from
1792 /// the source being gone.
1793 ///
1794 /// The numbers that made this real: `stall_timeout` 4.0s gives the transport a
1795 /// no-progress deadline of `4 * (4.0 + delta)` = 16.2s, while five consecutive
1796 /// stalls suspended the sole source for `min(4.0 * 2^3, 30)` = 30s. Measured on a
1797 /// 121.7 MiB GitHub release asset, 4 of 8 multi-connection runs aborted with a
1798 /// digest mismatch — three holding 126.9-127.0 MB of 127.6 MB, killed during a
1799 /// deliberate backoff over the final half-megabyte.
1800 #[test]
1801 fn a_sole_source_is_never_suspended_longer_than_its_stall_timeout() {
1802 const S: u64 = 8_000_000;
1803 let st = 4.0;
1804 let mut sc = Scheduler::new(S, vec![src(4e6)], &[4]).with_stall_timeout(st);
1805 sc.tick(0.0);
1806
1807 // Drive it through many consecutive stalls, which is what escalates backoff.
1808 let mut now = 0.0;
1809 let mut worst_suspension = 0.0f64;
1810 for _ in 0..12 {
1811 now += st * 1.5;
1812 sc.tick(now);
1813 if let Some(until) = sc.all_sources_suspended_until(now) {
1814 worst_suspension = worst_suspension.max(until - now);
1815 }
1816 }
1817 assert!(
1818 worst_suspension <= st.max(1.0) + 1e-9,
1819 "sole source suspended for {worst_suspension:.1}s against a {st:.1}s stall \
1820 timeout: a caller's no-progress watchdog will kill the transfer during a \
1821 pause the scheduler chose"
1822 );
1823 }
1824
1825 /// Ramping concurrency must find work WAITING, not have to steal it.
1826 ///
1827 /// With the ramp enabled the transfer starts with one connection active. If the
1828 /// initial split gives that connection the whole object, every connection
1829 /// admitted afterwards finds the unassigned set empty and its only route to
1830 /// work is a steal — paying a repair to undo a split that should not have been
1831 /// made. Measured cost of that mistake on a live 3.15 MB transfer: ~21 s
1832 /// against 6.3 s for fixed concurrency, with several runs reported as failures
1833 /// despite delivering byte-exact files.
1834 ///
1835 /// The invariant: while the active limit is below the connection budget, some
1836 /// work stays unassigned, and raising the limit produces `Request` actions
1837 /// rather than repairs.
1838 #[test]
1839 fn a_ramping_transfer_finds_unassigned_work_instead_of_stealing() {
1840 const S: u64 = 40_000_000;
1841 let sources = vec![Source {
1842 gamma_est: 2e6,
1843 delta_est: 0.05,
1844 ..Default::default()
1845 }];
1846 let mut sc = Scheduler::new(S, sources, &[8]).with_active_limit(1);
1847 let acts = sc.tick(0.0);
1848 assert_eq!(
1849 acts.iter()
1850 .filter(|a| matches!(a, Action::Request { .. }))
1851 .count(),
1852 1,
1853 "only the active connection may be given work"
1854 );
1855 assert!(
1856 !sc.unassigned_is_empty(),
1857 "the whole object was handed to one connection: connections admitted \
1858 later can only steal, which costs a repair each"
1859 );
1860
1861 // Deliver some bytes, then admit more connections as the ramp would.
1862 let mut now = 0.0;
1863 for _ in 0..5 {
1864 now += 0.1;
1865 sc.on_bytes(0, 200_000, now, 0.1);
1866 sc.tick(now);
1867 }
1868 let repairs_before = sc.stats.repairs;
1869 sc.set_active_limit(4);
1870 now += 0.1;
1871 let acts = sc.tick(now);
1872 let reqs = acts
1873 .iter()
1874 .filter(|a| matches!(a, Action::Request { .. }))
1875 .count();
1876 assert!(
1877 reqs >= 3,
1878 "admitting 3 connections produced {reqs} requests: they are not being \
1879 given the reserved work"
1880 );
1881 assert_eq!(
1882 sc.stats.repairs, repairs_before,
1883 "admitting a connection must not cost a repair"
1884 );
1885 assert!(sc.coverage_holds() && sc.liveness_holds());
1886 }
1887
1888 /// The reserve must survive the ramping connection RUNNING OUT OF WORK.
1889 ///
1890 /// [`a_ramping_transfer_finds_unassigned_work_instead_of_stealing`] pins the
1891 /// reserve as `initial_split` leaves it, and never lets the one active
1892 /// connection finish what it was given. That is the easy half. The half that
1893 /// actually decides whether a ramped transfer is fast is what work-conserving
1894 /// assignment hands out on the tick AFTER the active connection drains its
1895 /// quota — which, with the ramp still at one connection, is the common case on
1896 /// any object larger than a few of those quotas.
1897 ///
1898 /// Getting it wrong looks exactly like never having reserved at all: the idle
1899 /// connection takes `u64::MAX`, the reserve `initial_split` carefully held back
1900 /// is swallowed whole, and every connection the ramp admits afterwards finds
1901 /// nothing unassigned and must steal — the ~21 s against 6.3 s regression the
1902 /// sibling test above quotes, arrived at one tick later.
1903 ///
1904 /// The invariant, stated where it belongs: while the ramp can still grow, no
1905 /// single assignment may consume the reserve, whoever asks and whenever.
1906 #[test]
1907 fn a_ramping_connection_that_drains_its_quota_does_not_swallow_the_reserve() {
1908 const S: u64 = 40_000_000;
1909 let sources = vec![Source {
1910 gamma_est: 2e6,
1911 delta_est: 0.05,
1912 ..Default::default()
1913 }];
1914 let mut sc = Scheduler::new(S, sources, &[8]).with_active_limit(1);
1915 sc.tick(0.0);
1916 let (lo, _, hi) = sc
1917 .conn_range(0)
1918 .expect("the active connection holds a range");
1919 let quota = hi - lo;
1920 assert!(
1921 quota < S,
1922 "initial_split handed the whole object to one connection"
1923 );
1924
1925 // Drain that quota completely, so the connection goes idle with the ramp
1926 // still at one and the reserve still untouched.
1927 let mut now = 0.0;
1928 now += 0.1;
1929 sc.on_bytes(0, quota, now, 0.1);
1930 assert!(
1931 sc.conn_range(0).is_none(),
1932 "the connection should have finished its range"
1933 );
1934
1935 // The tick that re-assigns it. This is the one under test.
1936 now += 0.1;
1937 sc.tick(now);
1938 assert!(
1939 !sc.unassigned_is_empty(),
1940 "the idle connection took the entire remainder while the ramp was still \
1941 at one: every connection admitted later can only steal, which costs a \
1942 repair each"
1943 );
1944
1945 // And the reserve must still be big enough to matter — not a token sliver
1946 // left by a rounding accident.
1947 let held_back = sc.unassigned_total();
1948 assert!(
1949 held_back > (S - quota) / 2,
1950 "only {held_back} of {} remaining bytes stayed unassigned: the reserve \
1951 is nominal, and connections admitted later will still have to steal",
1952 S - quota
1953 );
1954 assert!(sc.coverage_holds() && sc.liveness_holds());
1955 }
1956
1957 /// Settled concurrency must hand out MAXIMAL ranges, not shares.
1958 ///
1959 /// The reserve exists for connections that are still to be admitted. A caller
1960 /// that never opted into the ramp has none: `active_limit` is left at
1961 /// `usize::MAX` ("every connection active"), nothing is waiting in the wings,
1962 /// and holding work back only guarantees the connection that was given a
1963 /// share has to come back for the rest — a request per share, where range
1964 /// scheduling exists to make it one.
1965 ///
1966 /// This pins the sentinel specifically. Any test of the reserve condition
1967 /// written as `admitted < active_limit` is comparing against `usize::MAX` for
1968 /// this caller, is therefore always true, and silently turns every fixed
1969 /// `-x N` transfer into the share path with no test noticing — the maximal
1970 /// branch becomes unreachable outside the ramp.
1971 #[test]
1972 fn settled_concurrency_hands_out_maximal_ranges_not_shares() {
1973 const S: u64 = 40_000_000;
1974 let sources = vec![Source {
1975 gamma_est: 2e6,
1976 delta_est: 0.05,
1977 ..Default::default()
1978 }];
1979 // No `with_active_limit`: the default, fixed-concurrency caller.
1980 let mut sc = Scheduler::new(S, sources, &[4]);
1981 sc.tick(0.0);
1982 assert_eq!(
1983 sc.active_limit(),
1984 usize::MAX,
1985 "this test is about the usize::MAX sentinel; it is not being used"
1986 );
1987
1988 // Hand two connections' ranges back, so there is unassigned work and two
1989 // idle connections to give it to. Their ranges are adjacent, so they
1990 // coalesce into one block.
1991 sc.on_conn_error(2, 0.0, 0.0);
1992 sc.on_conn_error(3, 0.0, 0.0);
1993 let reserve = sc.unassigned_total();
1994 assert!(reserve > 0, "nothing was handed back");
1995
1996 let acts = sc.tick(0.1);
1997 let biggest = acts
1998 .iter()
1999 .filter_map(|a| match a {
2000 Action::Request { range, .. } => Some(range.hi - range.lo),
2001 _ => None,
2002 })
2003 .max()
2004 .expect("an idle connection must be given the returned work");
2005 assert_eq!(
2006 biggest, reserve,
2007 "the idle connection was handed {biggest} of {reserve} available bytes: \
2008 concurrency has settled and nothing is waiting to be admitted, so \
2009 holding a reserve back only buys a second request for the same work"
2010 );
2011 assert!(sc.coverage_holds() && sc.liveness_holds());
2012 }
2013
2014 /// Every range shrink must be ANNOUNCED, not just performed.
2015 ///
2016 /// Regression test for the repair storm. The scheduler used to move
2017 /// `conns[victim].range` and emit nothing, so the transport's fetch loop —
2018 /// which tests `off < hi` against the bound it captured at request time —
2019 /// went on pulling the span that had just been handed to another connection.
2020 /// Both connections then fetched the same bytes over the same bottleneck, the
2021 /// resulting slowdown read as fresh divergence, and that triggered further
2022 /// repairs: measured at 32-49 repairs on a stationary 5.3 MB transfer whose
2023 /// correct repair count is zero, for ~2.2x the fluid optimum.
2024 ///
2025 /// The invariant is therefore stronger than "a repair happened": for every
2026 /// repair counted, the victim whose far end moved must appear in a `Shrink`
2027 /// carrying the new bound. A caller cannot honour what it is not told.
2028 #[test]
2029 fn every_repair_announces_the_victims_new_far_end() {
2030 const S: u64 = 40_000_000;
2031 let mut sc = Scheduler::new(S, vec![src(4e6), src(4e6)], &[1, 1]).with_stall_timeout(10.0);
2032 sc.tick(0.0);
2033 let mut now = 0.0;
2034 for _ in 0..12 {
2035 now += 0.1;
2036 sc.on_bytes(0, 400_000, now, 0.1);
2037 sc.on_bytes(1, 400_000, now, 0.1);
2038 sc.tick(now);
2039 }
2040
2041 // Collapse connection 0 so a divergence repair becomes correct to make.
2042 let mut shrinks: Vec<(usize, u64)> = Vec::new();
2043 let mut repairs_before = sc.stats.repairs;
2044 let mut saw_repair = false;
2045 for _ in 0..25 {
2046 now += 0.1;
2047 sc.on_bytes(0, 4_000, now, 0.1);
2048 sc.on_bytes(1, 400_000, now, 0.1);
2049 // Snapshot each victim's far end before the tick that may move it.
2050 let before: Vec<Option<u64>> = (0..sc.n_conns())
2051 .map(|j| sc.conn_range(j).map(|(_, _, hi)| hi))
2052 .collect();
2053 let acts = sc.tick(now);
2054 for a in &acts {
2055 if let Action::Shrink { conn, hi } = a {
2056 shrinks.push((*conn, *hi));
2057 // The announced bound must be the one actually installed, and
2058 // it must be a genuine reduction — never a raise, which would
2059 // hand out bytes another connection may already hold.
2060 assert_eq!(
2061 sc.conn_range(*conn).map(|(_, _, h)| h),
2062 Some(*hi),
2063 "announced bound must match the installed one"
2064 );
2065 if let Some(Some(b)) = before.get(*conn) {
2066 assert!(*hi <= *b, "a shrink must lower the far end: {b} -> {hi}");
2067 }
2068 }
2069 }
2070 if sc.stats.repairs > repairs_before {
2071 saw_repair = true;
2072 assert!(
2073 !shrinks.is_empty(),
2074 "a repair was counted with no Shrink announced: the victim's \
2075 socket would keep streaming the stolen span"
2076 );
2077 repairs_before = sc.stats.repairs;
2078 }
2079 }
2080 assert!(saw_repair, "the scenario must produce at least one repair");
2081 assert!(sc.coverage_holds() && sc.liveness_holds());
2082 }
2083
2084 /// The initial split must respect `mark_done`.
2085 ///
2086 /// Regression test: an earlier version partitioned `[0, size)` arithmetically
2087 /// and never consulted the unassigned set, so `mark_done` was silently
2088 /// ignored. That broke `--range` (fetched from offset 0 instead of the
2089 /// requested interval) and `--continue` (re-fetched bytes already on disk).
2090 #[test]
2091 fn initial_split_never_requests_bytes_marked_done() {
2092 let size = 100_000u64;
2093 let mut s = Scheduler::new(size, vec![src(1e6), src(1e6)], &[1, 1]);
2094 // Range mode: only [90_000, 90_512) is wanted.
2095 s.mark_done(0, 90_000);
2096 s.mark_done(90_512, size);
2097 let acts = s.tick(0.0);
2098 assert!(
2099 !acts.is_empty(),
2100 "the wanted interval must still be requested"
2101 );
2102 for a in &acts {
2103 if let Action::Request { range, .. } = a {
2104 assert!(
2105 range.lo >= 90_000 && range.hi <= 90_512,
2106 "requested {range:?} outside the wanted interval"
2107 );
2108 }
2109 }
2110 assert!(s.coverage_holds());
2111 }
2112
2113 /// Overlapping `mark_done` calls must not inflate the held count.
2114 ///
2115 /// Regression test for a silent truncation. `mark_done` credited the width of
2116 /// the span it was given rather than the bytes it actually claimed, so two
2117 /// callers marking the same prefix — a `-c` resume replaying its sidecar, and
2118 /// the concurrency probe reporting the bytes it fetched, both of which start
2119 /// at offset 0 — pushed `held` past the object's real length. `is_complete()`
2120 /// tests exactly that counter, so the transfer stopped believing it was
2121 /// finished and left a zero-filled hole in the tail of a file it reported as
2122 /// a success: measured at 240 138 unwritten bytes on an 11 200 900-byte
2123 /// object whose gzip then refused to decompress.
2124 #[test]
2125 fn overlapping_mark_done_credits_each_byte_once() {
2126 let size = 100_000u64;
2127 let mut s = Scheduler::new(size, vec![src(1e6)], &[1]);
2128 s.mark_done(0, 30_000); // a resume record
2129 s.mark_done(0, 10_000); // the probe, re-reporting part of the same prefix
2130 assert_eq!(
2131 s.bytes_held(),
2132 30_000,
2133 "the overlap must be credited once, not twice"
2134 );
2135 assert!(!s.is_complete(), "70 000 bytes are still missing");
2136
2137 // Marking every byte, in overlapping pieces, is completion — and exactly
2138 // completion, never more.
2139 s.mark_done(20_000, size);
2140 s.mark_done(0, size);
2141 assert_eq!(s.bytes_held(), size);
2142 assert!(s.is_complete());
2143 }
2144
2145 /// After the probe's ranges are marked, `held_ranges` must describe them.
2146 ///
2147 /// This is what the pre-transfer checkpoint writes into the sidecar, so that a
2148 /// ^C during or shortly after the concurrency probe does not discard bytes the
2149 /// probe already fetched at true offsets. The periodic checkpoint inside the
2150 /// transfer only fires after 2 seconds, which an early interrupt beats.
2151 #[test]
2152 fn held_ranges_reports_probe_bytes_before_any_transfer() {
2153 let size = 11_200_900u64;
2154 let mut s = Scheduler::new(size, vec![Source::default()], &[1]);
2155 // Nothing fetched yet: nothing to checkpoint, and an empty record must not
2156 // be written as though it were progress.
2157 assert!(s.held_ranges().is_empty());
2158
2159 // The probe fetched a 3 MiB prefix into the real output.
2160 s.mark_done(0, 3 << 20);
2161 assert_eq!(s.held_ranges(), vec![(0, 3 << 20)]);
2162 assert_eq!(s.bytes_held(), 3 << 20);
2163
2164 // A second, disjoint probe range is reported as its own span rather than
2165 // merged into a count: a byte count cannot describe a hole, which is why
2166 // the sidecar stores ranges.
2167 s.mark_done(5 << 20, 6 << 20);
2168 assert_eq!(s.held_ranges(), vec![(0, 3 << 20), (5 << 20, 6 << 20)]);
2169
2170 // Adjacent spans DO coalesce, so the record stays compact across a long run.
2171 s.mark_done(3 << 20, 5 << 20);
2172 assert_eq!(s.held_ranges(), vec![(0, 6 << 20)]);
2173 }
2174
2175 /// Resume: bytes already on disk must never be re-requested.
2176 #[test]
2177 fn resume_does_not_refetch_held_prefix() {
2178 let size = 64_000u64;
2179 let mut s = Scheduler::new(size, vec![src(1e6)], &[2]);
2180 s.mark_done(0, 48_000); // three quarters already fetched
2181 let acts = s.tick(0.0);
2182 for a in &acts {
2183 if let Action::Request { range, .. } = a {
2184 assert!(
2185 range.lo >= 48_000,
2186 "re-requested a held byte at {}",
2187 range.lo
2188 );
2189 }
2190 }
2191 assert_eq!(
2192 s.bytes_held(),
2193 48_000,
2194 "held count must include the resumed prefix"
2195 );
2196 assert!(s.coverage_holds());
2197 }
2198}