Skip to content

ontometrics/synth-clock

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

synth-clock

Synthetic and system clocks for deterministic time-shape testing and fast-forward demos.

[dependencies]
synth-clock = "0.1"

Three pieces

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() returns Utc::now(). schedule() is unimplemented!() by design (no async runtime pinned to this crate).
  • SyntheticClock — deterministic test/demo clock. Full callback machinery, snapshot/restore, speed multiplier.

Why

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

Quick example

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.

Capabilities

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

Branch-test pattern

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.

Speed multiplier

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.

What's deliberately not in scope

  • Time zones. This crate uses chrono::DateTime<Utc> exclusively. If you need zoned reads, build them on top.
  • Sub-millisecond precision. Internally chrono goes to nanoseconds, but the tick() 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 wrap SystemClock::now() themselves with whatever runtime they prefer.

Origin

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.

License

MIT OR Apache-2.0

About

Synthetic clock abstraction for testing and simulation

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages