Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/hydra-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "hya-core"
version = "0.3.2"
version = "0.3.3"
edition.workspace = true
license.workspace = true
repository.workspace = true
Expand Down
129 changes: 106 additions & 23 deletions crates/hydra-core/src/sched.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,27 @@ impl Scheduler {
self.conns.iter().filter(|c| c.busy()).count()
}

/// Connections that count against `active_limit` right now: busy, or already
/// holding queued work one tick from starting.
///
/// This is what "dormant" is measured against, not connection index. The
/// budget is a COUNT of connections in play, not a privilege attached to
/// low indices — a connection above `active_limit` that is still busy is
/// not "excess", it is simply already spending the budget it was granted
/// when it was admitted, and one at any index is free to spend it once
/// something else stops.
///
/// Queued connections are counted for the same reason `on_bytes` and
/// divergence repair must not both admit into the same headroom in one
/// tick: a connection with `queued` set has already been promised a slot,
/// even though it has not opened a socket yet.
fn admitted(&self) -> usize {
self.conns
.iter()
.filter(|c| c.busy() || c.queued.is_some())
.count()
}

/// Start with only `n` connections active, ramping up from there.
pub fn with_active_limit(mut self, n: usize) -> Self {
self.set_active_limit(n);
Expand Down Expand Up @@ -896,13 +917,47 @@ impl Scheduler {

// ---- work-conserving assignment (Lemma 2) -------------------------
//
// A connection above the active limit is DORMANT: it is skipped here, so it
// is never given work and never opens a socket. This is the whole mechanism
// behind the in-band concurrency ramp — raising the limit makes the next
// tick admit the connection through this ordinary path, and lowering it
// lets an already-busy connection finish its range and then go quiet, with
// no cancellation and no wasted bytes.
for j in 0..self.conns.len().min(self.active_limit) {
// A connection is DORMANT once the budget is spent: it is skipped here, so
// it is never given work and never opens a socket. This is the whole
// mechanism behind the in-band concurrency ramp — raising the limit makes
// the next tick admit a connection through this ordinary path, and
// lowering it lets an already-busy connection finish its range and then go
// quiet, with no cancellation and no wasted bytes.
//
// The budget is spent by COUNT (`admitted`), not by index. All connections
// are dispatched at once — a fixed `-x N`, or the opening burst before any
// refusal has taught the transfer anything — so which ones an origin
// happens to grant is not correlated with index at all. Gating eligibility
// on `j < active_limit` let a refusal-driven cap retire a connection the
// origin was actively serving just because its index was too high, while
// leaving a lower-index connection that was cooling down from its OWN
// refusal as the only thing still allowed to pick up new work — collapsing
// realised concurrency below what the origin would serve, which is the one
// thing this cap exists to prevent. See `admitted` for what counts.
// Candidates are visited proven connections first, unproven ones after —
// "proven" meaning `rate_est > 0.0`, which only a connection that has
// actually delivered bytes on this source carries; a reclaim resets it to
// zero. Plain index order reopens the exact bug above from the other
// side: the moment one of two settled, working connections finishes a
// chunk and goes idle for the one tick before this loop re-admits it, it
// is indistinguishable BY INDEX from a connection that has never
// delivered a byte and is only here because its OWN refusal cooldown
// happens to have expired on the same tick. Whichever has the lower
// index wins the freed slot — sometimes the untested one — and an origin
// that only ever grants the same two connections now refuses the
// newcomer, while the settled connection that actually earned the slot
// sits idle for another tick waiting its turn. Repeated over a transfer's
// life this is exactly the churn the ceiling exists to stop, just paid
// in requests instead of in stranded concurrency.
let mut order: Vec<usize> = (0..self.conns.len())
.filter(|&j| self.conns[j].rate_est > 0.0)
.collect();
order.extend((0..self.conns.len()).filter(|&j| self.conns[j].rate_est <= 0.0));
let mut admitted = self.admitted();
for j in order {
if admitted >= self.active_limit {
break;
}
if self.conns[j].busy() || now < self.conns[j].setup_end {
continue;
}
Expand All @@ -914,18 +969,33 @@ impl Scheduler {
//
// `u64::MAX` — take everything — is right once concurrency has settled:
// maximal ranges mean the fewest requests, which is the whole point of
// range scheduling. It is wrong while the ramp is still growing, because
// the first idle connection would swallow the reserve that connections
// admitted later are supposed to pick up, and they would be left to
// STEAL from it. That is a repair per admission, and the repair
// undoes a split that had just been made for no reason.
// range scheduling. It is wrong while more admissions are still
// expected, because the first idle connection would swallow the
// reserve that connections admitted later are supposed to pick up, and
// they would be left to STEAL from it. That is a repair per admission,
// and the repair undoes a split that had just been made for no reason.
//
// So while ramping, hand out a budget-sized share and leave the rest.
// The cost of being wrong in this direction is one extra request later —
// now nearly free on a pooled connection — against one repair per
// admitted connection the other way.
// So while room remains, hand out a budget-sized share and leave the
// rest. The cost of being wrong in this direction is one extra request
// later — now nearly free on a pooled connection — against one repair
// per admitted connection the other way.
//
// Whether room remains is `admitted` — a COUNT of connections actually
// in play — not `active_limit < ceiling`. The throttle path sets both
// to the same value in one step (see the transfer loop), which a
// comparison between the two can never see as "still growing" even
// though `admitted` can be well below `active_limit` right after that
// step: two connections were busy when the cap was learned, the
// budget just widened to four, and the other two seats are still
// empty because the connections that will fill them are cooling down
// from the refusal that taught the cap. `admitted` sees that room and
// `active_limit < ceiling` does not, so the first of the two survivors
// to finish took the entire remainder — twice, once per survivor, on
// every uneven finish for the rest of the transfer, since each grab
// provokes exactly the steal this branch exists to avoid. `+ 1`
// because `j` itself has not been counted into `admitted` yet.
let ceiling = self.ceiling();
let want = if self.active_limit < ceiling {
let want = if admitted + 1 < self.active_limit {
let remaining = self.unassigned.total();
let share = remaining / ceiling as u64;
share.max(STEAL_QUANTUM * 4)
Expand All @@ -935,6 +1005,7 @@ impl Scheduler {
if let Some(r) = self.unassigned.take_front(want) {
self.start(j, r, now);
acts.push(Action::Request { conn: j, range: r });
admitted += 1;
continue;
}
// Nothing unassigned: steal from the worst laggard.
Expand Down Expand Up @@ -992,6 +1063,7 @@ impl Scheduler {
conn: j,
range: stolen,
});
admitted += 1;
self.stats.repairs += 1;
}
}
Expand Down Expand Up @@ -1185,11 +1257,17 @@ impl Scheduler {
// the collapse has not yet dragged down.
let mut victim: Option<(usize, crate::detect::Health, f64)> = None;
let mut taker: Option<(usize, f64)> = None;
// Dormant connections (above the active limit) are excluded from BOTH
// roles. As taker, admitting one would open a socket the concurrency ramp
// has not yet justified — quietly defeating the limit through the repair
// path. As victim, one cannot be: it holds no range.
for j in 0..self.conns.len().min(self.active_limit) {
// A dormant connection — idle, and not already counted in `admitted` —
// may only become a taker if the budget has room for it: as taker,
// admitting one would open a socket the concurrency ramp, or a refusal
// that has capped the transfer, has not justified — quietly defeating the
// limit through the repair path. An already-busy connection spends no new
// budget by taking on queued work, so it is never gated on room. Index
// plays no part: the budget is a count (`admitted`), not a privilege
// attached to low indices — see `admitted` for why that distinction is
// the fix, not decoration.
let room = self.admitted() < self.active_limit;
for j in 0..self.conns.len() {
let c = &self.conns[j];
if now < c.setup_end || now < self.sources[c.source].suspended_until {
continue;
Expand All @@ -1200,13 +1278,18 @@ impl Scheduler {
} else {
crate::detect::Health::Healthy
};
// As victim a connection needs no room check: it already holds a
// range, so it is not being newly admitted, wherever its index falls.
if c.busy() && victim.map(|(_, vh, ve)| (h, e) > (vh, ve)).unwrap_or(true) {
victim = Some((j, h, e));
}
// A degraded connection must never be chosen as the TAKER: handing
// work to a collapsing connection is the failure mode this whole
// mechanism exists to prevent.
if !h.is_suspect_or_worse() && taker.map(|(_, te)| e < te).unwrap_or(true) {
if !h.is_suspect_or_worse()
&& (c.busy() || room)
&& taker.map(|(_, te)| e < te).unwrap_or(true)
{
taker = Some((j, e));
}
}
Expand Down
44 changes: 39 additions & 5 deletions crates/hydra-net/tests/throttled_origin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ use tokio::net::TcpListener;
const SIZE: u64 = 16 * 1024 * 1024;
/// What this origin will serve at once. Anything above it is refused.
const ALLOWED: usize = 2;
/// The slow-first-byte origin's artificial delay before a granted request's
/// first body byte, in milliseconds. Shared with the `Source::delta_est` the
/// scheduler is given for that origin: a real client would have this from a
/// probe before the transfer starts (`hydra-cli` measures it that way), and
/// starting the scheduler from a `delta_est` an order of magnitude below the
/// origin's real cost — as `Source::default()`'s 50 ms is here — makes the
/// repair profitability test misjudge what a steal actually pays, which is a
/// property of an unrealistic test fixture, not of the scheduler.
const SLOW_FIRST_BYTE_MS: u64 = 400;

fn byte_at(off: u64) -> u8 {
(off % 251) as u8
Expand Down Expand Up @@ -85,6 +94,17 @@ async fn spawn_throttled_origin(refusals: Arc<AtomicUsize>, peak: Arc<AtomicUsiz
break;
}
off = end + 1;
// Sampled here, not only at grant time above: a client that has
// converged needs few requests, so a request granted while both
// slots are held can run to completion without a single further
// accept() — and a peak that only updates on accept would then
// see nothing for the rest of the transfer, wiped by the test's
// reset if that grant landed before it, and reporting collapse
// for a client that never collapsed. Concurrency actually held
// is what the assertion means; sampling every chunk this
// connection writes is what makes that observable regardless of
// when the request that is holding it was granted.
peak.fetch_max(inflight.load(Ordering::SeqCst), Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(5)).await;
}
inflight.fetch_sub(1, Ordering::SeqCst);
Expand Down Expand Up @@ -229,9 +249,15 @@ async fn concurrency_the_origin_refuses_costs_nothing() {
);
// The same failure seen as requests rather than as clock. Eight connections
// against a two-connection origin needs a handful more requests than one does,
// not an order of magnitude more.
// not an order of magnitude more — the original bug measured in the hundreds.
// 24 is generous over the measured range (6-17 across 30+ runs on this
// machine): the two settled connections occasionally lose a race on which of
// them a freed slot goes to when a chunk finishes and a refusal-cooled
// connection becomes eligible on the same tick, costing one spurious refusal
// and retry. That is real tail variance from real network timing, not the
// request-a-share-at-a-time failure this assertion exists to catch.
assert!(
wide.grants <= 16,
wide.grants <= 24,
"{} granted requests to deliver the object at eight connections against {} \
at one: the transfer is re-requesting work a share at a time",
wide.grants,
Expand Down Expand Up @@ -264,7 +290,11 @@ async fn fetch_from_slow_first_byte_origin(n: usize) -> ThrottledRun {
let out = std::env::temp_dir().join(format!("hydra_throttled_slow_first_byte_{n}.bin"));
let outs = out.to_string_lossy().to_string();

let sched = Scheduler::new(SIZE, vec![Source::default()], &[n]).with_stall_timeout(5.0);
let source = Source {
delta_est: SLOW_FIRST_BYTE_MS as f64 / 1000.0,
..Default::default()
};
let sched = Scheduler::new(SIZE, vec![source], &[n]).with_stall_timeout(5.0);
let t0 = std::time::Instant::now();
let r = tokio::time::timeout(
Duration::from_secs(120),
Expand Down Expand Up @@ -362,7 +392,7 @@ async fn spawn_slow_first_byte_origin(
);
let _ = s.write_all(head.as_bytes()).await;
// The first byte costs a round trip the refusal did not.
tokio::time::sleep(Duration::from_millis(400)).await;
tokio::time::sleep(Duration::from_millis(SLOW_FIRST_BYTE_MS)).await;
let mut off = lo;
while off <= hi {
let end = (off + 32 * 1024 - 1).min(hi);
Expand Down Expand Up @@ -405,7 +435,11 @@ async fn bench_fixed_counts_against_the_throttled_origin() {
let t = Target::direct("127.0.0.1", port, "/obj");
let out = std::env::temp_dir().join(format!("hydra_bench_{n}.bin"));
let outs = out.to_string_lossy().to_string();
let sched = Scheduler::new(SIZE, vec![Source::default()], &[n]).with_stall_timeout(5.0);
let source = Source {
delta_est: SLOW_FIRST_BYTE_MS as f64 / 1000.0,
..Default::default()
};
let sched = Scheduler::new(SIZE, vec![source], &[n]).with_stall_timeout(5.0);
let t0 = std::time::Instant::now();
let r = tokio::time::timeout(
Duration::from_secs(120),
Expand Down
Loading