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