Skip to main content

coven_database/
internal_schema_retry_effect.rs

1//! What the person's retry would do if made now (§6.7, api.md E5), kept as a
2//! table so the app's live queries and local triggers can follow it (§9.4),
3//! as they follow `coven_sync_status`.
4//!
5//! Its one row holds what decides it: whether a started connection will
6//! plan (`syncing`), whether a failure only new credentials, an update or a
7//! reset can clear holds every pass (`held`), and when the provider cooldown
8//! holding every pass ends (`cooling_until`, milliseconds since the Unix
9//! epoch by the store's clock). `effect` is computed from them, so it never
10//! disagrees with them. While sync is stopped no owner would write a
11//! cooldown's end, so the row keeps none: the table refuses one. A held
12//! failure stays across stopping and starting, as the connection's retry
13//! state does. Opening writes the stopped row with nothing held, and each
14//! change writes the row in the step that makes it, on the writer, where
15//! the retry's own answer is decided from the same row
16//! (`Database::change_retry_state`).
17//!
18//! Like the sync status, only the handle that holds the store's writer lock
19//! reads it (`authorization.rs`): a read-only handle can't tell whether the
20//! writer that last wrote it still runs.
21
22use crate::stored_millis::{since_epoch, stored_time, Round};
23use crate::DbError;
24use std::time::SystemTime;
25
26/// The retry effect table, which the app's triggers may follow.
27pub(crate) const RETRY_EFFECT: &str = "coven_retry_effect";
28
29/// The table and its one row, as every store holds them from its creation.
30pub(super) const TABLE: &str = "CREATE TABLE coven_retry_effect (
31        singleton INTEGER PRIMARY KEY NOT NULL CHECK(singleton=1),
32        syncing INTEGER NOT NULL CHECK(syncing IN (0,1)),
33        held INTEGER NOT NULL CHECK(held IN (0,1)),
34        cooling_until INTEGER,
35        effect TEXT NOT NULL GENERATED ALWAYS AS (CASE
36            WHEN syncing=0 THEN 'not_syncing'
37            WHEN held=1 THEN 'failed'
38            WHEN cooling_until IS NOT NULL THEN 'cooling_down'
39            ELSE 'pass' END) STORED,
40        CHECK(syncing=1 OR cooling_until IS NULL)
41    ) STRICT;
42    INSERT INTO coven_retry_effect(singleton,syncing,held,cooling_until) VALUES(1,0,0,NULL);";
43
44/// What decides the person's retry, as `coven_retry_effect` holds it.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct RetryState {
47    /// Whether a started connection will plan, and the cooldown holding
48    /// its passes.
49    pub sync: RetrySync,
50    /// A failure only new credentials, an update or a reset can clear
51    /// holds every pass.
52    pub held: bool,
53}
54
55/// Whether a started connection will plan.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum RetrySync {
58    /// None will: a retry is answered at once that sync isn't running.
59    Stopped,
60    /// One will.
61    Started {
62        /// A provider cooldown holds every pass until then, by the store's
63        /// clock, in whole milliseconds rounded up.
64        cooling_until: Option<SystemTime>,
65    },
66}
67
68impl RetryState {
69    /// The state a store opens in: no connection plans yet, and its owner
70    /// has found nothing holding a pass.
71    pub(crate) const OPENED: Self = Self { sync: RetrySync::Stopped, held: false };
72}
73
74/// Milliseconds since the Unix epoch, rounded up so a stored end never comes
75/// before the cooldown's own. A time before the epoch or past SQLite's
76/// integers is an invariant failure.
77fn millis(time: SystemTime) -> Result<i64, DbError> {
78    since_epoch(time, Round::Up).ok_or(DbError::Invariant {
79        what: "a cooldown ends between the Unix epoch and SQLite's last millisecond",
80    })
81}
82
83/// The state as the row holds it.
84pub(crate) fn read(db: &crate::sqlite::DatabaseConnection) -> Result<RetryState, DbError> {
85    let (syncing, held, cooling_until) = db.query_row(
86        "SELECT syncing,held,cooling_until FROM coven_retry_effect WHERE singleton=1",
87        [],
88        |row| Ok((row.get::<_, bool>(0)?, row.get::<_, bool>(1)?, row.get::<_, Option<i64>>(2)?)),
89    )?;
90    let sync = if syncing {
91        RetrySync::Started { cooling_until: cooling_until.map(stored_time).transpose()? }
92    } else {
93        RetrySync::Stopped
94    };
95    Ok(RetryState { sync, held })
96}
97
98/// Write the state whole, leaving the row alone when it already says this.
99/// The cooldown's end is stored rounded up to the millisecond.
100pub(crate) fn save(
101    db: &crate::sqlite::DatabaseConnection,
102    state: RetryState,
103) -> Result<(), DbError> {
104    let held = state.held;
105    let (syncing, cooling_until) = match state.sync {
106        RetrySync::Stopped => (false, None),
107        RetrySync::Started { cooling_until } => (true, cooling_until.map(millis).transpose()?),
108    };
109    db.internal_execute(
110        "UPDATE coven_retry_effect SET syncing=?1,held=?2,cooling_until=?3
111         WHERE singleton=1 AND (syncing IS NOT ?1 OR held IS NOT ?2 OR cooling_until IS NOT ?3)",
112        rusqlite::params![syncing, held, cooling_until],
113    )?;
114    Ok(())
115}
116
117/// The state rounded as the row stores it, so a decision made from a state
118/// before it is written matches one made from the row after.
119pub(crate) fn stored(state: RetryState) -> Result<RetryState, DbError> {
120    let sync = match state.sync {
121        RetrySync::Stopped => RetrySync::Stopped,
122        RetrySync::Started { cooling_until } => RetrySync::Started {
123            cooling_until: cooling_until
124                .map(|until| millis(until).and_then(stored_time))
125                .transpose()?,
126        },
127    };
128    Ok(RetryState { sync, ..state })
129}
130
131#[cfg(test)]
132#[path = "internal_schema_retry_effect_tests.rs"]
133mod tests;