ADR-0002: Clock interface for deterministic, testable time¶
Status¶
Accepted.
Context¶
Every algorithm in this library is time-dependent: token buckets refill over time, GCRA computes a theoretical arrival time, sliding windows expire old requests, circuit breakers open for a timeout then probe, retries back off, and timeouts fire on deadlines.
If these components called time.Now(), time.Sleep(), and time.NewTicker()
directly, tests would have to use real wall-clock sleeps to exercise
time-based behaviour. That makes tests slow, flaky (a time.Sleep(10*time.Millisecond)
races against the scheduler), and unable to deterministically reproduce edge
cases like "exactly at the window boundary" or "a single advance that spans many
ticker intervals."
Decision¶
All time-dependent components take a clock.Clock interface rather than
calling the time package directly. The interface lives in internal/clock
(internal/clock/clock.go) and abstracts Now, Sleep, Since, Until,
NewTimer, NewTicker, and AfterFunc.
Two implementations exist:
clock.RealClock— a thin, allocation-free pass-through to the stdlibtimepackage. This is the production default.clock.ManualClock— a test double whose time advances only whenAdvance(d)is called. It deterministically fires all pending timers and tickers in order, unblocking any goroutine parked onSleepor a timer channel.
The package doc comment states this outright: it is "the most important
architectural decision in this library — it makes every algorithm fully testable
without time.Sleep."
The ManualClock implementation is deliberately careful about concurrency: it
uses a consistent lock order between Advance and Reset to avoid a
lock-order-inversion deadlock, and its ticker channel is generously buffered
(tickerChanBuffer = 1024) so a single Advance spanning many intervals can
deposit one tick per interval, coalescing (dropping) only once a consumer falls
that far behind — matching real time.Ticker semantics.
internal/clock is an internal package: it is a foundational abstraction, not
part of the public API, and carries no external compatibility guarantee (see
docs/STABILITY.md).
Consequences¶
Positive:
- Time-based tests are deterministic and instant: advance the clock to the exact instant of interest and assert, with no wall-clock sleeps and no scheduler races.
- Boundary conditions (window edges, breaker open→half-open transitions, backoff schedules) are directly and reproducibly testable.
- Production overhead is negligible —
RealClockis a zero-state pass-through to the stdlib.
Negative / trade-offs:
- Every time-dependent constructor must thread a
Clock(typically via aWithClockfunctional option), a small amount of extra plumbing. ManualClockis itself concurrent code that must be maintained carefully; its history includes fixes for lock-order inversion and ticker coalescing, which is the price of a faithful test double.- The distributed (Redis) path does not use the injected clock as the
authoritative time source — server-time mode reads the Redis server clock
in-script instead (see
ADR-0003); the injected
clock only supplies the client-side
nowand cosmetic snapshot fields there.