Skip to main content

datasynth_core/
clock.rs

1//! Deterministic generation clock.
2//!
3//! Synthetic generation must be byte-identical across runs with the same
4//! `(config, seed)`. Many records stamp `created_at` / `updated_at` /
5//! `generated_at` with wall-clock time, which breaks that guarantee. This
6//! module provides a process-global deterministic epoch: while it is set,
7//! [`now`] returns a fixed, seed-derived timestamp instead of the wall clock,
8//! so every timestamp that routes through [`now`] becomes reproducible.
9//!
10//! The epoch is stored in a global atomic (not a `thread_local!`, unlike
11//! [`crate::serde_decimal`]) because generation fans out across worker threads
12//! under `parallel = true`; a thread-local set on the main thread would not be
13//! visible to those workers.
14//!
15//! # Usage
16//!
17//! The orchestrator wraps a generation run in a [`DeterministicClockGuard`]
18//! built from the run's seed; on drop the wall clock is restored. Outside a
19//! guard, [`now`] is exactly `Utc::now()`.
20//!
21//! # Limitation
22//!
23//! The epoch is process-global, so two generation runs executing concurrently
24//! in the same process would share it. CLI generation is sequential; callers
25//! that generate concurrently in-process (e.g. a server) should serialize the
26//! deterministic-clock scope or accept wall-clock timestamps.
27
28use crate::uuid_factory::{DeterministicUuidFactory, GeneratorType};
29use chrono::{DateTime, NaiveDate, Utc};
30use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
31use uuid::Uuid;
32
33/// Sentinel meaning "no deterministic epoch set" → use the real wall clock.
34const UNSET: i64 = i64::MIN;
35
36/// Deterministic epoch in milliseconds since the Unix epoch, or [`UNSET`].
37static DET_EPOCH_MILLIS: AtomicI64 = AtomicI64::new(UNSET);
38
39// --- Deterministic UUID fallback (for `Uuid::now_v7()` sites) ---
40//
41// Many model constructors (e.g. `JournalEntryHeader::new`) mint a `now_v7`
42// document id, which is wall-clock/random. When the deterministic context is
43// active, [`next_document_id`] instead returns sequential seeded UUIDs from a
44// dedicated namespace (sub-discriminator `0xFF`, so it never collides with the
45// hot-path factory that uses the same seed + `GeneratorType::JournalEntry`).
46//
47// Determinism of these ids requires the *order* of `next_document_id()` calls
48// to be reproducible. That holds for the sequential side-path phases that use
49// `new()` (period close, balances, tax, standards, intercompany); the parallel
50// hot JE path uses `with_deterministic_id` and does not draw from this counter.
51const DET_UUID_SUB: u8 = 0xFF;
52static DET_UUID_ACTIVE: AtomicBool = AtomicBool::new(false);
53static DET_UUID_SEED: AtomicU64 = AtomicU64::new(0);
54static DET_UUID_COUNTER: AtomicU64 = AtomicU64::new(0);
55
56/// Set (or clear) the process-global deterministic epoch.
57///
58/// `Some(epoch)` makes [`now`] return `epoch`; `None` restores the wall clock.
59pub fn set_deterministic_epoch(epoch: Option<DateTime<Utc>>) {
60    let v = epoch.map(|e| e.timestamp_millis()).unwrap_or(UNSET);
61    DET_EPOCH_MILLIS.store(v, Ordering::SeqCst);
62}
63
64/// Whether a deterministic epoch is currently active.
65pub fn is_deterministic() -> bool {
66    DET_EPOCH_MILLIS.load(Ordering::SeqCst) != UNSET
67}
68
69/// The current timestamp: the deterministic epoch if one is set, else the
70/// real wall clock. Use this in generation code instead of `Utc::now()`.
71pub fn now() -> DateTime<Utc> {
72    let v = DET_EPOCH_MILLIS.load(Ordering::SeqCst);
73    if v == UNSET {
74        Utc::now()
75    } else {
76        DateTime::from_timestamp_millis(v).unwrap_or_else(Utc::now)
77    }
78}
79
80/// Activate (or clear) the deterministic UUID fallback used by model
81/// constructors. `Some(seed)` resets the counter and makes [`next_document_id`]
82/// return seeded sequential UUIDs; `None` restores `Uuid::now_v7()`.
83pub fn set_deterministic_uuids(seed: Option<u64>) {
84    match seed {
85        Some(s) => {
86            DET_UUID_SEED.store(s, Ordering::SeqCst);
87            DET_UUID_COUNTER.store(0, Ordering::SeqCst);
88            DET_UUID_ACTIVE.store(true, Ordering::SeqCst);
89        }
90        None => DET_UUID_ACTIVE.store(false, Ordering::SeqCst),
91    }
92}
93
94/// A document id for a model constructor: a seeded sequential UUID when the
95/// deterministic context is active, otherwise `Uuid::now_v7()`. Use this in
96/// place of `Uuid::now_v7()` in generation model constructors.
97pub fn next_document_id() -> Uuid {
98    if DET_UUID_ACTIVE.load(Ordering::SeqCst) {
99        let n = DET_UUID_COUNTER.fetch_add(1, Ordering::SeqCst);
100        let seed = DET_UUID_SEED.load(Ordering::SeqCst);
101        DeterministicUuidFactory::with_sub_discriminator(
102            seed,
103            GeneratorType::JournalEntry,
104            DET_UUID_SUB,
105        )
106        .generate_at(n)
107    } else {
108        Uuid::now_v7()
109    }
110}
111
112/// Derive a deterministic epoch from a run seed and the config start date.
113///
114/// Anchored at midnight UTC of `start_date` and nudged by a seed-derived
115/// offset within the day, so different seeds yield distinct-but-reproducible
116/// epochs while staying close to the simulated period.
117pub fn epoch_from_seed(seed: u64, start_date: NaiveDate) -> DateTime<Utc> {
118    let base = start_date
119        .and_hms_opt(0, 0, 0)
120        .unwrap_or_default()
121        .and_utc();
122    let offset_secs = (seed % 86_400) as i64;
123    base + chrono::Duration::seconds(offset_secs)
124}
125
126/// RAII guard: sets the deterministic epoch on construction and restores the
127/// wall clock on drop. Hold it for the lifetime of a generation run.
128#[must_use = "the deterministic clock is only active while the guard is alive"]
129pub struct DeterministicClockGuard {
130    _private: (),
131}
132
133impl DeterministicClockGuard {
134    /// Activate the deterministic clock at `epoch` (timestamps only; the UUID
135    /// fallback is left untouched).
136    pub fn new(epoch: DateTime<Utc>) -> Self {
137        set_deterministic_epoch(Some(epoch));
138        Self { _private: () }
139    }
140
141    /// Activate the full deterministic context — clock epoch + seeded UUID
142    /// fallback — for a generation run keyed off `seed`/`start_date`.
143    pub fn from_seed(seed: u64, start_date: NaiveDate) -> Self {
144        set_deterministic_epoch(Some(epoch_from_seed(seed, start_date)));
145        set_deterministic_uuids(Some(seed));
146        Self { _private: () }
147    }
148}
149
150impl Drop for DeterministicClockGuard {
151    fn drop(&mut self) {
152        set_deterministic_epoch(None);
153        set_deterministic_uuids(None);
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::sync::Mutex;
161
162    /// Serializes the tests that mutate the process-global deterministic clock
163    /// context (epoch + UUID seed). The cargo test runner executes a binary's
164    /// tests on multiple threads, so without this lock
165    /// `test_now_honors_epoch_and_guard_restores`'s guard drop (which calls
166    /// `set_deterministic_uuids(None)`) can fire between the two id sequences in
167    /// `test_next_document_id_deterministic_under_context`, flipping the context
168    /// off mid-test and yielding a random-v4 second sequence — observed flaking
169    /// on Windows CI. These two tests are the only global-context mutators in
170    /// this binary, so locking both fully serializes them.
171    /// `into_inner()` on poison so one panicking test doesn't cascade.
172    static CLOCK_CTX_LOCK: Mutex<()> = Mutex::new(());
173
174    #[test]
175    fn test_epoch_from_seed_is_deterministic() {
176        let d = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
177        assert_eq!(epoch_from_seed(42, d), epoch_from_seed(42, d));
178        assert_ne!(epoch_from_seed(42, d), epoch_from_seed(43, d));
179        // Anchored on/after the start date.
180        assert!(epoch_from_seed(0, d) >= d.and_hms_opt(0, 0, 0).unwrap().and_utc());
181    }
182
183    #[test]
184    fn test_next_document_id_deterministic_under_context() {
185        let _ctx = CLOCK_CTX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
186        // Active context → seeded sequential ids, reproducible across resets.
187        set_deterministic_uuids(Some(99));
188        let s1: Vec<Uuid> = (0..3).map(|_| next_document_id()).collect();
189        set_deterministic_uuids(Some(99));
190        let s2: Vec<Uuid> = (0..3).map(|_| next_document_id()).collect();
191        set_deterministic_uuids(None);
192        assert_eq!(s1, s2, "same seed → identical id sequence");
193        assert_ne!(s1[0], s1[1], "sequential ids are distinct");
194        assert!(!s1[0].is_nil());
195    }
196
197    // Shares CLOCK_CTX_LOCK with the UUID test: this test's guard drop resets
198    // the global UUID context, which would otherwise race that test.
199    #[test]
200    fn test_now_honors_epoch_and_guard_restores() {
201        let _ctx = CLOCK_CTX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
202        let epoch = NaiveDate::from_ymd_opt(2024, 6, 7)
203            .unwrap()
204            .and_hms_opt(12, 0, 0)
205            .unwrap()
206            .and_utc();
207        assert!(!is_deterministic(), "must start unset");
208        {
209            let _g = DeterministicClockGuard::new(epoch);
210            assert!(is_deterministic());
211            assert_eq!(now(), epoch);
212            assert_eq!(now(), epoch, "stable across calls");
213        }
214        // Guard dropped → wall clock restored.
215        assert!(!is_deterministic());
216        assert_ne!(now(), epoch);
217    }
218}