Published on
Reading time
13 min read

Clockworks

Authors

Introduction

In this post, I describe the ideas behind Clockworks, a library I built for making time deterministic and controllable in dotnet, and then show how those ideas turn into practical engineering for timers, timeouts, time-ordered IDs, and distributed causality.

One of the awkward things about time in application code is that it rarely stays "just a timestamp".

The moment a system becomes even slightly interesting, time starts leaking into everything:

  • retries and backoff
  • timeouts and leases
  • heartbeats and scheduling
  • ID generation
  • message ordering
  • distributed causality

At that point, using "whatever the wall clock says right now" stops being a harmless implementation detail and starts becoming an architectural choice.

I built Clockworks because I wanted a cleaner answer to that problem in dotnet. I wanted time to be something I could inject, control, fast-forward, and reason about explicitly in tests and simulations. And once I had that foundation, it became natural to build time-ordered IDs and distributed clock primitives on top of it.

Time is a dependency, and once you treat it like one, a lot of awkward engineering starts to become much easier to model.

In the rest of this post I will start with the theory that underpins the library, and then move into the practical engineering choices inside Clockworks.

Background: When Time Gets Weird

Most developers have felt at least one of these problems:

  • a test that uses Task.Delay() and sometimes flakes in CI
  • a timeout that is hard to simulate without literally waiting for it
  • an identifier that should sort by time using the same clock the code relies on (real or simulated) but still be unique
  • a distributed workflow where "earlier wall-clock time" is not the same thing as "happened before"

Those problems look different on the surface, but they share the same root issue: we often treat time as ambient global state instead of as an explicit input to the system.

That is where dotnet's TimeProvider becomes interesting. Once time is injectable, it stops being something code reads passively and starts becoming something the application can model deliberately.

Time As A Dependency

The core idea behind Clockworks is simple: if time affects behavior, then time should be passed in the same way we pass in any other dependency.

That gives us a few immediate wins:

  • tests can control time instead of waiting for it
  • timers and timeouts can be driven deterministically
  • ID generation can follow either real or simulated time
  • distributed simulations can preserve the same notion of time across the entire scenario

This is the motivation for SimulatedTimeProvider, which is the foundation the rest of the library builds on.

How This Differs From Older Clock Abstractions

Before dotnet introduced TimeProvider, a lot of us ended up creating some version of a SystemClock, IClock, or IDateTimeProvider abstraction for tests.

Those abstractions were useful, but they usually only solved one narrow problem: they let me fake "what time is it right now?"

That helped for code that directly read DateTime.UtcNow or DateTimeOffset.UtcNow, but it usually did not help with:

  • timers
  • timeouts
  • scheduling
  • elapsed time measurement
  • deterministic replay or simulation

So you often ended up in an awkward middle ground where "now" was injectable, but the actual behavior of the system was still tied to real time.

That is why TimeProvider is such a good addition to the base class library. It does not just abstract wall-clock time. It also gives dotnet a standard abstraction for timers and monotonic timestamps, which is a much better foundation for testable time-based behavior.

Clockworks is not trying to replace TimeProvider. It is built on top of it.

The difference is that Clockworks takes that abstraction and turns it into a more opinionated model:

  • SimulatedTimeProvider gives me deterministic, explicitly advanced time
  • wall time and scheduler time are separated on purpose
  • Timeouts follows the same provider, instead of escaping back to real time
  • the same time foundation extends into UUIDv7, HLC, and vector clocks

So to summarize the separation of concepts: the old SystemClock pattern usually let me fake "now", TimeProvider gives dotnet a real time abstraction, and Clockworks builds a deterministic simulation and distributed-systems toolkit on top of that foundation.

The Determinism Model

One of the most important design choices in Clockworks is that simulated time is not just a single mutable clock. There are actually two different notions of time:

SimulatedTimeProvider
|
+-- wall time
|   returned by GetUtcNow()
|   can move forwards or backwards
|   useful for skew, drift, rewind scenarios
|
+-- scheduler time
    drives timers, timeouts, and GetTimestamp()
    monotonic
    advances only when Advance(...) is called

I split these on purpose.

If I only had one mutable clock, then simulating a wall-clock rewind would also scramble timer behavior. That is usually not what I want. In tests and simulations, I want timers to remain deterministic and ordered, while still being able to say "pretend this machine's wall clock jumped backwards by two seconds".

So in Clockworks:

  • wall time is for modeling what the system thinks the current time is
  • scheduler time is for driving callbacks, timers, and timeout behavior in a deterministic and reproducible way

That separation is what makes the rest of the library hang together.

Deterministic Timers

Once scheduler time is explicit, timers become very predictable.

var tp = new SimulatedTimeProvider();

var fired = 0;
using var timer = tp.CreateTimer(
    _ => fired++,
    null,
    TimeSpan.FromSeconds(1),
    Timeout.InfiniteTimeSpan);

tp.Advance(TimeSpan.FromSeconds(1));
// fired == 1

The interesting thing here is not that a timer fired after one second. The interesting thing is that no real second had to pass.

That sounds small, but it changes the way tests and simulations feel. Instead of writing code that sleeps and hopes, I can write code that advances the model and then asserts on the resulting state.

Timeouts That Follow The Provider

The same idea applies to timeouts.

If a library has injectable time for "what time is it?" but still uses unrelated real timers for cancellation, the model breaks down pretty quickly. So Clockworks also includes Timeouts.CreateTimeout(...) and Timeouts.CreateTimeoutHandle(...), both driven by the provided TimeProvider.

var tp = new SimulatedTimeProvider();

using var timeout = Timeouts.CreateTimeoutHandle(
    tp,
    TimeSpan.FromSeconds(5));

tp.Advance(TimeSpan.FromSeconds(5));
// timeout.Token.IsCancellationRequested == true

That gives me a much cleaner mental model: if a component depends on a particular clock, then its timers and timeouts should depend on that same clock too.

Ordering Is Not The Same Thing As Causality

Once time is injectable and deterministic, the next natural question is: what exactly do I mean by "before"?

This is where distributed systems force us to be more precise.

If node A writes a timestamp of 10:00:01.000 and node B writes 10:00:00.999, that does not automatically mean B happened before A. Physical clocks can drift, messages can be delayed, and some events are genuinely concurrent.

That distinction goes back to Leslie Lamport's classic paper, Time, Clocks, and the Ordering of Events in a Distributed System. The key idea is that distributed systems naturally give us a happened-before relation, which is a partial order, not a complete one.

That leads to two different families of tools:

  • tools that give a cheap total order
  • tools that preserve richer causal structure, including concurrency

Clockworks includes both, because they solve different problems.

Hybrid Logical Clocks And Vector Clocks

I found it useful to think about the trade-off like this:

Need cheap ordering that stays close to wall time?
-> Hybrid Logical Clock (HLC)

Need exact happens-before and concurrency detection?
-> Vector Clock

Hybrid Logical Clocks were formalized in Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases by Kulkarni and Demirbas. The appeal of HLC is that it gives you a compact, O(1) timestamp that tracks causality while staying close to physical time.

Vector clocks take the opposite side of the trade-off: they preserve exact causality and can detect concurrency, but the metadata grows with the number of participants.

So the mental model I implemented in Clockworks is:

  • HLC for cheap ordering, time correlation, and time-ordered distributed identifiers
  • Vector clocks for exact causal reasoning and concurrency detection

UUIDv7 As A Time-Aware ID

The first practical payoff after deterministic time is identifier generation. Clockworks includes a UuidV7Factory built around TimeProvider. That means the same ID generator can follow TimeProvider.System in production or SimulatedTimeProvider in tests.

var tp = new SimulatedTimeProvider();
using var factory = new UuidV7Factory(
    tp,
    overflowBehavior: CounterOverflowBehavior.Auto);

var id1 = factory.NewGuid();
tp.Advance(TimeSpan.FromMilliseconds(1));
var id2 = factory.NewGuid();

Console.WriteLine(id1.CompareByTimestamp(id2) < 0); // true

UUIDv7 is great here because it sits at a nice intersection:

  • globally unique
  • sortable by embedded time (using CompareByTimestamp)
  • no central sequence generator
  • still compatible with the normal Guid shape most dotnet code already expects

There is an important caveat though: UUIDv7 embeds a timestamp. That means it is useful as an internal time-ordered identifier, but it should not be treated as an opaque secret across public boundaries.

I lean more towards thinking of UUIDv7 as a combination of a time-aware identifier and a time-ordered identifier. It is a time-aware identifier because it embeds a timestamp, and it is a time-ordered identifier because it is sortable by timestamp (using CompareByTimestamp).

Causal Propagation With HLC

Once IDs and local time are handled, the next step is message flow.

Clockworks exposes a simple BeforeSend / BeforeReceive workflow through HlcCoordinator. The idea is that sending a message should produce a timestamp to attach to the outgoing message, and receiving a message should witness that remote timestamp before the receiver continues.

NodeA
  BeforeSend() -> t1
  send message with t1
            |
            v
NodeB
  BeforeReceive(t1)
  BeforeSend() -> t2

Guarantee: t1 < t2

In code, it looks like this:

var tp = new SimulatedTimeProvider();

using var idFactory = new UuidV7Factory(tp, overflowBehavior: CounterOverflowBehavior.Auto);
using var aFactory = new HlcGuidFactory(tp, nodeId: 1);
using var bFactory = new HlcGuidFactory(tp, nodeId: 2);

var a = new HlcCoordinator(aFactory);
var b = new HlcCoordinator(bFactory);

var sent = a.BeforeSend();
var header = new HlcMessageHeader(sent, correlationId: idFactory.NewGuid());

var parsed = HlcMessageHeader.Parse(header.ToString());
b.BeforeReceive(parsed.Timestamp);

var reply = b.BeforeSend();
Console.WriteLine(sent < reply); // true

I'm also using UuidV7Factory here for the correlation ID, so the identifier follows the same injected time model as the rest of the example.

That is a small API surface, but it captures an important idea: the receiver is not just reading a remote timestamp as data. It is incorporating that timestamp into its own local notion of causality.

Exact Causality With Vector Clocks

Sometimes cheap ordering is not enough.

If I want to know whether two events are concurrent, or whether one definitely happened before another, I need something stronger than a total order that merely tracks time closely. That is where VectorClock and VectorClockCoordinator come in.

The coordinator API follows the same broad shape:

  • BeforeSend() for outbound propagation
  • BeforeReceive(remoteClock) for merge-plus-advance
  • NewLocalEvent() for causally meaningful local events

The big difference is what I can ask afterward. With vector clocks I can distinguish:

  • Before
  • After
  • Equal
  • Concurrent

That matters in exactly the kinds of places where distributed systems get subtle: replicated state, deduplication, idempotent workflows, and debugging unexpected message races.

Where The Pieces Come Together

The best way to think about Clockworks is as a stack of related capabilities:

TimeProvider
-> controllable time
-> deterministic timers and timeouts
-> time-ordered UUIDv7 identifiers
-> causal propagation with HLC
-> exact happens-before reasoning with vector clocks

That is also why the ds-atleastonce demo exists in the repo. It ties the ideas together in one deterministic distributed simulation:

  • an order is emitted as a root event
  • downstream services process it idempotently
  • retries are driven by Timeouts
  • messages carry both HLC and vector clock metadata
  • simulated time advances the whole scenario deterministically

That demo is where the library feels most like a coherent system rather than a collection of helpers.

Engineering Trade-Offs

Like the reference post that inspired this one, I think it is worth being explicit about the caveats.

Simulated time is deliberately opinionated

SimulatedTimeProvider optimizes for determinism, not for pretending to be a perfect clone of the operating system clock. Timer callbacks are driven synchronously during Advance(...), because that makes simulations reproducible and understandable.

UUIDv7 exposes approximate creation time

This is a feature and a limitation. It is great for ordering and storage locality, but it is not appropriate when you need externally opaque identifiers.

HLC and vector clocks solve different problems

HLC gives me compact, cheap, wall-time-adjacent ordering, but it does not detect concurrency. Vector clocks do detect concurrency, but they carry more metadata and cost more to compare and merge.

Overflow and drift policies are part of the design

Two examples:

  • UuidV7Factory has explicit overflow behavior because generating more than 4096 values in one millisecond is a real design boundary.
  • HlcGuidFactory exposes drift settings because some systems want strict enforcement while others prefer throughput and monotonicity.

I wanted those trade-offs to be visible in the API instead of hidden as accidental behavior.

Summary

In the initial section of this post I tried to build the theoretical foundation for Clockworks: time as a dependency, the split between wall time and scheduler time, and the difference between simple ordering and actual causality.

In the subsequent section, I moved into the practical engineering that falls out of that model: deterministic timers, TimeProvider-driven timeouts, UUIDv7 generation, HLC propagation, and vector clocks.

When I started working on Clockworks, I was not trying to build "a time library" in the abstract. I was trying to make time easier to reason about in code that needed deterministic behavior, realistic simulations, and stronger ordering semantics.

That led to a fairly natural progression:

  • make time injectable with TimeProvider
  • separate wall time from scheduler time
  • make timers and timeouts deterministic
  • build time-ordered identifiers on the same foundation
  • add causal clocks for distributed workflows

If you only need one part of that stack, Clockworks is still useful. You can use SimulatedTimeProvider by itself, or just the UUIDv7 generator, or just the HLC/vector clock pieces.

But the real shape of the library is the connection between those pieces. Once time becomes something I can model explicitly, deterministic testing and distributed reasoning stop feeling like separate problems and start feeling like different layers of the same one.