Clock Injection — Testable Time

Schedule::exponential(100.ms()).take(3) with real retries takes ~700ms to test. Multiply by hundreds of tests and your test suite takes minutes. Clock injection solves this.

The Clock Trait

#![allow(unused)]
fn main() {
use id_effect::{Clock};

// The Clock trait abstracts time
trait Clock: Send + Sync {
    fn now(&self) -> UtcDateTime;
    fn sleep(&self, duration: Duration) -> Effect<(), Never, ()>;
}
}

All time-related operations in id_effect go through the current fiber's Clock. Replace the clock, and "time" moves as fast as you drive it.

Production: LiveClock

#![allow(unused)]
fn main() {
use id_effect::LiveClock;

// Uses real system time and tokio::time::sleep
let live_clock = LiveClock::new();
}

In production, inject LiveClock through the environment. Effect code never calls std::time::SystemTime::now() directly — it uses the injected clock.

Testing: TestClock

#![allow(unused)]
fn main() {
use id_effect::TestClock;

let clock = TestClock::new();

// The clock starts at epoch and doesn't advance on its own
assert_eq!(clock.now(), EPOCH);

// Advance time instantly
clock.advance(Duration::from_secs(60));
assert_eq!(clock.now(), EPOCH + 60s);
}

TestClock is deterministic. It advances only when you tell it to. Sleep effects don't wait — they check the clock, and if the clock is past their wake time, they return immediately.

Test Example

#![allow(unused)]
fn main() {
#[test]
fn exponential_retry_makes_three_attempts() {
    let clock = TestClock::new();
    let attempts = Arc::new(AtomicU32::new(0));

    let effect = {
        let attempts = attempts.clone();
        failing_operation(attempts.clone())
            .retry(Schedule::exponential(Duration::from_secs(1)).take(3))
    };

    // Fork the effect with the test clock
    let handle = effect.fork_with_clock(&clock);

    // Advance time to trigger each retry
    clock.advance(Duration::from_secs(1));   // retry 1
    clock.advance(Duration::from_secs(2));   // retry 2
    clock.advance(Duration::from_secs(4));   // retry 3 (exhausted)

    let exit = handle.join_blocking();
    assert!(matches!(exit, Exit::Failure(_)));
    assert_eq!(attempts.load(Ordering::Relaxed), 4);  // initial + 3 retries
}
}

The test runs in microseconds despite testing multi-second retry behaviour. No tokio::time::pause() hacks. No sleep(Duration::ZERO) workarounds.

Clock in the Environment

Like all services, Clock lives in the effect environment:

#![allow(unused)]
fn main() {
service_key!(ClockKey: Arc<dyn Clock>);

fn now() -> Effect<UtcDateTime, Never, impl NeedsClock> {
    effect! {
        let clock = ~ ClockKey;
        clock.now()
    }
}
}

The production Layer provides LiveClock; test code provides TestClock. Business logic is identical in both contexts.