Skip to main content

macrame/util/
clock.rs

1use crate::error::{DbError, Result};
2use crate::util::timestamp;
3use std::sync::Mutex;
4use std::time::{Duration, SystemTime};
5
6/// How far ahead of the wall clock a stored `recorded_at` may be before the
7/// floor computed at open refuses it. Twenty-four hours.
8///
9/// A whole day is deliberately generous. The condition being caught is a stamp
10/// that is *wrong* — a skewed machine, a bad import, a fixture that escaped —
11/// and those are typically years out, not hours. What a tight bound would catch
12/// instead is a timezone-confused host or a database carried across a daylight
13/// boundary by a tool that mishandled it, and refusing to open on that is worse
14/// than the disease. The check exists to stop a stamp no clock could have
15/// issued from becoming permanent, not to police minutes.
16pub const DEFAULT_FUTURE_STAMP_TOLERANCE: Duration = Duration::from_secs(24 * 60 * 60);
17
18/// What [`crate::Database::open_tuned`] does about a `recorded_at` in the future
19/// (0.13.5, W7.4, [D-178]).
20///
21/// Shaped like [`crate::WalCheckpointPolicy`] and for
22/// [D-155](../../docs/architecture/s13-decision-register.md)'s reason: this
23/// guards an invariant, so "leave it alone" must not be spelt with an `Option`
24/// whose `None` turns it off for every caller who never heard of it.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26#[non_exhaustive]
27pub enum FutureStampPolicy {
28    /// Refuse beyond [`DEFAULT_FUTURE_STAMP_TOLERANCE`].
29    #[default]
30    Default,
31    /// Refuse beyond a tolerance of your own. `Duration::ZERO` refuses any
32    /// stamp at all ahead of the wall clock, which is the strictest form and is
33    /// only reasonable where the host's time is known good.
34    Tolerance(Duration),
35    /// Open regardless, and take the floor from whatever is stored.
36    ///
37    /// **The repair path, and it is not a repair.** It exists because a
38    /// database this check refuses cannot otherwise be reached by the crate
39    /// that refuses it, and inspecting a file requires opening it. Every write
40    /// made under this policy inherits the poisoned floor, so use it to read
41    /// and to plan, not to carry on.
42    Allow,
43}
44
45impl FutureStampPolicy {
46    /// `None` means no bound is applied.
47    fn tolerance(self) -> Option<Duration> {
48        match self {
49            Self::Default => Some(DEFAULT_FUTURE_STAMP_TOLERANCE),
50            Self::Tolerance(d) => Some(d),
51            Self::Allow => None,
52        }
53    }
54}
55
56/// Trait defining the clock interface for timestamp generation.
57/// CONTRACT: successive calls return strictly increasing values,
58/// even across application restarts and NTP corrections.
59pub trait Clock: Send + Sync {
60    /// Returns the current timestamp as ISO-8601 UTC string (e.g. "YYYY-MM-DDTHH:MM:SS.ffffffZ").
61    fn now(&self) -> String;
62
63    /// Raise this clock's floor to `floor`, if it is currently behind it.
64    ///
65    /// **The contract above is what makes this part of the trait rather than an
66    /// implementation detail of [`SystemClock`].** "Strictly increasing across
67    /// restarts" is not a property a clock can hold on its own — it depends on
68    /// what the database already contains, which the clock cannot see. Every
69    /// implementation therefore needs a way to be told, and
70    /// [`crate::Database::open_with_clock`] tells it exactly once, at open.
71    ///
72    /// Without this, injecting a [`FakeClock`] was not merely awkward but
73    /// unusable on any database with rows in it: a fake starting at the epoch
74    /// issues stamps below every existing `recorded_at` and the first concept
75    /// write aborts on `trg_concepts_monotonic_ra`. That is the reason defect K
76    /// stalled for three releases, and it is a missing trait method rather than
77    /// a missing constructor argument.
78    fn raise_floor(&self, floor: SystemTime);
79}
80
81/// The newest `recorded_at` in the ledger tables, or `None` on an empty database.
82///
83/// One definition of "the floor", used by [`SystemClock::new`] and by
84/// `open_with_clock`, rather than the query existing twice and being kept in
85/// step by hand.
86///
87/// # A stamp from the future is refused rather than absorbed (0.13.5, W7.4, §3.4)
88///
89/// The floor is `MAX(recorded_at)` and the clock is raised to it, so a single
90/// row stamped in 2087 — a skewed host, a bad import, a fixture that escaped —
91/// becomes this process's floor, and every stamp it then issues is in 2087 too.
92/// Those rows are written, so the next open reads the same floor back. **The
93/// damage is permanent and it spreads**, which is what separates this from an
94/// ordinary bad value.
95///
96/// It is caught here rather than at the write, because here is where a stamp
97/// the crate could not have issued becomes one the crate does issue. `policy`
98/// decides how far ahead is too far; see [`FutureStampPolicy`].
99///
100/// A corrupt stamp is still a `warn!` and no floor, unchanged
101/// ([D-027](../../docs/architecture/s13-decision-register.md)) — an unparseable
102/// value cannot be inherited, so it cannot spread, and refusing to open on one
103/// would be the harsher answer to the smaller problem.
104pub(crate) async fn recorded_at_floor(
105    conn: &libsql::Connection,
106    policy: FutureStampPolicy,
107) -> Result<Option<SystemTime>> {
108    let max_ts: Option<String> = conn
109        .query(
110            "SELECT MAX(recorded_at) FROM (
111                 SELECT MAX(recorded_at) AS recorded_at FROM concepts
112                 UNION ALL
113                 SELECT MAX(recorded_at) AS recorded_at FROM links
114             )",
115            (),
116        )
117        .await?
118        .next()
119        .await?
120        .and_then(|row| row.get(0).ok());
121
122    Ok(match max_ts {
123        Some(ts) => match parse_iso8601_utc(&ts) {
124            Ok(t) => {
125                if let Some(tolerance) = policy.tolerance() {
126                    let limit = SystemTime::now() + tolerance;
127                    if t > limit {
128                        return Err(DbError::FutureRecordedAt {
129                            stamp: ts,
130                            limit: format_iso8601_utc(limit),
131                        });
132                    }
133                }
134                Some(t)
135            }
136            Err(e) => {
137                tracing::warn!(
138                    "clock: failed to parse MAX(recorded_at)={:?}: {}; no floor applied",
139                    ts,
140                    e
141                );
142                None
143            }
144        },
145        None => None,
146    })
147}
148
149/// Production system clock maintaining a monotonic timestamp floor.
150pub struct SystemClock {
151    last_issued: Mutex<SystemTime>,
152}
153
154impl SystemClock {
155    pub async fn new(conn: &libsql::Connection, policy: FutureStampPolicy) -> Result<Self> {
156        // Wall clock when the database is empty or its stamp will not parse:
157        // there is nothing to be behind, and a corrupt stored timestamp must not
158        // become this process's floor (D-027). A stamp from the *future* is a
159        // different case and `recorded_at_floor` refuses it (W7.4).
160        let floor = recorded_at_floor(conn, policy)
161            .await?
162            .unwrap_or_else(SystemTime::now);
163        Ok(Self {
164            last_issued: Mutex::new(floor),
165        })
166    }
167}
168
169impl Clock for SystemClock {
170    fn now(&self) -> String {
171        let mut guard = self.last_issued.lock().unwrap();
172        let wall = SystemTime::now();
173        let next = if wall > *guard {
174            wall
175        } else {
176            *guard + std::time::Duration::from_micros(1)
177        };
178        *guard = next;
179        format_iso8601_utc(next)
180    }
181
182    fn raise_floor(&self, floor: SystemTime) {
183        let mut guard = self.last_issued.lock().unwrap();
184        if floor > *guard {
185            *guard = floor;
186        }
187    }
188}
189
190/// Fake clock for deterministic unit and scenario testing.
191///
192/// Inject with [`crate::Database::open_with_clock`]. On a **fresh** database the
193/// stamps are exactly what the caller sets, which is the point: a bitemporal
194/// test that has to accommodate wall-clock `recorded_at` values cannot assert on
195/// them, so most of the interesting divergence properties were previously
196/// written against `valid_time` alone or against a hand-driven connection.
197///
198/// On a database that **already holds rows**, `open_with_clock` raises this
199/// clock to their newest `recorded_at` before the actor starts — see
200/// [`Clock::raise_floor`]. That is not optional and it does cost determinism:
201/// reopening a populated database with a fake starting at the epoch would
202/// otherwise abort the first concept write on `trg_concepts_monotonic_ra`. Tests
203/// wanting exact stamps should start from an empty file.
204pub struct FakeClock {
205    current: Mutex<SystemTime>,
206}
207
208impl FakeClock {
209    pub fn new(initial: SystemTime) -> Self {
210        Self {
211            current: Mutex::new(initial),
212        }
213    }
214
215    pub fn advance(&self, duration: std::time::Duration) {
216        let mut guard = self.current.lock().unwrap();
217        *guard += duration;
218    }
219
220    /// The stamp this clock would issue next, without issuing it.
221    pub fn peek(&self) -> String {
222        format_iso8601_utc(*self.current.lock().unwrap())
223    }
224}
225
226impl Clock for FakeClock {
227    fn now(&self) -> String {
228        let mut guard = self.current.lock().unwrap();
229        let res = format_iso8601_utc(*guard);
230        *guard += std::time::Duration::from_micros(1);
231        res
232    }
233
234    fn raise_floor(&self, floor: SystemTime) {
235        let mut guard = self.current.lock().unwrap();
236        if floor > *guard {
237            // One microsecond past, not equal to: `recorded_at` must be
238            // *strictly* increasing, and issuing the floor itself would collide
239            // with the row it was read from.
240            *guard = floor + std::time::Duration::from_micros(1);
241        }
242    }
243}
244
245/// Strict parser for the canonical timestamp form (§4.1), accepting the legacy
246/// second-precision form for rows written by older crate versions.
247///
248/// Both this and [`format_iso8601_utc`] delegate to [`crate::util::timestamp`],
249/// which owns the canonical form. They remain here as the names §5.1.1 uses.
250pub fn parse_iso8601_utc(s: &str) -> Result<SystemTime> {
251    timestamp::parse(s)
252}
253
254/// Format a `SystemTime` in the canonical form `YYYY-MM-DDTHH:MM:SS.ffffffZ`.
255pub fn format_iso8601_utc(st: SystemTime) -> String {
256    timestamp::format(st)
257}