hya_core/plan.rs
1// Copyright (C) 2026 Javad Rajabzadeh
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Deciding how many connections each source gets, before any of them exist.
5//!
6//! # Why this is a separate decision from scheduling
7//!
8//! [`crate::sched`] partitions BYTES across connections that already exist, and
9//! it does so from measurement. This module answers the question one level up
10//! and one moment earlier: given a list of sources and a socket budget, how many
11//! connections should each source be given in the first place? Nothing has been
12//! measured yet, so the only inputs are the budget, the etiquette ceilings, and
13//! whatever the publisher said about their mirrors.
14//!
15//! It lives in the I/O-free core rather than in the transport because it is
16//! pure arithmetic over a policy, it has to be identical under the simulator and
17//! under real HTTP, and getting it wrong is invisible at runtime — an allocation
18//! that quietly exceeds a stated ceiling looks exactly like one that does not.
19//!
20//! # What a mirror list adds to the problem
21//!
22//! Two things a bare URL list does not have:
23//!
24//! * **A ranking.** Metalink `priority` (RFC 5854) and `preference` (3.0) say
25//! which mirrors the publisher expects to serve well. Splitting evenly ignores
26//! it; splitting only by it concentrates the object on one host and throws
27//! away the redundancy that made the list worth having. [`allocate`] takes it
28//! as a proportional weight, so rank 1 gets more than rank 4 and rank 4 still
29//! gets a connection.
30//! * **Per-mirror ceilings.** Metalink 3.0 `maxconnections` is an operator of a
31//! volunteer machine stating a limit for their own host. It binds tighter than
32//! the client's own per-host default and must never be rounded up past.
33//!
34//! # More sources than sockets is the normal case
35//!
36//! A distribution image's mirror list names fifteen to twenty hosts; politeness
37//! and physics together justify perhaps four connections. So most of the list is
38//! not allocated at all — it is a reserve bench, drawn on by
39//! [`crate::sched::Scheduler::replace_source`] when a source dies. [`allocate`]
40//! therefore returns zero for the surplus rather than shaving everyone to
41//! fractional shares, and [`reserves`] names who is on the bench.
42
43/// What is known about one candidate source before the transfer starts.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct SourcePlan {
46 /// RFC 5854 direction: 1 is best. Use [`crate::sched::NO_PRIORITY`] for
47 /// "unranked", which makes every source weigh the same.
48 pub priority: u32,
49 /// A ceiling stated by the source itself (Metalink `maxconnections`).
50 ///
51 /// `None` means the source stated none, in which case the client's own
52 /// per-host ceiling applies. A stated value NARROWS that ceiling and never
53 /// widens it: a mirror operator asking for at most one connection is making
54 /// a request about their machine, while a mirror claiming it can take
55 /// sixty-four is not entitled to override the user's politeness setting.
56 pub max_connections: Option<usize>,
57}
58
59impl Default for SourcePlan {
60 fn default() -> Self {
61 SourcePlan {
62 priority: crate::sched::NO_PRIORITY,
63 max_connections: None,
64 }
65 }
66}
67
68impl SourcePlan {
69 pub fn ranked(priority: u32) -> Self {
70 SourcePlan {
71 priority,
72 ..Default::default()
73 }
74 }
75
76 /// This source's ceiling, given the client's per-host limit.
77 fn cap(&self, per_host: usize) -> usize {
78 let client = per_host.max(1);
79 match self.max_connections {
80 Some(n) => n.clamp(1, client),
81 None => client,
82 }
83 }
84
85 fn weight(&self) -> f64 {
86 1.0 / (self.priority.max(1) as f64)
87 }
88}
89
90/// Split `requested` connections across `sources`, honouring every ceiling.
91///
92/// Returns one entry per source, **in input order** — a caller's own per-source
93/// bookkeeping (targets, hostnames, progress rows) is index-aligned with this,
94/// and re-ordering the result to put the best mirror first would silently
95/// scramble it.
96///
97/// The three ceilings, all enforced:
98///
99/// * `requested` — what the caller asked for, usually `-x` or a measured
100/// concurrency.
101/// * `total` — the aggregate socket ceiling. Eight connections across two
102/// mirrors is still eight sockets, which is the number a server operator
103/// actually feels.
104/// * `per_host`, narrowed by any [`SourcePlan::max_connections`].
105///
106/// # The allocation rule
107///
108/// Every source that gets anything gets at least one, best-ranked first; the
109/// remainder is distributed by the divisor method — repeatedly give the next
110/// connection to whichever source maximises `weight / (held + 1)`. That is the
111/// standard proportional-apportionment rule, and it is used here for the
112/// property that makes it standard: it never leaves a source with a share that
113/// another source's ranking cannot justify, and it terminates in exactly
114/// `budget` steps with no rounding residue to strand.
115///
116/// Ties break on `(priority, index)` so two runs against the same mirror list
117/// allocate identically. A download that opens different mirrors on every
118/// attempt cannot be debugged from its logs.
119pub fn allocate(
120 sources: &[SourcePlan],
121 requested: usize,
122 per_host: usize,
123 total: usize,
124) -> Vec<usize> {
125 let n = sources.len();
126 let mut out = vec![0usize; n];
127 if n == 0 {
128 return out;
129 }
130 let budget = requested.max(1).min(total.max(1));
131 let caps: Vec<usize> = sources.iter().map(|s| s.cap(per_host)).collect();
132
133 // Best-ranked first, stable on index. This order decides WHO participates
134 // when there are more sources than sockets; it does not decide the shape of
135 // the output, which stays in input order.
136 let mut order: Vec<usize> = (0..n).collect();
137 order.sort_by_key(|&i| (sources[i].priority, i));
138
139 // One each, to as many sources as the budget can seat.
140 let seated = n.min(budget);
141 for &i in order.iter().take(seated) {
142 out[i] = 1;
143 }
144 let mut left = budget - seated;
145
146 // Divisor method over the seated sources.
147 while left > 0 {
148 let mut best: Option<(usize, f64)> = None;
149 for &i in order.iter().take(seated) {
150 if out[i] >= caps[i] {
151 continue;
152 }
153 let score = sources[i].weight() / (out[i] + 1) as f64;
154 // Strictly greater keeps the `order` tie-break: the first source in
155 // rank order wins an exact tie.
156 if best.map(|(_, b)| score > b).unwrap_or(true) {
157 best = Some((i, score));
158 }
159 }
160 let Some((i, _)) = best else {
161 // Every seated source is at its ceiling. The remaining budget is not
162 // reassigned to unseated sources: seating another host to spend
163 // sockets the ranked ones were not permitted would be a politeness
164 // ceiling defeated by arithmetic.
165 break;
166 };
167 out[i] += 1;
168 left -= 1;
169 }
170 out
171}
172
173/// The sources [`allocate`] gave no connections to, best-ranked first.
174///
175/// These are not rejected sources — they are the bench. When a source fails,
176/// [`crate::sched::Scheduler::replace_source`] substitutes one of these in
177/// place, which is what makes a nineteen-mirror list worth more than a
178/// four-mirror one at four connections.
179pub fn reserves(sources: &[SourcePlan], allocation: &[usize]) -> Vec<usize> {
180 let mut idx: Vec<usize> = (0..sources.len())
181 .filter(|&i| allocation.get(i).copied().unwrap_or(0) == 0)
182 .collect();
183 idx.sort_by_key(|&i| (sources[i].priority, i));
184 idx
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::sched::NO_PRIORITY;
191
192 fn flat(n: usize) -> Vec<SourcePlan> {
193 vec![SourcePlan::default(); n]
194 }
195
196 #[test]
197 fn an_unranked_list_splits_evenly_and_the_remainder_leads() {
198 // Matches the behaviour that existed before rankings did: `5` over `2` is
199 // `3, 2`, not `4, 1`.
200 assert_eq!(allocate(&flat(2), 5, 8, 16), vec![3, 2]);
201 assert_eq!(allocate(&flat(1), 4, 8, 16), vec![4]);
202 assert_eq!(allocate(&flat(4), 4, 8, 16), vec![1, 1, 1, 1]);
203 }
204
205 #[test]
206 fn a_ranking_shifts_share_without_starving_the_rest() {
207 // Splitting only by rank would concentrate the object on one host and
208 // throw away the redundancy the list exists to provide.
209 let s = vec![SourcePlan::ranked(1), SourcePlan::ranked(4)];
210 let got = allocate(&s, 6, 8, 16);
211 assert_eq!(got.iter().sum::<usize>(), 6);
212 assert!(got[0] > got[1], "rank 1 must lead rank 4: {got:?}");
213 assert!(got[1] >= 1, "rank 4 must not be starved: {got:?}");
214 }
215
216 #[test]
217 fn the_output_is_in_input_order_not_rank_order() {
218 // A caller's targets, hostnames and progress rows are index-aligned with
219 // this. Re-ordering to put the best mirror first would scramble them
220 // silently — every row would describe a different host than it fetches
221 // from.
222 let s = vec![SourcePlan::ranked(9), SourcePlan::ranked(1)];
223 let got = allocate(&s, 4, 8, 16);
224 assert!(got[1] > got[0], "the better mirror is at index 1: {got:?}");
225 }
226
227 #[test]
228 fn the_aggregate_ceiling_is_never_multiplied_by_the_mirror_count() {
229 // Eight connections over two mirrors is still eight sockets, which is
230 // what an operator feels.
231 for n in 1..8usize {
232 let got = allocate(&flat(n), 8, 8, 2);
233 assert_eq!(got.iter().sum::<usize>(), 2, "n={n} {got:?}");
234 }
235 }
236
237 #[test]
238 fn a_mirrors_own_stated_ceiling_narrows_but_never_widens_the_clients() {
239 // `maxconnections="1"` is an operator of a volunteer machine stating a
240 // limit for their own host, and it must not be rounded up past.
241 let s = vec![
242 SourcePlan {
243 priority: 1,
244 max_connections: Some(1),
245 },
246 SourcePlan::ranked(2),
247 ];
248 let got = allocate(&s, 6, 4, 16);
249 assert_eq!(got[0], 1, "the stated ceiling binds: {got:?}");
250 assert!(got[1] > 1);
251
252 // A mirror claiming it can take sixty-four does not get to override the
253 // user's own politeness setting.
254 let greedy = vec![SourcePlan {
255 priority: 1,
256 max_connections: Some(64),
257 }];
258 assert_eq!(allocate(&greedy, 16, 4, 16), vec![4]);
259 }
260
261 #[test]
262 fn surplus_budget_is_dropped_rather_than_spent_on_an_unseated_host() {
263 // Every seated source at its ceiling with budget left over. Seating
264 // another host to spend it would defeat the per-host ceiling by
265 // arithmetic — the aggregate would be honoured and the intent would not.
266 let s = vec![
267 SourcePlan {
268 priority: 1,
269 max_connections: Some(1),
270 },
271 SourcePlan {
272 priority: 2,
273 max_connections: Some(1),
274 },
275 SourcePlan::ranked(3),
276 ];
277 let got = allocate(&s, 2, 4, 16);
278 assert_eq!(got, vec![1, 1, 0]);
279 assert_eq!(got.iter().sum::<usize>(), 2);
280 }
281
282 #[test]
283 fn more_mirrors_than_sockets_leaves_a_reserve_bench_in_rank_order() {
284 // The normal case for a real mirror list: nineteen hosts, four sockets.
285 let s: Vec<SourcePlan> = (0..19).map(|i| SourcePlan::ranked(19 - i as u32)).collect();
286 let got = allocate(&s, 4, 4, 16);
287 assert_eq!(got.iter().sum::<usize>(), 4);
288 assert_eq!(got.iter().filter(|&&n| n > 0).count(), 4);
289 // The four best-ranked hosts are the ones seated: priorities 1..4, which
290 // are the LAST four entries by construction.
291 assert!(got[15..].iter().all(|&n| n > 0), "{got:?}");
292
293 let bench = reserves(&s, &got);
294 assert_eq!(bench.len(), 15);
295 // Best-ranked reserve first: it is the next one substituted in.
296 assert_eq!(s[bench[0]].priority, 5);
297 assert!(bench.iter().all(|&i| got[i] == 0));
298 }
299
300 #[test]
301 fn allocation_is_deterministic_across_runs() {
302 // A download that opens different mirrors on every attempt cannot be
303 // debugged from its logs.
304 let s = vec![
305 SourcePlan::ranked(3),
306 SourcePlan::ranked(3),
307 SourcePlan::ranked(3),
308 ];
309 let first = allocate(&s, 7, 4, 16);
310 for _ in 0..50 {
311 assert_eq!(allocate(&s, 7, 4, 16), first);
312 }
313 // An exact tie goes to the earlier index.
314 assert!(first[0] >= first[1] && first[1] >= first[2], "{first:?}");
315 }
316
317 #[test]
318 fn degenerate_inputs_do_not_panic_or_over_allocate() {
319 assert!(allocate(&[], 4, 4, 16).is_empty());
320 // Zero is not a socket count anyone can act on; one is the floor.
321 assert_eq!(allocate(&flat(1), 0, 4, 16).iter().sum::<usize>(), 1);
322 assert_eq!(allocate(&flat(3), 4, 0, 16).iter().sum::<usize>(), 3);
323 assert_eq!(allocate(&flat(3), 4, 4, 0).iter().sum::<usize>(), 1);
324 // An unranked source and NO_PRIORITY are the same thing.
325 assert_eq!(
326 allocate(&[SourcePlan::ranked(NO_PRIORITY)], 3, 4, 16),
327 allocate(&flat(1), 3, 4, 16)
328 );
329 }
330}