Skip to main content

coven_sync/
file_upload_pool.rs

1//! The upload worker's runs (§17.9, E7). While uploads can run, a run keeps
2//! as many attempts in flight as the upload limit allows, in each of two
3//! lanes: bound uploads, which writes wait for, and trailing ones (§17.4).
4//! As each one ends, the next eligible upload of its lane starts, files
5//! queued meanwhile included; no upload waits for the others to finish, and
6//! no bound upload for a trailing one. Pausing holds the trailing lane only.
7//! An app's early retry joins the run in progress.
8
9use crate::{
10    files::file_failure::upload_subject, files::FilesInner, files::TrailingHold,
11    retry_waits::Ending, NetworkOwner, PendingRecord, Retry, SyncError,
12};
13use coven_database::{DbError, FileUpload, RetrySubject};
14use coven_foundation::locks::{lock_unpoisoned, read_unpoisoned};
15use coven_storage::StorageFailure;
16use futures_util::{future::BoxFuture, stream::FuturesUnordered, StreamExt};
17use std::{
18    collections::{BTreeMap, BTreeSet, VecDeque},
19    sync::atomic::Ordering,
20    time::Duration,
21};
22use tokio::sync::oneshot;
23
24/// Result of an app retry (`retry_uploads_now`), or of one upload run.
25/// Individual failures stay in `pending()`.
26#[derive(Debug, PartialEq, Eq)]
27pub enum DrainOutcome {
28    /// Every upload the retry covered was attempted; this many stored a
29    /// file.
30    Drained {
31        /// Uploads whose bytes storage confirmed for files waiting for them.
32        /// One whose move failed or was cancelled while it was sent stored no
33        /// file, and doesn't count (§17.3).
34        uploaded: usize,
35    },
36    /// No waiting upload exists.
37    QueueEmpty,
38    /// Uploads were paused: no trailing upload was tried. Each bound upload
39    /// the retry covered was, since the pause holds none (§17.4).
40    Paused,
41    /// The network is metered and the app moves files only on unmetered
42    /// networks: no trailing upload was tried. Each bound upload the retry
43    /// covered was, since the setting holds none (§17.9).
44    Metered,
45    /// Sync is stopped: no upload was tried (§17.4).
46    NotSyncing,
47}
48
49/// An app's early retry, waiting for its answer. A run takes it when it
50/// next reads the queue: the retry then covers every upload that can ever
51/// retry, each eligible whatever its wait, and is answered once each covered
52/// upload's attempt has ended.
53pub(super) struct AppRetry {
54    /// The queue ids it covers, fixed when a run takes it; each leaves as its
55    /// attempt ends. `None` until taken.
56    covers: Option<BTreeSet<i64>>,
57    /// How many covered uploads were stored.
58    uploaded: usize,
59    /// Its place among the retries asked, which says whether a run's reads
60    /// of the queue and the holds came after it.
61    asked: u64,
62    reply: oneshot::Sender<Result<DrainOutcome, SyncError>>,
63}
64
65/// The answer to an app's early retry: how its covered uploads' attempts
66/// ended, or nothing if the worker stopped with the store first.
67pub(super) struct UploadRetryAnswer(oneshot::Receiver<Result<DrainOutcome, SyncError>>);
68
69impl UploadRetryAnswer {
70    /// Wait for the answer; the store closed if the worker stopped first.
71    pub(super) async fn answered(self) -> Result<DrainOutcome, SyncError> {
72        self.0.await.unwrap_or_else(|_| Err(DbError::StoreClosed.into()))
73    }
74
75    /// The answer if the run already gave it; `None` when it did not.
76    #[cfg(test)]
77    pub(super) fn try_answered(&mut self) -> Option<Result<DrainOutcome, SyncError>> {
78        self.0.try_recv().ok()
79    }
80}
81
82/// Why the upload worker ended, which each app retry from then on hears.
83#[derive(Debug)]
84pub(super) enum WorkerEnd {
85    /// It panicked with this bug, which the store's bug sink heard: each
86    /// retry hears it as `SyncError::WorkerFailed` (E15).
87    Panicked(coven_foundation::task::Bug),
88    /// The store closed under it.
89    Closed,
90    /// What it watches failed, as when the handle fails, with one shared
91    /// cause.
92    Failed(crate::LocalFailure, std::sync::Arc<dyn std::error::Error + Send + Sync>),
93}
94
95impl WorkerEnd {
96    /// The end of a worker that stopped on `error`, the failure of what it
97    /// watches.
98    pub(super) fn of(error: SyncError) -> Self {
99        match error {
100            SyncError::Database(DbError::StoreClosed) => Self::Closed,
101            error => {
102                let (failure, source) = error.into_shared();
103                Self::Failed(failure, source)
104            }
105        }
106    }
107
108    fn error(&self) -> SyncError {
109        match self {
110            Self::Panicked(bug) => SyncError::WorkerFailed(bug.clone()),
111            Self::Closed => DbError::StoreClosed.into(),
112            Self::Failed(failure, source) => {
113                SyncError::Failed { failure: *failure, source: source.clone() }
114            }
115        }
116    }
117}
118
119/// The lane an upload runs in (§17.4): a bound upload's write and every
120/// later one wait for it, so neither the pause nor trailing uploads hold it.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub(super) enum Lane {
123    Bound,
124    Trailing,
125}
126
127impl Lane {
128    pub(super) fn of(upload: &FileUpload) -> Self {
129        match upload.holds {
130            Some(_) => Self::Bound,
131            None => Self::Trailing,
132        }
133    }
134    pub(super) fn index(self) -> usize {
135        match self {
136            Self::Bound => 0,
137            Self::Trailing => 1,
138        }
139    }
140}
141
142/// Why no upload may start now.
143#[derive(Clone, Copy, Debug)]
144enum Blocked {
145    Closed,
146    /// Sync is stopped, which stops both lanes (§17.4).
147    NotSyncing,
148    NoStorage,
149    /// The pause or a metered network holds trailing uploads, and no bound
150    /// upload waits.
151    Held(TrailingHold),
152    /// Files wait for the sequence to catch up (§5.4).
153    CatchUp,
154    /// The provider asked every consumer to wait this long (§6.7).
155    Cooling(Duration),
156    /// A connection change waits for the uploads in flight to end.
157    Changing,
158}
159
160impl Blocked {
161    /// What an app retry learns; `None` keeps it for the run after a
162    /// connection change, which serves it on the new connection.
163    fn retry_answer(self) -> Option<Result<DrainOutcome, SyncError>> {
164        Some(match self {
165            Self::Closed => Err(DbError::StoreClosed.into()),
166            Self::NotSyncing => Ok(DrainOutcome::NotSyncing),
167            Self::NoStorage => Err(SyncError::NoStorage),
168            Self::Held(TrailingHold::Paused) => Ok(DrainOutcome::Paused),
169            Self::Held(TrailingHold::Metered) => Ok(DrainOutcome::Metered),
170            // As for an app write (§12.3).
171            Self::Held(TrailingHold::MemberKeysChanging) => Err(DbError::MemberKeysChanging.into()),
172            Self::CatchUp => Err(StorageFailure::CatchUpRequired.into()),
173            // An app retry overrides ordinary backoff, never a provider cooldown.
174            Self::Cooling(remaining) => Err(StorageFailure::RateLimited
175                .with_source(coven_storage::CoolingDown { remaining })
176                .into()),
177            Self::Changing => return None,
178        })
179    }
180
181    /// What the run itself returns. Automatic work waits out a cooldown, a
182    /// connection change and a self-replacement's write pause without
183    /// failing.
184    fn run_outcome(self, uploaded: usize) -> Result<DrainOutcome, SyncError> {
185        match self {
186            Self::Cooling(_) | Self::Changing | Self::Held(TrailingHold::MemberKeysChanging) => {
187                Ok(DrainOutcome::Drained { uploaded })
188            }
189            blocked => blocked.retry_answer().unwrap_or(Ok(DrainOutcome::Drained { uploaded })),
190        }
191    }
192}
193
194type Attempt<'a> = BoxFuture<'a, (i64, Result<super::file_upload::Attempted, SyncError>)>;
195
196/// One run's scheduling state.
197#[derive(Default)]
198struct Run<'a> {
199    /// Attempts in flight, each yielding its queue id and result.
200    attempts: FuturesUnordered<Attempt<'a>>,
201    /// The network reports seen as each attempt in flight started.
202    started: BTreeMap<i64, u64>,
203    /// The lane of each attempt in flight.
204    lanes: BTreeMap<i64, Lane>,
205    /// The bound uploads of the last read of the queue, whose retries wake
206    /// the run while trailing uploads are held.
207    bound: BTreeSet<i64>,
208    /// A commit since that read may have queued a bound upload.
209    committed: bool,
210    /// Eligible uploads from the last read of the queue, oldest first, not
211    /// started yet.
212    ready: VecDeque<FileUpload>,
213    /// Something may have made more uploads eligible since that read.
214    stale: bool,
215    /// Uploads whose attempt failed after a report arrived while it ran,
216    /// with which waits those reports end; they end the wait each failed
217    /// into.
218    crossed: BTreeMap<RetrySubject, Ending>,
219    /// Files this run stored.
220    uploaded: usize,
221}
222
223impl Run<'_> {
224    fn in_flight(&self, lane: Lane) -> usize {
225        self.lanes.values().filter(|of| **of == lane).count()
226    }
227
228    /// The connections `lane` uses: one for each attempt in flight, and
229    /// those its attempts took beside their own for parts sent together.
230    fn used(&self, lane: Lane, owner: &FilesInner) -> usize {
231        self.in_flight(lane) + owner.connections_beside(lane)
232    }
233
234    /// Whether an upload of some lane a hold leaves open could start: a
235    /// lane with room, and for the bound lane, a bound upload the last read
236    /// found or a commit since may have queued.
237    fn room(&self, limit: usize, held: bool, owner: &FilesInner) -> bool {
238        (self.used(Lane::Bound, owner) < limit && (self.committed || !self.bound.is_empty()))
239            || (!held && self.used(Lane::Trailing, owner) < limit)
240    }
241}
242
243impl FilesInner {
244    /// Queue an app retry for the upload worker, and wake it. A worker that
245    /// panicked serves none: the retry fails with its bug, as
246    /// `SyncError::WorkerFailed`.
247    pub(super) fn ask_retry(&self) -> Result<UploadRetryAnswer, SyncError> {
248        let (reply, answer) = oneshot::channel();
249        {
250            // Closing sets the flag before clearing this list under its lock,
251            // and the worker's panic sets its bug before answering it, so a
252            // retry queued here is either cleared, answered or refused.
253            let mut retries = lock_unpoisoned(&self.app_retries);
254            self.check_open()?;
255            if let Some(ended) = self.worker_ended.get() {
256                return Err(ended.error());
257            }
258            let asked = self.retry_asked();
259            retries.push(AppRetry { covers: None, uploaded: 0, asked, reply });
260        }
261        self.upload_wake.notify_one();
262        Ok(UploadRetryAnswer(answer))
263    }
264
265    /// The upload worker ended, on a panic or on the failure of what it
266    /// watches: no run will serve an app retry, so each waiting one is
267    /// answered with why, as each asked from now on is (E15).
268    pub(super) fn worker_ended(&self, end: WorkerEnd) {
269        let mut retries = lock_unpoisoned(&self.app_retries);
270        let ended = self.worker_ended.get_or_init(|| end);
271        for retry in retries.drain(..) {
272            #[expect(
273                clippy::let_underscore_must_use,
274                reason = "a retry whose caller stopped waiting needs no answer"
275            )]
276            let _ = retry.reply.send(Err(ended.error()));
277        }
278    }
279
280    /// Answer every queued app retry with this; `None` keeps them all, as
281    /// they are, for the next run.
282    fn answer_retries(&self, answer: impl Fn() -> Option<Result<DrainOutcome, SyncError>>) {
283        let mut retries = lock_unpoisoned(&self.app_retries);
284        if answer().is_none() {
285            return;
286        }
287        for retry in retries.drain(..) {
288            if let Some(result) = answer() {
289                // The caller may have stopped waiting; nothing is lost.
290                #[expect(
291                    clippy::let_underscore_must_use,
292                    reason = "a retry whose caller stopped waiting needs no answer"
293                )]
294                let _ = retry.reply.send(result);
295            }
296        }
297    }
298
299    /// A test's run of the upload worker that begins with an app retry of its
300    /// own, whose answer it returns.
301    #[cfg(test)]
302    pub(crate) async fn drain_uploads_retrying(&self) -> Result<DrainOutcome, SyncError> {
303        let mut own = self.ask_retry()?;
304        let run = self.drain_uploads().await;
305        // Unanswered, its covered uploads were not all attempted: the run
306        // failed, or a connection change stopped it.
307        own.try_answered().unwrap_or(run)
308    }
309
310    /// One run of the upload worker: start uploads while they can run,
311    /// keeping the upload limit's number in flight, and end once none is in
312    /// flight and none can start. A run that fails on a bug returns it to
313    /// the worker, which reports it; each waiting app retry hears it as
314    /// `WorkerFailed` (E15).
315    pub(crate) async fn drain_uploads(&self) -> Result<DrainOutcome, SyncError> {
316        use coven_foundation::task::CarriesBug;
317        let _run = self.drain.lock().await;
318        let result = self.serve_uploads().await;
319        match result {
320            Ok(outcome) => Ok(outcome),
321            Err(error) if lock_unpoisoned(&self.app_retries).is_empty() => Err(error),
322            Err(error) => {
323                if let Some(bug) = error.bug().cloned() {
324                    self.answer_retries(|| Some(Err(SyncError::WorkerFailed(bug.clone()))));
325                    return Err(error);
326                }
327                // Every waiting app retry learns the failure that ended the run.
328                let (failure, source) = error.into_shared();
329                self.answer_retries(|| {
330                    Some(Err(SyncError::Failed { failure, source: source.clone() }))
331                });
332                Err(SyncError::Failed { failure, source })
333            }
334        }
335    }
336
337    /// Why no upload may start now, from the owner's current state.
338    fn blocked(&self) -> Option<Blocked> {
339        if self.check_open().is_err() {
340            Some(Blocked::Closed)
341        } else if !self.waits.started() {
342            Some(Blocked::NotSyncing)
343        } else if read_unpoisoned(&self.storage).is_none() {
344            Some(Blocked::NoStorage)
345        } else if self.changing.load(Ordering::Acquire) > 0 {
346            Some(Blocked::Changing)
347        } else if !self.send.files_allowed() {
348            Some(Blocked::CatchUp)
349        } else {
350            self.waits.cooldown().map(Blocked::Cooling)
351        }
352    }
353
354    fn end_blocked(&self, blocked: Blocked, uploaded: usize) -> Result<DrainOutcome, SyncError> {
355        self.answer_retries(|| blocked.retry_answer());
356        blocked.run_outcome(uploaded)
357    }
358
359    /// End a run that a hold on trailing uploads stops. Each app retry a
360    /// read of the queue covered is answered with the hold: a trailing upload
361    /// it covers was not tried, and each bound one was, or none waited. One
362    /// asked since could cover a bound upload queued after that read, which
363    /// the hold doesn't stop, or come after the hold ended, so it waits for
364    /// the next run, which reads the queue and the hold again.
365    fn end_held(
366        &self,
367        hold: TrailingHold,
368        uploaded: usize,
369        covered: impl Fn(&AppRetry) -> bool,
370    ) -> Result<DrainOutcome, SyncError> {
371        let blocked = Blocked::Held(hold);
372        let mut retries = lock_unpoisoned(&self.app_retries);
373        let (answered, asked_since): (Vec<_>, Vec<_>) =
374            retries.drain(..).partition(|retry| covered(retry));
375        *retries = asked_since;
376        let waiting = !retries.is_empty();
377        drop(retries);
378        for retry in answered {
379            if let Some(answer) = blocked.retry_answer() {
380                #[expect(
381                    clippy::let_underscore_must_use,
382                    reason = "a retry whose caller stopped waiting needs no answer"
383                )]
384                let _ = retry.reply.send(answer);
385            }
386        }
387        if waiting {
388            self.upload_wake.notify_one();
389        }
390        blocked.run_outcome(uploaded)
391    }
392
393    #[cfg(test)]
394    fn pass_blocked_end_gate(&self) {
395        let gate = lock_unpoisoned(&self.blocked_end_gate).clone();
396        if let Some(gate) = gate {
397            gate();
398        }
399    }
400
401    async fn serve_uploads(&self) -> Result<DrainOutcome, SyncError> {
402        // Observe before reading the queue, so nothing that happens while it
403        // is read is missed.
404        let mut changes = self.database.queued_work_changes();
405        let mut sending = self.send.watch();
406        let mut reports = self.waits.network_reports();
407        let mut rearmed = self.waits.rearmed();
408        let mut syncing = self.waits.syncing();
409        let mut network = self.network.clone();
410        if let Some(blocked @ Blocked::Closed) = self.blocked() {
411            return self.end_blocked(blocked, 0);
412        }
413        // Uploads no file waits for any more go first, paused, stopped or
414        // with no storage too; an attempt in flight retires its own as it
415        // ends (§17.9). One holding a provider session has it aborted
416        // first, which needs storage and sync started.
417        self.end_abandoned_sessions().await?;
418        for id in self
419            .database
420            .retire_unlinked(self.database.uploads_in_flight().current(), upload_subject)
421            .await?
422        {
423            self.waits.forget(RetrySubject::Upload(id));
424            self.forget_connection_wait(RetrySubject::Upload(id));
425        }
426        if let Some(blocked @ (Blocked::NotSyncing | Blocked::NoStorage)) = self.blocked() {
427            return self.end_blocked(blocked, 0);
428        }
429        // The persisted settings govern; parts check these copies between
430        // sends. A change of either waits until this run has acted on what
431        // it read, so the run cannot answer a retry asked after a resume
432        // with the pause.
433        let change = self.lock_hold_change().await;
434        // The app retries asked before the reads below, which cover them.
435        let read_after = self.retries_asked();
436        let paused = self.database.uploads_paused().await?;
437        self.set_paused(paused);
438        let unmetered_only = self.database.unmetered_only().await?;
439        self.unmetered_only.store(unmetered_only, Ordering::Release);
440        #[cfg(test)]
441        {
442            let gate = self.take_settings_read_gate();
443            if let Some((read, resume)) = gate {
444                read.send(()).expect("the test waits for the read");
445                resume.await.expect("the test resumes the run");
446            }
447        }
448        self.read_writes_paused().await?;
449        let work = self.database.upload_work::<PendingRecord>(upload_subject).await?;
450        // The pause, a self-replacement's write pause and a metered network
451        // hold trailing uploads only (§12.3, §17.4, §17.9).
452        if let Some(hold) = self.trailing_hold() {
453            if work.iter().all(|(item, _)| Lane::of(item) == Lane::Trailing) {
454                #[cfg(test)]
455                self.pass_blocked_end_gate();
456                return self.end_held(hold, 0, |retry| retry.asked < read_after);
457            }
458        }
459        drop(change);
460        if work.is_empty() {
461            self.answer_retries(|| Some(Ok(DrainOutcome::QueueEmpty)));
462            return Ok(DrainOutcome::QueueEmpty);
463        }
464        let mut run = Run::default();
465        let mut work = Some(work);
466        loop {
467            if let Some(blocked) = self.blocked() {
468                if run.attempts.is_empty() {
469                    #[cfg(test)]
470                    self.pass_blocked_end_gate();
471                    return self.end_blocked(blocked, run.uploaded);
472                }
473            } else {
474                self.act_on_reports(&mut run).await;
475                let limit = self.transfers.limits().uploads.get();
476                if work.is_some() || (run.stale && run.room(limit, self.trailing_held(), self)) {
477                    // The first read went stale if a report ended waits since.
478                    let work = match work.take() {
479                        Some(work) if !run.stale => work,
480                        _ => {
481                            // A self-replacement's pause starts and ends
482                            // with a commit, which made the read stale.
483                            self.read_writes_paused().await?;
484                            self.database.upload_work::<PendingRecord>(upload_subject).await?
485                        }
486                    };
487                    self.read_ready(&mut run, work);
488                }
489                self.start_ready(&mut run, limit);
490                if run.attempts.is_empty() && run.ready.is_empty() {
491                    // Held, the run ends once nothing it read is left to
492                    // start and no bound upload could have been queued since.
493                    let held = self.trailing_held();
494                    if held && (!run.stale || !run.room(limit, held, self)) {
495                        #[cfg(test)]
496                        self.pass_blocked_end_gate();
497                        // As with the read above, a setting's change waits
498                        // while the run acts on the hold it saw; after a
499                        // resume since the check, the run serves uploads
500                        // again.
501                        let _change = self.lock_hold_change().await;
502                        self.read_writes_paused().await?;
503                        if let Some(hold) = self.trailing_hold() {
504                            return self
505                                .end_held(hold, run.uploaded, |retry| retry.covers.is_some());
506                        }
507                        run.stale = true;
508                        continue;
509                    }
510                    if !run.stale
511                        && lock_unpoisoned(&self.app_retries)
512                            .iter()
513                            .all(|retry| retry.covers.is_some())
514                    {
515                        // Nothing a taken retry covers is left to start.
516                        for retry in lock_unpoisoned(&self.app_retries).drain(..) {
517                            #[expect(
518                                clippy::let_underscore_must_use,
519                                reason = "a retry whose caller stopped waiting needs no answer"
520                            )]
521                            let _ = retry
522                                .reply
523                                .send(Ok(DrainOutcome::Drained { uploaded: retry.uploaded }));
524                        }
525                        return Ok(DrainOutcome::Drained { uploaded: run.uploaded });
526                    }
527                    run.stale = true;
528                    continue;
529                }
530            }
531            // A due retry matters only while an upload could start; it
532            // would wake at once for a wait an attempt in flight serves.
533            let open = self.blocked().is_none()
534                && run.room(self.transfers.limits().uploads.get(), self.trailing_held(), self);
535            let retry = if open { self.next_upload_retry(&run.bound) } else { None };
536            tokio::select! {
537                Some((id, result)) = run.attempts.next(), if !run.attempts.is_empty() => {
538                    self.finished(&mut run, id, result?);
539                }
540                () = self.upload_wake.notified() => run.stale = true,
541                // A connection a part took beside its attempt's ended: an
542                // upload waiting for one may start.
543                () = self.connection_freed() => {}
544                change = changes.next() => {
545                    change?;
546                    run.stale = true;
547                    run.committed = true;
548                }
549                _ = sending.changed() => run.stale = true,
550                _ = syncing.changed() => run.stale = true,
551                _ = reports.changed() => {}
552                () = network.changed() => run.stale = true,
553                _ = rearmed.changed() => {}
554                () = async {
555                    match retry {
556                        Some(retry) => retry.await,
557                        None => std::future::pending().await,
558                    }
559                } => run.stale = true,
560            }
561        }
562    }
563
564    /// The run's own work failed with `error`, which no caller hears: a bug
565    /// it carries goes to the sink, and any other failure is logged (E15).
566    fn failed_on_its_own(&self, what: &'static str, error: &SyncError) {
567        if !coven_foundation::task::report_carried(self.database.bugs().as_ref(), error) {
568            tracing::error!(?error, "{what}");
569        }
570    }
571
572    /// Act on reports for upload waits, as each plan does (§6.7).
573    /// A failure keeps every wait and the report, for the next plan.
574    async fn act_on_reports(&self, run: &mut Run<'_>) {
575        if !run.crossed.is_empty() {
576            let crossed = std::mem::take(&mut run.crossed);
577            match self.waits.end_waits_after_report(NetworkOwner::Uploads, &crossed).await {
578                Ok(()) => run.stale = true,
579                Err(error) => {
580                    self.failed_on_its_own("ending reported upload waits failed", &error);
581                    run.crossed = crossed;
582                }
583            }
584        }
585        match self.waits.end_reported_waits(NetworkOwner::Uploads).await {
586            Ok(true) => run.stale = true,
587            Ok(false) => {}
588            Err(error) => self.failed_on_its_own("ending reported upload waits failed", &error),
589        }
590    }
591
592    /// Take the queue just read as the run's next uploads: each one not in
593    /// flight that may retry and whose wait is over, or that an app retry
594    /// covers. App retries not yet taken cover every upload that may retry.
595    /// While trailing uploads are held, only bound ones are taken.
596    fn read_ready(&self, run: &mut Run<'_>, work: Vec<(FileUpload, Option<PendingRecord>)>) {
597        run.stale = false;
598        run.committed = false;
599        run.bound = work
600            .iter()
601            .filter(|(item, _)| Lane::of(item) == Lane::Bound)
602            .map(|(item, _)| item.id)
603            .collect();
604        let held = self.trailing_held();
605        let retryable: BTreeSet<i64> = work
606            .iter()
607            .filter(|(_, record)| {
608                record.as_ref().is_none_or(|record| record.retry() != Retry::Never)
609            })
610            .map(|(item, _)| item.id)
611            .collect();
612        let forced: BTreeSet<i64> = {
613            let mut retries = lock_unpoisoned(&self.app_retries);
614            // A retry whose caller stopped waiting is dropped.
615            retries.retain(|retry| !retry.reply.is_closed());
616            for retry in retries.iter_mut() {
617                retry.covers.get_or_insert_with(|| retryable.clone());
618            }
619            retries.iter().flat_map(|retry| retry.covers.iter().flatten().copied()).collect()
620        };
621        self.answer_covered();
622        run.ready.clear();
623        for (item, record) in work {
624            let subject = RetrySubject::Upload(item.id);
625            if !retryable.contains(&item.id) || (held && Lane::of(&item) == Lane::Trailing) {
626                continue;
627            }
628            // An upload in flight, or one an app retry starts whatever its
629            // wait, was evaluated: its ended wait wakes nothing.
630            if run.started.contains_key(&item.id) {
631                self.waits.seen(subject);
632            } else if forced.contains(&item.id) {
633                self.waits.seen(subject);
634                run.ready.push_back(item);
635            } else if self.retry_due(&item, record.as_ref()) {
636                run.ready.push_back(item);
637            }
638        }
639    }
640
641    /// Start ready uploads, oldest first, until each lane has the limit's
642    /// number in flight. A trailing upload taken before a hold began waits
643    /// for the run's next read once the hold ends.
644    fn start_ready<'a>(&'a self, run: &mut Run<'a>, limit: usize) {
645        let held = self.trailing_held();
646        let mut waiting = VecDeque::new();
647        while let Some(item) = run.ready.pop_front() {
648            let lane = Lane::of(&item);
649            if lane == Lane::Trailing && held {
650                continue;
651            }
652            if run.used(lane, self) >= limit {
653                waiting.push_back(item);
654                continue;
655            }
656            let id = item.id;
657            run.lanes.insert(id, lane);
658            self.set_lane_attempts(lane, run.in_flight(lane));
659            run.started.insert(id, self.waits.network_reported());
660            // A resumed session starts where the provider confirmed it.
661            let progress = coven_database::TransferProgress {
662                bytes_transferred: item.progress.map_or(0, |progress| progress.bytes_confirmed),
663                bytes_total: item.bytes_total,
664            };
665            let sending = self.database.uploads_in_flight().start(
666                item.location.uploader(),
667                item.location.object(),
668                item.files.clone(),
669                progress,
670            );
671            run.attempts.push(Box::pin(async move {
672                let attempted = self.attempt_upload(item, sending.meter()).await;
673                sending.end(match &attempted {
674                    Ok(attempted) => attempted.transfer_end(),
675                    // The attempt failed, and so did recording its failure.
676                    Err(_) => coven_database::TransferEnd::Failed,
677                });
678                (id, attempted)
679            }));
680        }
681        // An upload waiting for a connection takes the next one freed,
682        // before a part beside an attempt does.
683        for lane in [Lane::Bound, Lane::Trailing] {
684            let waits = waiting.iter().any(|item| Lane::of(item) == lane);
685            self.set_lane_waiting(lane, waits);
686        }
687        run.ready = waiting;
688    }
689
690    /// One attempt ended: its slot is free, and the app retries covering it
691    /// count it. Only an upload that stored a file counts as uploaded; one
692    /// that retired because no file waited for it any more stored none.
693    fn finished(&self, run: &mut Run<'_>, id: i64, attempted: super::file_upload::Attempted) {
694        use super::file_upload::Attempted;
695        if let Some(lane) = run.lanes.remove(&id) {
696            self.set_lane_attempts(lane, run.in_flight(lane));
697        }
698        let started = run.started.remove(&id);
699        let stored = attempted == Attempted::Stored;
700        match attempted {
701            Attempted::Stored => run.uploaded += 1,
702            Attempted::Retired => {}
703            Attempted::Failed | Attempted::Held => {
704                if let Some(ending) = started.and_then(|seen| self.waits.reports_since(seen)) {
705                    run.crossed.insert(RetrySubject::Upload(id), ending);
706                }
707            }
708        }
709        for retry in lock_unpoisoned(&self.app_retries).iter_mut() {
710            if let Some(covers) = &mut retry.covers {
711                if covers.remove(&id) && stored {
712                    retry.uploaded += 1;
713                }
714            }
715        }
716        self.answer_covered();
717    }
718
719    /// Answer each taken app retry whose covered uploads were all attempted.
720    fn answer_covered(&self) {
721        let mut retries = lock_unpoisoned(&self.app_retries);
722        let mut kept = Vec::new();
723        for retry in retries.drain(..) {
724            match &retry.covers {
725                Some(covers) if covers.is_empty() => {
726                    #[expect(
727                        clippy::let_underscore_must_use,
728                        reason = "a retry whose caller stopped waiting needs no answer"
729                    )]
730                    let _ =
731                        retry.reply.send(Ok(DrainOutcome::Drained { uploaded: retry.uploaded }));
732                }
733                _ => kept.push(retry),
734            }
735        }
736        *retries = kept;
737    }
738}