Skip to main content

rget/
scheduler.rs

1//! Byte-range planning and worker assignment (PRD §7, §8).
2//!
3//! ## Ownership model
4//!
5//! A range is owned by at most one worker at a time. Ownership is the window
6//! `[start + progress, end]`, where `end` lives in an `AtomicU64` the scheduler
7//! may *lower* but never raise. A worker writes only below its own `end`.
8//!
9//! Dynamic splitting (PRD §8) exploits exactly that asymmetry: to hand an idle
10//! worker part of a slow worker's range, the scheduler lowers the victim's
11//! `end` and creates a new range starting above it. Because the split point is
12//! chosen at least [`SPLIT_MARGIN`] bytes ahead of the victim's current write
13//! position, and a single body chunk is far smaller than that margin, the
14//! victim cannot have written into the new owner's territory even if it read
15//! the old `end` a moment before the split. No overlap, no locking on the hot
16//! path (PRD Invariant 2).
17
18use std::collections::{BTreeMap, VecDeque};
19use std::sync::Arc;
20use std::sync::Mutex;
21use std::sync::atomic::{AtomicU64, Ordering};
22
23use tokio::sync::Notify;
24
25use crate::storage::{OPEN_END, RangeRecord, RangeState};
26
27/// Smallest range worth creating. Below this, per-request overhead and the
28/// extra connection cost more than the parallelism gains.
29pub const MIN_CHUNK: u64 = 4 << 20;
30/// Largest range we plan up front. Bigger ranges mean coarser recovery after a
31/// crash and less room for the scheduler to rebalance.
32pub const MAX_CHUNK: u64 = 128 << 20;
33/// How far ahead of a victim's write position a split point must sit. Must
34/// exceed the largest single body chunk a worker can write (tens of KiB in
35/// practice), with a wide safety factor.
36pub const SPLIT_MARGIN: u64 = 4 << 20;
37/// Do not bother splitting unless both halves are worth having.
38pub const MIN_SPLIT_TAIL: u64 = 8 << 20;
39
40/// Chunk size for a fresh plan: one range per connection.
41///
42/// This used to plan four ranges per connection so that a slow connection would
43/// naturally pick up less work. That predates work stealing, and it costs a full
44/// round trip per extra wave of ranges: a client with `n` connections and `4n`
45/// ranges pays `4 × RTT` in request setup before the last byte can start moving,
46/// on every download. Measured against a server with 200 ms of per-request
47/// latency, that oversubscription made `-c8` 2.5× *slower* than a single
48/// request, because latency, not bandwidth, was the limit.
49///
50/// Rebalancing is now [`Scheduler::acquire`]'s job: it splits a range that is
51/// running behind and hands the tail to an idle worker, which adapts to real
52/// connection speed rather than guessing at it up front. So plan the minimum
53/// number of ranges and let stealing do the rest.
54///
55/// [`MAX_CHUNK`] still applies, so very large files get more ranges than there
56/// are connections -- which is what keeps crash recovery granular.
57pub fn chunk_size(total: u64, connections: usize) -> u64 {
58    let target_chunks = (connections as u64).max(1);
59    (total / target_chunks).clamp(MIN_CHUNK, MAX_CHUNK)
60}
61
62/// Partition `[0, total)` into contiguous inclusive ranges.
63pub fn plan(total: u64, connections: usize) -> Vec<RangeRecord> {
64    if total == 0 {
65        return Vec::new();
66    }
67    let chunk = chunk_size(total, connections);
68    let mut ranges = Vec::new();
69    let mut start = 0u64;
70    let mut idx = 0u64;
71    while start < total {
72        let end = (start + chunk - 1).min(total - 1);
73        ranges.push(RangeRecord {
74            idx,
75            start,
76            end,
77            state: RangeState::Pending,
78            bytes_written: 0,
79        });
80        start = end + 1;
81        idx += 1;
82    }
83    ranges
84}
85
86/// A plan whose first range is exactly the bytes the priming probe already has
87/// in flight, so none of them are wasted and none are fetched twice.
88///
89/// The probe opens a bounded range rather than the whole file precisely so this
90/// is possible: an open-ended primed body would stream the entire file down a
91/// connection whose worker stops at the first chunk boundary, throwing away
92/// everything the server raced ahead. Pinning the boundary to what was actually
93/// requested makes the waste zero.
94///
95/// The remainder is split across the *other* connections, so the total request
96/// count stays at one per connection — the whole point of [`chunk_size`].
97pub fn plan_primed(total: u64, connections: usize, primed: u64) -> Vec<RangeRecord> {
98    // A primed body covering everything (or nothing) has no boundary to pin.
99    if total == 0 || primed == 0 || primed >= total {
100        return plan(total, connections);
101    }
102
103    let mut ranges = vec![RangeRecord {
104        idx: 0,
105        start: 0,
106        end: primed - 1,
107        state: RangeState::Pending,
108        bytes_written: 0,
109    }];
110
111    let rest = total - primed;
112    let chunk = chunk_size(rest, connections.saturating_sub(1).max(1));
113    let mut start = primed;
114    let mut idx = 1u64;
115    while start < total {
116        let end = (start + chunk - 1).min(total - 1);
117        ranges.push(RangeRecord {
118            idx,
119            start,
120            end,
121            state: RangeState::Pending,
122            bytes_written: 0,
123        });
124        start = end + 1;
125        idx += 1;
126    }
127    ranges
128}
129
130/// The single-range plan used when the server has no usable `Range` support or
131/// never told us the size (PRD §6: the fallback needs no user intervention).
132pub fn plan_sequential(total: Option<u64>) -> Vec<RangeRecord> {
133    vec![RangeRecord {
134        idx: 0,
135        start: 0,
136        end: total.map(|t| t.saturating_sub(1)).unwrap_or(OPEN_END),
137        state: RangeState::Pending,
138        bytes_written: 0,
139    }]
140}
141
142/// A worker's exclusive claim on part of the file.
143#[derive(Clone)]
144pub struct Lease {
145    pub idx: u64,
146    pub start: u64,
147    /// Inclusive upper bound. The scheduler may lower this; re-read it before
148    /// every write and stop when it is passed.
149    pub end: Arc<AtomicU64>,
150    /// Bytes written from `start`, published by the owning worker.
151    pub progress: Arc<AtomicU64>,
152}
153
154impl Lease {
155    pub fn end(&self) -> u64 {
156        self.end.load(Ordering::Acquire)
157    }
158
159    pub fn progress(&self) -> u64 {
160        self.progress.load(Ordering::Acquire)
161    }
162
163    /// Absolute file offset of the next byte to fetch.
164    pub fn cursor(&self) -> u64 {
165        self.start + self.progress()
166    }
167
168    pub fn is_open_ended(&self) -> bool {
169        self.end() >= OPEN_END
170    }
171
172    /// Bytes still owed on this lease, given the current (possibly lowered) end.
173    pub fn remaining(&self) -> u64 {
174        let end = self.end();
175        let cursor = self.cursor();
176        if cursor > end { 0 } else { end - cursor + 1 }
177    }
178
179    pub fn publish_progress(&self, bytes_from_start: u64) {
180        self.progress.store(bytes_from_start, Ordering::Release);
181    }
182}
183
184struct Live {
185    start: u64,
186    end: Arc<AtomicU64>,
187    progress: Arc<AtomicU64>,
188    state: RangeState,
189    leased: bool,
190}
191
192struct State {
193    ranges: BTreeMap<u64, Live>,
194    pending: VecDeque<u64>,
195    next_idx: u64,
196}
197
198pub struct Scheduler {
199    state: Mutex<State>,
200    /// Woken when work appears or the last worker finishes, so idle workers do
201    /// not poll.
202    wake: Notify,
203}
204
205/// A split the scheduler performed, for the caller to persist.
206#[derive(Debug, Clone, Copy)]
207pub struct Split {
208    pub shrunk: RangeRecord,
209    pub added: RangeRecord,
210}
211
212impl Scheduler {
213    /// Build from persisted ranges. Completed ranges stay completed; anything
214    /// else becomes pending from its durable prefix — including ranges left in
215    /// `downloading` by a process that died (PRD §12 step 4).
216    pub fn from_ranges(ranges: &[RangeRecord]) -> Self {
217        let mut map = BTreeMap::new();
218        let mut pending = VecDeque::new();
219        let mut next_idx = 0;
220        for r in ranges {
221            next_idx = next_idx.max(r.idx + 1);
222            let complete = r.state == RangeState::Complete;
223            map.insert(
224                r.idx,
225                Live {
226                    start: r.start,
227                    end: Arc::new(AtomicU64::new(r.end)),
228                    progress: Arc::new(AtomicU64::new(if complete {
229                        r.size()
230                    } else {
231                        r.bytes_written
232                    })),
233                    state: if complete {
234                        RangeState::Complete
235                    } else {
236                        RangeState::Pending
237                    },
238                    leased: false,
239                },
240            );
241            if !complete {
242                pending.push_back(r.idx);
243            }
244        }
245        Self {
246            state: Mutex::new(State {
247                ranges: map,
248                pending,
249                next_idx,
250            }),
251            wake: Notify::new(),
252        }
253    }
254
255    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
256        self.state.lock().unwrap_or_else(|e| e.into_inner())
257    }
258
259    /// Claim work: a pending range if one exists, otherwise steal the tail of
260    /// the slowest active range. `None` means "nothing to do right now".
261    pub fn acquire(&self) -> Option<(Lease, Option<Split>)> {
262        let mut st = self.lock();
263        while let Some(idx) = st.pending.pop_front() {
264            let Some(live) = st.ranges.get_mut(&idx) else {
265                continue;
266            };
267            if live.state == RangeState::Complete || live.leased {
268                continue;
269            }
270            live.leased = true;
271            live.state = RangeState::Downloading;
272            return Some((
273                Lease {
274                    idx,
275                    start: live.start,
276                    end: live.end.clone(),
277                    progress: live.progress.clone(),
278                },
279                None,
280            ));
281        }
282        self.split_locked(&mut st).map(|(l, s)| (l, Some(s)))
283    }
284
285    /// Subdivide the active range with the largest remaining tail so an idle
286    /// worker has something to do (PRD §8).
287    fn split_locked(&self, st: &mut State) -> Option<(Lease, Split)> {
288        let mut best: Option<(u64, u64)> = None; // (idx, tail length)
289        for (idx, live) in st.ranges.iter() {
290            if !live.leased || live.state == RangeState::Complete {
291                continue;
292            }
293            let end = live.end.load(Ordering::Acquire);
294            if end >= OPEN_END {
295                // An open-ended range has no known midpoint to split at.
296                continue;
297            }
298            let cursor = live.start + live.progress.load(Ordering::Acquire);
299            let split_at = cursor + SPLIT_MARGIN;
300            if end < split_at {
301                continue;
302            }
303            let tail = end - split_at + 1;
304            if tail < MIN_SPLIT_TAIL {
305                continue;
306            }
307            if best.is_none_or(|(_, best_tail)| tail > best_tail) {
308                best = Some((*idx, tail));
309            }
310        }
311
312        let (victim_idx, _) = best?;
313        let (victim_start, old_end, cursor) = {
314            let live = st.ranges.get(&victim_idx)?;
315            (
316                live.start,
317                live.end.load(Ordering::Acquire),
318                live.start + live.progress.load(Ordering::Acquire),
319            )
320        };
321
322        // Split at the midpoint of what is left, but never closer than
323        // SPLIT_MARGIN to where the victim is writing right now.
324        let midpoint = cursor + (old_end - cursor) / 2;
325        let split_at = midpoint.max(cursor + SPLIT_MARGIN);
326        if split_at > old_end || old_end - split_at + 1 < MIN_SPLIT_TAIL {
327            return None;
328        }
329
330        // Lower the victim's ceiling first. From this instant the victim can no
331        // longer write at or past `split_at`.
332        let new_victim_end = split_at - 1;
333        let victim_progress = {
334            let live = st.ranges.get_mut(&victim_idx)?;
335            live.end.store(new_victim_end, Ordering::Release);
336            live.progress.load(Ordering::Acquire)
337        };
338
339        let new_idx = st.next_idx;
340        st.next_idx += 1;
341        let end = Arc::new(AtomicU64::new(old_end));
342        let progress = Arc::new(AtomicU64::new(0));
343        st.ranges.insert(
344            new_idx,
345            Live {
346                start: split_at,
347                end: end.clone(),
348                progress: progress.clone(),
349                state: RangeState::Downloading,
350                leased: true,
351            },
352        );
353
354        Some((
355            Lease {
356                idx: new_idx,
357                start: split_at,
358                end,
359                progress,
360            },
361            Split {
362                shrunk: RangeRecord {
363                    idx: victim_idx,
364                    start: victim_start,
365                    end: new_victim_end,
366                    state: RangeState::Downloading,
367                    bytes_written: victim_progress,
368                },
369                added: RangeRecord {
370                    idx: new_idx,
371                    start: split_at,
372                    end: old_end,
373                    state: RangeState::Pending,
374                    bytes_written: 0,
375                },
376            },
377        ))
378    }
379
380    /// Mark a lease finished. Idempotent.
381    pub fn complete(&self, idx: u64) {
382        let mut st = self.lock();
383        if let Some(live) = st.ranges.get_mut(&idx) {
384            live.state = RangeState::Complete;
385            live.leased = false;
386            let end = live.end.load(Ordering::Acquire);
387            live.progress.store(
388                end.saturating_sub(live.start).saturating_add(1),
389                Ordering::Release,
390            );
391        }
392        drop(st);
393        self.wake.notify_waiters();
394    }
395
396    /// Release a lease without completing it. The range keeps its durable
397    /// prefix and goes back in the queue, so one worker's failure costs only
398    /// that range (PRD Invariant 6).
399    pub fn release(&self, idx: u64) {
400        let mut st = self.lock();
401        if let Some(live) = st.ranges.get_mut(&idx) {
402            if live.state != RangeState::Complete {
403                live.state = RangeState::Pending;
404                live.leased = false;
405                st.pending.push_back(idx);
406            }
407        }
408        drop(st);
409        self.wake.notify_waiters();
410    }
411
412    /// Give up on a range permanently. The download as a whole fails, but we
413    /// keep every other range's progress.
414    pub fn fail(&self, idx: u64) {
415        let mut st = self.lock();
416        if let Some(live) = st.ranges.get_mut(&idx) {
417            live.state = RangeState::Failed;
418            live.leased = false;
419        }
420        drop(st);
421        self.wake.notify_waiters();
422    }
423
424    /// When a range's real length turns out to differ from the plan — an
425    /// open-ended sequential transfer that just ended — record the true end.
426    pub fn set_end(&self, idx: u64, end: u64) {
427        let st = self.lock();
428        if let Some(live) = st.ranges.get(&idx) {
429            live.end.store(end, Ordering::Release);
430        }
431    }
432
433    pub fn is_finished(&self) -> bool {
434        let st = self.lock();
435        st.ranges.values().all(|l| l.state == RangeState::Complete)
436    }
437
438    pub fn has_failure(&self) -> bool {
439        let st = self.lock();
440        st.ranges.values().any(|l| l.state == RangeState::Failed)
441    }
442
443    /// True when no work is available and none will become available — every
444    /// range is either complete or failed.
445    pub fn is_drained(&self) -> bool {
446        let st = self.lock();
447        st.pending.is_empty()
448            && st
449                .ranges
450                .values()
451                .all(|l| matches!(l.state, RangeState::Complete | RangeState::Failed) || l.leased)
452            && !st.ranges.values().any(|l| l.leased)
453    }
454
455    pub async fn wait_for_change(&self, timeout: std::time::Duration) {
456        let _ = tokio::time::timeout(timeout, self.wake.notified()).await;
457    }
458
459    pub fn notify(&self) {
460        self.wake.notify_waiters();
461    }
462
463    /// Current view of every range, for the committer and for progress.
464    pub fn snapshot(&self) -> Vec<RangeRecord> {
465        let st = self.lock();
466        st.ranges
467            .iter()
468            .map(|(idx, live)| RangeRecord {
469                idx: *idx,
470                start: live.start,
471                end: live.end.load(Ordering::Acquire),
472                state: live.state,
473                bytes_written: live.progress.load(Ordering::Acquire),
474            })
475            .collect()
476    }
477
478    pub fn counts(&self) -> (usize, usize) {
479        let st = self.lock();
480        let complete = st
481            .ranges
482            .values()
483            .filter(|l| l.state == RangeState::Complete)
484            .count();
485        (complete, st.ranges.len())
486    }
487
488    /// Sum of every range's written prefix — the engine's view of "downloaded".
489    pub fn written_bytes(&self) -> u64 {
490        let st = self.lock();
491        st.ranges
492            .values()
493            .map(|l| l.progress.load(Ordering::Acquire))
494            .sum()
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    /// The invariant from PRD §34: ranges partition the file exactly.
503    fn assert_partition(ranges: &[RangeRecord], total: u64) {
504        let mut sorted: Vec<_> = ranges.to_vec();
505        sorted.sort_by_key(|r| r.start);
506        let mut cursor = 0u64;
507        for r in &sorted {
508            assert_eq!(
509                r.start, cursor,
510                "gap or overlap at range {} ({}..={})",
511                r.idx, r.start, r.end
512            );
513            assert!(r.end >= r.start, "inverted range {}", r.idx);
514            assert!(r.end < total, "range {} extends past {total}", r.idx);
515            cursor = r.end + 1;
516        }
517        assert_eq!(cursor, total, "ranges do not cover the whole file");
518    }
519
520    #[test]
521    fn plan_partitions_the_file() {
522        for total in [1u64, 2, MIN_CHUNK - 1, MIN_CHUNK, MIN_CHUNK + 1, 1 << 30] {
523            for conns in [1usize, 4, 8, 32] {
524                let ranges = plan(total, conns);
525                assert!(!ranges.is_empty());
526                assert_partition(&ranges, total);
527            }
528        }
529    }
530
531    #[test]
532    fn plan_of_empty_file_is_empty() {
533        assert!(plan(0, 8).is_empty());
534    }
535
536    #[test]
537    fn chunk_size_is_bounded() {
538        assert_eq!(chunk_size(1, 8), MIN_CHUNK);
539        assert_eq!(chunk_size(u64::MAX, 8), MAX_CHUNK);
540        let c = chunk_size(10 << 30, 8);
541        assert!((MIN_CHUNK..=MAX_CHUNK).contains(&c));
542    }
543
544    #[test]
545    fn an_idle_worker_steals_from_the_range_furthest_behind() {
546        // With one range per connection there is no spare pending work, so
547        // stealing is the *entire* rebalancing mechanism -- this is what the
548        // plan's 4x oversubscription used to provide. A fast connection that
549        // finishes early must be able to take work off a slow one, or a single
550        // straggler decides the download's wall clock.
551        let total = 256 << 20;
552        let s = Scheduler::from_ranges(&plan(total, 4));
553        let mut leases = Vec::new();
554        for _ in 0..4 {
555            let (lease, split) = s.acquire().expect("a pending range");
556            assert!(split.is_none(), "no split while pending work remains");
557            leases.push(lease);
558        }
559
560        // Three connections finish. The fourth has barely started.
561        for lease in &leases[..3] {
562            s.complete(lease.idx);
563        }
564        let straggler = &leases[3];
565        straggler.publish_progress(1 << 20);
566
567        let (stolen, split) = s
568            .acquire()
569            .expect("an idle worker must be able to steal from the straggler");
570        let split = split.expect("the only work left is inside a leased range");
571        assert_eq!(split.shrunk.idx, straggler.idx);
572
573        // The stolen tail must start beyond where the victim could still be
574        // writing, and stay inside what the victim originally owned.
575        assert!(
576            stolen.start > straggler.start + straggler.progress(),
577            "stole bytes the victim may still write"
578        );
579        assert!(stolen.end() < straggler.start + (total / 4));
580        assert_eq!(
581            straggler.end(),
582            stolen.start - 1,
583            "the victim's ceiling must drop to meet the stolen tail"
584        );
585    }
586
587    #[test]
588    fn primed_plan_pins_its_first_range_to_the_primed_bytes() {
589        let total = 64 << 20;
590        let primed = MIN_CHUNK;
591        let ranges = plan_primed(total, 4, primed);
592
593        // The first range must be exactly what the probe already has in flight,
594        // or those bytes are either wasted or fetched twice.
595        assert_eq!(ranges[0].start, 0);
596        assert_eq!(ranges[0].end, primed - 1);
597
598        // Still a contiguous, gapless partition of the whole file.
599        for pair in ranges.windows(2) {
600            assert_eq!(pair[1].start, pair[0].end + 1, "gap or overlap in the plan");
601        }
602        assert_eq!(ranges.last().unwrap().end, total - 1);
603        for (i, r) in ranges.iter().enumerate() {
604            assert_eq!(r.idx, i as u64);
605        }
606    }
607
608    #[test]
609    fn primed_plan_falls_back_when_there_is_no_boundary_to_pin() {
610        let total = 64 << 20;
611        // A body covering the whole file, or none of it, leaves nothing to pin.
612        assert_eq!(plan_primed(total, 4, 0), plan(total, 4));
613        assert_eq!(plan_primed(total, 4, total), plan(total, 4));
614        assert_eq!(plan_primed(total, 4, total + 1), plan(total, 4));
615        assert!(plan_primed(0, 4, MIN_CHUNK).is_empty());
616    }
617
618    #[test]
619    fn sequential_plan_handles_unknown_size() {
620        let r = plan_sequential(None);
621        assert_eq!(r.len(), 1);
622        assert!(r[0].is_open_ended());
623
624        let r = plan_sequential(Some(1000));
625        assert_eq!(r[0].end, 999);
626    }
627
628    #[test]
629    fn acquire_hands_out_each_range_once() {
630        let total = 100 << 20;
631        let s = Scheduler::from_ranges(&plan(total, 4));
632        let (_, count) = s.counts();
633        let mut seen = Vec::new();
634        for _ in 0..count {
635            let (lease, split) = s.acquire().expect("a pending range");
636            assert!(split.is_none(), "should not split while ranges are pending");
637            assert!(
638                !seen.contains(&lease.idx),
639                "range {} leased twice",
640                lease.idx
641            );
642            seen.push(lease.idx);
643        }
644        assert_eq!(seen.len(), count);
645
646        // The plan is fully leased now, so the only way to satisfy more demand
647        // is to steal the tail of a range already in flight. A plan of one range
648        // per connection relies on exactly that.
649        if let Some((lease, split)) = s.acquire() {
650            assert!(
651                split.is_some(),
652                "a lease beyond the plan must come from a split"
653            );
654            assert!(
655                !seen.contains(&lease.idx),
656                "split handed back an existing range"
657            );
658        }
659    }
660
661    #[test]
662    fn resumes_only_incomplete_ranges() {
663        let mut ranges = plan(100 << 20, 4);
664        ranges[0].state = RangeState::Complete;
665        ranges[0].bytes_written = ranges[0].size();
666        // A range the previous process was mid-way through.
667        ranges[1].state = RangeState::Downloading;
668        ranges[1].bytes_written = 1024;
669
670        let s = Scheduler::from_ranges(&ranges);
671        let mut leases = Vec::new();
672        while let Some((lease, _)) = s.acquire() {
673            leases.push(lease);
674        }
675        assert!(
676            !leases.iter().any(|l| l.idx == 0),
677            "completed range must not be re-leased"
678        );
679        let resumed = leases
680            .iter()
681            .find(|l| l.idx == 1)
682            .expect("range 1 re-leased");
683        assert_eq!(resumed.cursor(), ranges[1].start + 1024);
684        assert_eq!(s.written_bytes(), ranges[0].size() + 1024);
685    }
686
687    #[test]
688    fn split_never_overlaps_and_preserves_the_partition() {
689        let total = 512 << 20;
690        let s = Scheduler::from_ranges(&plan_sequential(Some(total)));
691        let (victim, _) = s.acquire().expect("one range to lease");
692
693        // Victim has written a little; an idle worker steals the tail.
694        victim.publish_progress(16 << 20);
695        let (thief, split) = s.acquire().expect("split should produce work");
696        let split = split.expect("expected a split record");
697
698        assert!(
699            thief.start > victim.start + victim.progress() + SPLIT_MARGIN - 1,
700            "split point {} too close to victim cursor {}",
701            thief.start,
702            victim.cursor()
703        );
704        assert_eq!(victim.end() + 1, thief.start, "split left a gap");
705        assert_eq!(thief.end(), total - 1);
706        assert_eq!(split.shrunk.end + 1, split.added.start);
707
708        assert_partition(&s.snapshot(), total);
709    }
710
711    #[test]
712    fn split_refuses_when_the_tail_is_small() {
713        // 8 MiB total: after the margin there is nothing worth splitting.
714        let s = Scheduler::from_ranges(&plan_sequential(Some(8 << 20)));
715        let (lease, _) = s.acquire().unwrap();
716        lease.publish_progress(1 << 20);
717        assert!(s.acquire().is_none(), "should not split a tiny tail");
718    }
719
720    #[test]
721    fn split_refuses_open_ended_ranges() {
722        let s = Scheduler::from_ranges(&plan_sequential(None));
723        let (lease, _) = s.acquire().unwrap();
724        lease.publish_progress(64 << 20);
725        assert!(s.acquire().is_none(), "cannot split an unknown length");
726    }
727
728    #[test]
729    fn repeated_splits_keep_the_partition_intact() {
730        let total = 4u64 << 30;
731        let s = Scheduler::from_ranges(&plan(total, 2));
732        let mut leases = Vec::new();
733        while let Some((lease, _)) = s.acquire() {
734            leases.push(lease);
735        }
736        // Everyone makes some progress, then we keep stealing tails.
737        for round in 0..6 {
738            for l in &leases {
739                l.publish_progress((round + 1) * (8 << 20));
740            }
741            if let Some((lease, _)) = s.acquire() {
742                leases.push(lease);
743            }
744            assert_partition(&s.snapshot(), total);
745        }
746    }
747
748    #[test]
749    fn release_requeues_with_progress_kept() {
750        let s = Scheduler::from_ranges(&plan(100 << 20, 2));
751        let (lease, _) = s.acquire().unwrap();
752        lease.publish_progress(4096);
753        s.release(lease.idx);
754
755        let mut found = None;
756        while let Some((l, _)) = s.acquire() {
757            if l.idx == lease.idx {
758                found = Some(l);
759                break;
760            }
761        }
762        let again = found.expect("released range should be handed out again");
763        assert_eq!(again.progress(), 4096);
764        assert!(!s.is_finished());
765    }
766
767    #[test]
768    fn completion_is_tracked() {
769        let ranges = plan(100 << 20, 2);
770        let s = Scheduler::from_ranges(&ranges);
771        let mut leases = Vec::new();
772        while let Some((l, _)) = s.acquire() {
773            leases.push(l);
774        }
775        for l in &leases {
776            s.complete(l.idx);
777        }
778        assert!(s.is_finished());
779        let (done, total) = s.counts();
780        assert_eq!(done, total);
781        assert_eq!(s.written_bytes(), 100 << 20);
782        assert!(!s.has_failure());
783    }
784
785    #[test]
786    fn failure_is_isolated() {
787        let s = Scheduler::from_ranges(&plan(100 << 20, 2));
788        let (a, _) = s.acquire().unwrap();
789        let (b, _) = s.acquire().unwrap();
790        b.publish_progress(1000);
791        s.complete(b.idx);
792        s.fail(a.idx);
793
794        assert!(s.has_failure());
795        assert!(!s.is_finished());
796        // The completed range keeps its bytes.
797        let snap = s.snapshot();
798        let completed = snap.iter().find(|r| r.idx == b.idx).unwrap();
799        assert_eq!(completed.state, RangeState::Complete);
800    }
801
802    #[test]
803    fn lease_remaining_shrinks_with_the_ceiling() {
804        let s = Scheduler::from_ranges(&plan_sequential(Some(1000)));
805        let (lease, _) = s.acquire().unwrap();
806        assert_eq!(lease.remaining(), 1000);
807        lease.publish_progress(400);
808        assert_eq!(lease.remaining(), 600);
809        s.set_end(lease.idx, 499);
810        assert_eq!(lease.remaining(), 100);
811        lease.publish_progress(500);
812        assert_eq!(lease.remaining(), 0);
813    }
814}