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;