Synthetic and system clocks for deterministic time-shape testing and fast-forward demos.
[dependencies]
synth-clock = "0.1"trait Clock: Send + Sync {
fn now(&self) -> DateTime<Utc>;
fn schedule(&self, at: DateTime<Utc>, callback: Callback) -> Handle;
fn cancel(&self, handle: Handle);
}Clock— production-side trait. Production code that needs time takes&dyn Clock.SystemClock— wall clock.now()returnsUtc::now().schedule()isunimplemented!()by design (no async runtime pinned to this crate).SyntheticClock— deterministic test/demo clock. Full callback machinery, snapshot/restore, speed multiplier.
Time-shape applications — anything that schedules, waits, or reasons
about durations — are notoriously hard to test. Real Utc::now() is
non-deterministic; sleep-based tests are slow and flaky. This crate
lets you:
- skip an entire multi-hour duration in microseconds during tests
- assert that a callback fired at the precisely correct moment
- branch-test "scenario A vs scenario B from this exact state"
- run a 24-hour timeline of activity in 2 minutes by setting a 720× speed multiplier
use chrono::{Duration, TimeZone, Utc};
use synth_clock::{Clock, SyntheticClock};
let clock = SyntheticClock::at(
Utc.with_ymd_and_hms(2026, 4, 28, 8, 0, 0).unwrap()
);
clock.schedule(
clock.now() + Duration::minutes(45),
Box::new(|t| println!("fired at {}", t)),
);
clock.advance(Duration::hours(1));
// Callback fires precisely at the 45-min mark; clock now reads 9:00.| Method | Purpose |
|---|---|
SyntheticClock::at(t) |
Construct at a deterministic starting moment |
clock.now() |
Read current simulated time |
clock.advance(by) |
Move forward by a Duration |
clock.set(to) |
Jump to an absolute time |
clock.run_until(t) |
Advance to t, firing every callback along the way in chronological order |
clock.jump_to_next_callback() |
Advance exactly to the next pending callback's time |
clock.schedule(at, cb) |
Register a callback to fire at at. Returns a Handle. |
clock.cancel(handle) |
Remove a not-yet-fired callback |
clock.pending_callbacks() |
Inspect what's waiting (for tests) |
clock.fired_callbacks() |
Count of callbacks fired so far |
clock.with_speed_multiplier(k) |
Set how fast tick() advances simulated time relative to wall time |
clock.play() / pause() |
Toggle whether tick() advances |
clock.tick(wall_dt) |
Advance simulated time by wall_dt × multiplier (no-op when paused) |
clock.snapshot() / restore(snap) |
Capture and rewind for branch testing |
let clock = SyntheticClock::at(t0);
let snap = clock.snapshot();
// Scenario A
clock.advance(Duration::hours(2));
let a_state = capture_state(&clock);
// Rewind
clock.restore(snap);
// Scenario B
clock.advance(Duration::hours(10));
let b_state = capture_state(&clock);
assert!(b_state.something > a_state.something);Snapshot captures the clock's now() only — pending callbacks are
cleared on restore. Each scenario re-registers whatever it needs.
This is intentional: clone-able callbacks are hard (most callbacks
are FnOnce), and the typical use case is "fresh setup per scenario"
anyway.
let clock = SyntheticClock::at(t0).with_speed_multiplier(720.0);
clock.play();
loop {
std::thread::sleep(std::time::Duration::from_millis(50));
clock.tick(chrono::Duration::milliseconds(50));
}50ms wall-clock × 720 = 36 simulated seconds per real iteration. A 24-hour bake plays in 2 minutes; demos and book figures land in visible time without losing the shape of the timeline.
- Time zones. This crate uses
chrono::DateTime<Utc>exclusively. If you need zoned reads, build them on top. - Sub-millisecond precision. Internally
chronogoes to nanoseconds, but thetick()math is millisecond-precision. Don't use this for high-frequency event simulation. - Persistent scheduling. Callbacks live in memory; surviving a process restart is the caller's problem.
- Networked / cross-process clocks. This is single-process. If multiple processes need to agree on simulated time, you need another layer.
- Real-time
SystemClock::schedule(). Implementing it would pin this crate to a specific async runtime. Callers wrapSystemClock::now()themselves with whatever runtime they prefer.
Extracted from the
Baker project — a sourdough
planner whose recipes span 24-72 hours and whose tests need to skip
those hours in microseconds. The design rationale lives in Baker's
docs/design/synthetic-clock-spec.md.
MIT OR Apache-2.0