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}