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}