Skip to main content

coven_sync/
retry_answer.rs

1//! The answer to the person's retry (§6.7; IO `RetryAnswer`).
2//!
3//! What decides it is `coven_retry_effect`'s row: whether a started
4//! connection will plan, whether a failure only new credentials, an update
5//! or a reset can clear holds every pass, and the provider cooldown holding
6//! them. Each step below changes that row and acts on the requests in one
7//! step on the store's writer (`Database::change_retry_state`), so the
8//! answer a request gets is the effect the row shows when it is made (IO
9//! `ask_answers_by_effect`), and a request, stopping and a pass taking its
10//! requests each happen at once (IO `never_stranded`). Each step writes the
11//! cooldown as it stands then, and the row keeps no cooldown while sync is
12//! stopped, since no owner would write its end. The cooldown's end is a
13//! write too: the connection's owner wakes when it ends, and its next step
14//! writes it.
15//!
16//! A retry request is a report, so it takes the next report number. A
17//! request the row answers at once, stopped, held or cooling down, is
18//! answered so; any other waits for a pass. The connection's owner, the
19//! connection's first attempt and then its sync loop, acts on reports when
20//! it plans between passes. A pass begins only while the row lets it, and
21//! takes every waiting request the owner has acted on, answering them when
22//! it ends; a request made after the owner last acted waits for a later
23//! pass. When a failure or cooldown begins to hold the pass, the owner's
24//! next plan answers the requests it acted on at once. Stopping sync
25//! answers every waiting request `NotSyncing`. A pass that ends without
26//! answering the requests it took, because it panicked or was dropped,
27//! leaves them waiting again; an owner that panicked stops sync and answers
28//! every waiting request with its bug, as `SyncError::WorkerFailed` (E15).
29//!
30//! A failed write has failed the store's handle, which every call and live
31//! query then reports, and no later step can commit; the requests waiting
32//! then are answered with that failure. A write that panicked fails alone:
33//! its bug goes to the step's caller, or to the sink when the connection's
34//! owner wrote on its own, and that owner stops (E15).
35
36use crate::{NetworkOwner, PassFailure, RetryWaits, SyncFailure};
37use coven_database::{DbError, RetryState, RetrySync};
38use coven_foundation::clock::ClockRef;
39use coven_foundation::locks::lock_unpoisoned;
40use std::{
41    future::Future,
42    pin::Pin,
43    sync::{Arc, Mutex, Weak},
44    task::{Context, Poll},
45    time::SystemTime,
46};
47use tokio::sync::oneshot;
48
49/// How a step commits: a change of the retry state alone, or a start of
50/// sync, which `coven_sync_status` counts in the same transaction.
51#[derive(Clone, Copy)]
52enum Step {
53    Change,
54    Start,
55}
56
57/// What the first sync pass after the person's retry came to (E5).
58#[derive(Clone, Debug)]
59pub enum RetryOutcome {
60    /// The pass reached its read end.
61    Synced {
62        /// The store's clock when the pass finished.
63        finished_at: SystemTime,
64    },
65    /// The pass failed, or a failure only new credentials, an update or a
66    /// reset can clear holds every pass; the failure says which.
67    Failed(PassFailure),
68    /// A provider cooldown holds every pass until then. The pass runs once
69    /// it ends, with no further request.
70    CoolingDown {
71        /// When the cooldown ends, by the store's clock.
72        until: SystemTime,
73    },
74    /// Sync is stopped or the handle closed, so no pass follows the request.
75    NotSyncing,
76}
77
78impl From<PassFailure> for RetryOutcome {
79    fn from(failure: PassFailure) -> Self {
80        Self::Failed(failure)
81    }
82}
83
84/// What the person's retry would do if made now (E5), as
85/// `coven_retry_effect` holds it: run a pass, or be answered at once
86/// without one. An app offers a retry only while it is `Pass`; the status
87/// says why it is not.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum RetryEffect {
90    /// A pass runs for the retry, and its end answers it.
91    Pass,
92    /// A provider cooldown holds every pass until then, by the store's
93    /// clock: the retry is answered `CoolingDown` at once, and the pass runs
94    /// once the cooldown ends.
95    CoolingDown {
96        /// When the cooldown ends, by the store's clock.
97        until: SystemTime,
98    },
99    /// A failure only new credentials, an update or a reset can clear holds
100    /// every pass: the retry is answered `Failed` with it at once. The
101    /// status shows the failure.
102    Failed,
103    /// Sync is stopped or the handle closed: the retry is answered
104    /// `NotSyncing` at once.
105    NotSyncing,
106}
107
108impl From<RetryState> for RetryEffect {
109    /// A held failure answers before a cooldown: the cooldown's end would
110    /// not let the pass run. `coven_retry_effect.effect` is computed alike.
111    fn from(state: RetryState) -> Self {
112        match (state.sync, state.held) {
113            (RetrySync::Stopped, _) => Self::NotSyncing,
114            (RetrySync::Started { .. }, true) => Self::Failed,
115            (RetrySync::Started { cooling_until: Some(until) }, false) => {
116                Self::CoolingDown { until }
117            }
118            (RetrySync::Started { cooling_until: None }, false) => Self::Pass,
119        }
120    }
121}
122
123impl RetryEffect {
124    /// Read `coven_retry_effect`, as an app's live query does:
125    /// `handle.subscribe(RetryEffect::read)`.
126    pub fn read(sql: coven_database::SqlReadContext<'_>) -> Result<Self, DbError> {
127        let (effect, until) =
128            sql.query_row("SELECT effect,cooling_until FROM coven_retry_effect", [], |row| {
129                Ok((row.get::<_, String>(0)?, row.get::<_, Option<i64>>(1)?))
130            })?;
131        Ok(match (effect.as_str(), until) {
132            ("pass", None) => Self::Pass,
133            ("cooling_down", Some(until)) => {
134                Self::CoolingDown { until: coven_database::stored_time(until)? }
135            }
136            ("failed", _) => Self::Failed,
137            ("not_syncing", None) => Self::NotSyncing,
138            _ => return Err(DbError::Invariant { what: "the retry effect is one coven computes" }),
139        })
140    }
141
142    /// Follow `coven_retry_effect` with a live query, as an app does.
143    #[cfg(any(test, feature = "test-utils"))]
144    pub fn follow(
145        database: &coven_database::Database,
146    ) -> coven_database::test_utils::Followed<Self, DbError> {
147        coven_database::test_utils::Followed::new(database.subscribe(Self::read))
148    }
149}
150
151/// Resolves when the first sync pass after a retry request ends (E5).
152/// Dropping it leaves the retry in place.
153#[derive(Debug)]
154pub struct RetryAnswer(oneshot::Receiver<RetryOutcome>);
155
156impl Future for RetryAnswer {
157    type Output = RetryOutcome;
158
159    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<RetryOutcome> {
160        // Every waiting request is answered before its owner lets it go; a
161        // request is dropped unanswered only with the store's retry schedule
162        // itself, once the handle is gone.
163        Pin::new(&mut self.0).poll(context).map(|answer| answer.unwrap_or(RetryOutcome::NotSyncing))
164    }
165}
166
167/// Retry requests by report number, each with where its answer goes.
168type Waiting = Vec<(u64, oneshot::Sender<RetryOutcome>)>;
169
170/// Answer every request in `requests` with `outcome`.
171fn answer(requests: Waiting, outcome: &RetryOutcome) {
172    for (_, request) in requests {
173        #[expect(
174            clippy::let_underscore_must_use,
175            reason = "a person who stopped waiting needs no answer"
176        )]
177        let _ = request.send(outcome.clone());
178    }
179}
180
181/// Requests a pass took when it began, answered when it ends. A pass that
182/// panicked, or was dropped, before answering them leaves them waiting
183/// again, for its owner's ending to answer.
184#[must_use = "the pass answers its requests when it ends"]
185pub struct PassRequests {
186    taken: Waiting,
187    /// The schedule they wait in again, unless it is gone.
188    waits: Weak<RetryWaits>,
189}
190
191impl PassRequests {
192    /// Answer every request with how the pass ended.
193    pub fn answer(mut self, outcome: &RetryOutcome) {
194        answer(std::mem::take(&mut self.taken), outcome);
195    }
196}
197
198impl Drop for PassRequests {
199    fn drop(&mut self) {
200        if self.taken.is_empty() {
201            return;
202        }
203        let Some(waits) = self.waits.upgrade() else {
204            return;
205        };
206        lock_unpoisoned(&waits.requests).waiting.append(&mut self.taken);
207    }
208}
209
210/// Waiting requests by report number, and the failure the row says holds
211/// every pass: the error objects a `Failed` answer carries, which the row
212/// can't hold. Both change only in a step on the writer, once the row they
213/// go with commits.
214pub(crate) struct RetryRequests {
215    held: Option<PassFailure>,
216    waiting: Waiting,
217}
218
219impl RetryRequests {
220    /// No request waiting, and no failure held.
221    pub(crate) fn new() -> Self {
222        Self { held: None, waiting: Vec::new() }
223    }
224
225    /// The answer a request gets at once in `state`, none when a pass
226    /// answers it: the effect's.
227    fn at_once(&self, state: RetryState) -> Option<RetryOutcome> {
228        match RetryEffect::from(state) {
229            RetryEffect::Pass => None,
230            RetryEffect::CoolingDown { until } => Some(RetryOutcome::CoolingDown { until }),
231            RetryEffect::Failed => Some(RetryOutcome::Failed(
232                self.held.clone().expect("the row holds a failure only with its failure"),
233            )),
234            RetryEffect::NotSyncing => Some(RetryOutcome::NotSyncing),
235        }
236    }
237
238    /// The waiting requests the connection's owner has acted on, which
239    /// wait in `waits` again if a pass drops them unanswered.
240    fn acted_on(&mut self, waits: &Arc<RetryWaits>, handled: u64) -> PassRequests {
241        let (taken, later) = std::mem::take(&mut self.waiting)
242            .into_iter()
243            .partition::<Vec<_>, _>(|(number, _)| *number <= handled);
244        self.waiting = later;
245        PassRequests { taken, waits: Arc::downgrade(waits) }
246    }
247}
248
249/// When the live provider cooldown ends, by the store's clock as it was
250/// armed, so writing one cooldown again names the same instant. `None` also
251/// when the clock cannot represent that time; the pass then answers its
252/// requests once the cooldown ends.
253fn cooldown_until(schedule: &Mutex<super::WaitSchedule>, clock: &ClockRef) -> Option<SystemTime> {
254    let elapsed = clock.elapsed();
255    lock_unpoisoned(schedule)
256        .armed
257        .get(&coven_database::RetrySubject::Provider)
258        .filter(|armed| !armed.over(elapsed))?
259        .ends
260}
261
262/// The answer to the requests waiting when a step fails, carrying the typed
263/// error the step's caller gets, never its text (F.5), or `NotSyncing` once
264/// the store closed. A step fails only as its handle does, whose failure
265/// every later call reports from one shared cause, so the answer carries that
266/// same failure; as its runtime shuts down, saying how far it got; or as it
267/// panics, with the bug.
268fn failed(error: &DbError) -> RetryOutcome {
269    let failure = match error {
270        DbError::StoreClosed => return RetryOutcome::NotSyncing,
271        DbError::StatePersistence(cause) => DbError::StatePersistence(cause.clone()),
272        DbError::RollbackFailed => DbError::RollbackFailed,
273        DbError::CheckpointFailed(cause) => DbError::CheckpointFailed(cause.clone()),
274        DbError::StatisticsFailed(cause) => DbError::StatisticsFailed(cause.clone()),
275        DbError::WallTimer(cause) => DbError::WallTimer(cause.clone()),
276        DbError::StoreFailed(bug) => DbError::StoreFailed(bug.clone()),
277        DbError::Shutdown(shutdown) => DbError::Shutdown(*shutdown),
278        DbError::Bug(bug) => DbError::Bug(bug.clone()),
279        _ => DbError::Invariant { what: "a retry step fails only as its handle or runtime does" },
280    };
281    PassFailure::Failed { error: SyncFailure::Other(Arc::new(failure)) }.into()
282}
283
284impl RetryWaits {
285    /// The row with the cooldown live as the step that writes it commits,
286    /// while sync is started; while it is stopped, none.
287    pub(super) fn live_cooldown(&self) -> impl FnOnce(RetryState) -> RetryState + Send + 'static {
288        let (schedule, clock) = (self.state.clone(), self.clock.clone());
289        move |state| match state.sync {
290            RetrySync::Stopped => state,
291            RetrySync::Started { .. } => RetryState {
292                sync: RetrySync::Started { cooling_until: cooldown_until(&schedule, &clock) },
293                ..state
294            },
295        }
296    }
297
298    /// Change the row with `change` and the live cooldown, then run `then`
299    /// with the requests and the committed state, in one step on the
300    /// writer. A failed write answers every waiting request with it.
301    async fn step<T: Send + 'static>(
302        self: &Arc<Self>,
303        change: impl FnOnce(RetryState) -> RetryState + Send + 'static,
304        then: impl FnOnce(&Arc<RetryWaits>, &mut RetryRequests, RetryState) -> T + Send + 'static,
305    ) -> Result<T, DbError> {
306        self.step_as(Step::Change, change, then).await
307    }
308
309    /// `step`, as `kind` commits it.
310    async fn step_as<T: Send + 'static>(
311        self: &Arc<Self>,
312        kind: Step,
313        change: impl FnOnce(RetryState) -> RetryState + Send + 'static,
314        then: impl FnOnce(&Arc<RetryWaits>, &mut RetryRequests, RetryState) -> T + Send + 'static,
315    ) -> Result<T, DbError> {
316        let Some(database) = &self.database else {
317            return Err(DbError::Invariant { what: "only a handle that writes the store syncs" });
318        };
319        let cooling = self.live_cooldown();
320        let waits = self.clone();
321        let change = move |state| cooling(change(state));
322        let then = move |state: RetryState| {
323            waits.syncing.send_if_modified(|syncing| {
324                let started = matches!(state.sync, RetrySync::Started { .. });
325                std::mem::replace(syncing, started) != started
326            });
327            then(&waits, &mut lock_unpoisoned(&waits.requests), state)
328        };
329        let result = match kind {
330            Step::Change => database.change_retry_state(change, then).await,
331            Step::Start => database.start_retry_state(change, then).await,
332        };
333        if let Err(error) = &result {
334            let waiting = std::mem::take(&mut lock_unpoisoned(&self.requests).waiting);
335            answer(waiting, &failed(error));
336        }
337        result
338    }
339
340    /// The person asked to retry everything now (§6.7): each owner ends
341    /// every ordinary wait it retries when it next plans, as for a network
342    /// report, answered or not, and the provider cooldown holds. Stopped,
343    /// held or cooling down, as the row says, answers at once; otherwise the
344    /// first pass that begins after the connection's owner acted on the
345    /// request answers it. A closed store answers `NotSyncing`: no pass
346    /// follows.
347    pub async fn retry_requested(self: &Arc<Self>) -> Result<RetryAnswer, DbError> {
348        let (answer, answered) = oneshot::channel();
349        let asked = self
350            .step(
351                |state| state,
352                move |waits, requests, state| {
353                    let number = waits.report(crate::retry_waits::Ending::Every);
354                    match requests.at_once(state) {
355                        None => requests.waiting.push((number, answer)),
356                        Some(outcome) => {
357                            #[expect(
358                                clippy::let_underscore_must_use,
359                                reason = "the receiver is still held here"
360                            )]
361                            let _ = answer.send(outcome);
362                        }
363                    }
364                },
365            )
366            .await;
367        match asked {
368            Ok(()) => Ok(RetryAnswer(answered)),
369            Err(DbError::StoreClosed) => {
370                let (answer, answered) = oneshot::channel();
371                #[expect(
372                    clippy::let_underscore_must_use,
373                    reason = "the receiver is still held here"
374                )]
375                let _ = answer.send(RetryOutcome::NotSyncing);
376                Ok(RetryAnswer(answered))
377            }
378            Err(error) => Err(error),
379        }
380    }
381
382    /// A started connection will plan: requests wait for its passes, and
383    /// the failure its owner last found still holds them. Once a worker's
384    /// panic has halted sync (`sync_halted`), it fails with that bug as
385    /// `SyncError::WorkerFailed` until the store reopens (E15).
386    pub async fn sync_started(self: &Arc<Self>) -> Result<(), crate::SyncError> {
387        if let Some(bug) = self.halted() {
388            return Err(crate::SyncError::WorkerFailed(bug));
389        }
390        Ok(self
391            .step_as(
392                Step::Start,
393                |state| RetryState { sync: RetrySync::Started { cooling_until: None }, ..state },
394                |_, _, _| (),
395            )
396            .await?)
397    }
398
399    /// Sync stopped, or no connection will plan: no pass follows a waiting
400    /// request, so each is answered `NotSyncing`. A pass already running
401    /// answers the requests it took.
402    pub async fn sync_stopped(self: &Arc<Self>) -> Result<(), DbError> {
403        let stopped = self
404            .step(
405                |state| RetryState { sync: RetrySync::Stopped, ..state },
406                |_, requests, _| std::mem::take(&mut requests.waiting),
407            )
408            .await?;
409        answer(stopped, &RetryOutcome::NotSyncing);
410        Ok(())
411    }
412
413    /// The upload worker, which serves the whole store, panicked with
414    /// `bug`, which the sink heard: sync halts for the rest of this store's
415    /// session, as the connection owner's own panic halts it (E15). The last
416    /// pass shows the bug with nothing moving, sync stops, each waiting
417    /// request hears it as `SyncError::WorkerFailed`, the connection's owner
418    /// ends on it, and every start fails with it until the store reopens.
419    pub async fn sync_halted(
420        self: &Arc<Self>,
421        bug: coven_foundation::task::Bug,
422    ) -> Result<(), DbError> {
423        self.halted.send_replace(Some(bug.clone()));
424        if let Some(database) = &self.database {
425            crate::LastPass::failed(crate::SyncError::WorkerFailed(bug.clone()))
426                .record_halt(database)
427                .await?;
428        }
429        self.sync_panicked(bug).await
430    }
431
432    /// The connection's owner panicked with `bug`, which the sink heard, so
433    /// no pass follows a waiting request: sync stops, and each request,
434    /// those the panicking pass took among them, is answered `Failed` with
435    /// `SyncError::WorkerFailed` carrying the bug (E15).
436    pub async fn sync_panicked(
437        self: &Arc<Self>,
438        bug: coven_foundation::task::Bug,
439    ) -> Result<(), DbError> {
440        let waiting = self
441            .step(
442                |state| RetryState { sync: RetrySync::Stopped, ..state },
443                |_, requests, _| std::mem::take(&mut requests.waiting),
444            )
445            .await?;
446        let error = crate::SyncError::WorkerFailed(bug).into();
447        answer(waiting, &PassFailure::Failed { error }.into());
448        Ok(())
449    }
450
451    /// A pass begins, if the row lets one: sync is started, and neither a
452    /// failure nor a cooldown holds it. It takes every waiting request the
453    /// connection's owner has acted on (IO `pass_answers_acted_on`). `None`
454    /// when the row holds the pass, and the owner plans again.
455    pub async fn begin_pass(self: &Arc<Self>) -> Result<Option<PassRequests>, DbError> {
456        self.step(
457            |state| state,
458            |waits, requests, state| {
459                (RetryEffect::from(state) == RetryEffect::Pass)
460                    .then(|| requests.acted_on(waits, waits.handled(NetworkOwner::Connection)))
461            },
462        )
463        .await
464    }
465
466    /// The connection's owner found what holds every pass: a failure only
467    /// new credentials, an update or a reset can clear, or none. It says so
468    /// as it opens and after each pass, before writing the status a failed
469    /// pass leaves, so the effect never trails that status; each time it
470    /// plans, `answer_held` says so too.
471    pub async fn holds(self: &Arc<Self>, failure: Option<&PassFailure>) -> Result<(), DbError> {
472        let failure = failure.cloned();
473        let held = failure.is_some();
474        self.step(
475            move |state| RetryState { held, ..state },
476            move |_, requests, _| requests.held = failure,
477        )
478        .await
479    }
480
481    /// The owner plans: it records what holds every pass, as `holds` does,
482    /// and answers the requests it has acted on now if no pass will run for
483    /// them (IO `held_answers`). A failure only new credentials, an update
484    /// or a reset can clear answers `Failed`; otherwise a live provider
485    /// cooldown answers `CoolingDown`, and anything else that holds the
486    /// pass leaves them for it. The answer is the effect's.
487    pub async fn answer_held(
488        self: &Arc<Self>,
489        failure: Option<&PassFailure>,
490    ) -> Result<(), DbError> {
491        let failure = failure.cloned();
492        let held = failure.is_some();
493        let answered = self
494            .step(
495                move |state| RetryState { held, ..state },
496                move |waits, requests, state| {
497                    requests.held = failure;
498                    // With no answer at once, a pass runs and answers them.
499                    let outcome = requests.at_once(state)?;
500                    let handled = waits.handled(NetworkOwner::Connection);
501                    Some((requests.acted_on(waits, handled), outcome, RetryEffect::from(state)))
502                },
503            )
504            .await?;
505        let Some((acted_on, outcome, effect)) = answered else {
506            return Ok(());
507        };
508        // No pass runs for these requests, so nothing else says what came of
509        // the person's retry.
510        if !acted_on.taken.is_empty() {
511            tracing::info!(
512                requests = acted_on.taken.len(),
513                ?effect,
514                "no pass runs for the person's retry; answering it at once"
515            );
516        }
517        acted_on.answer(&outcome);
518        Ok(())
519    }
520
521    /// The connection's first attempt connected: its requests wait for the
522    /// loop's first pass, or are answered `NotSyncing` if sync stopped
523    /// meanwhile.
524    pub async fn return_requests(self: &Arc<Self>, mut taken: PassRequests) -> Result<(), DbError> {
525        let stopped = self
526            .step(
527                |state| state,
528                move |_, requests, state| match state.sync {
529                    RetrySync::Started { .. } => {
530                        requests.waiting.append(&mut taken.taken);
531                        None
532                    }
533                    RetrySync::Stopped => Some(taken),
534                },
535            )
536            .await?;
537        if let Some(stopped) = stopped {
538            stopped.answer(&RetryOutcome::NotSyncing);
539        }
540        Ok(())
541    }
542}
543
544#[cfg(test)]
545#[path = "retry_answer_tests.rs"]
546mod tests;