Ambient.Clock (Ambient v0.1.0)

Copy Markdown View Source

An overridable wall clock.

In production, utc_now/0 delegates to DateTime.utc_now/0. In tests, a per-process override set via set/1 or advance/1 lets time-dependent code (interval growth, same-day vs cross-day rollover, expiry, aging) be driven deterministically – without Process.sleep or hand-rolled date math.

Inheritance

The override lives in Ambient.ProcessOverride, so any child process spawned by the test inherits the clock through:

So async: true tests stay isolated, and a scenario that fans out to a background worker still observes the frozen test clock.

Production code

Ambient.Clock.utc_now()
Ambient.Clock.utc_today()

Use these instead of direct DateTime.utc_now/0 / Date.utc_today/0 calls anywhere time must be testable. A custom Credo check can enforce the convention project-wide.

Test usage

Ambient.Clock.set(~U[2026-01-01 09:00:00Z])
Ambient.Clock.advance(days: 1)
Ambient.Clock.reset()

# For a long-lived process that must read the test clock:
Ambient.Clock.allow(genserver_pid)

Summary

Functions

Advance the clock and return the new time.

Authorise child_pid to read owner_pid's overrides. For long-lived processes that don't appear in the $callers chain.

Drop every override this process owns in this module's table.

Drop this process's override for key. No-op if there isn't one.

Current UTC time as a NaiveDateTime, derived from utc_now/0. Use instead of NaiveDateTime.utc_now/0.

Current DateTime in the given timezone, derived from utc_now/0. Mirrors the return shape of DateTime.now/1 (requires a configured time zone database) so it's a drop-in replacement.

Whether a clock override is currently in effect for this process.

Whether an override for key is in scope for the calling process.

Set a process-local override for key. Auto-cleaned when the process exits.

Drop the override; utc_now/0 returns to the real clock.

Freeze the clock at dt for the current process (and its children).

Return this module's table to private, process-scoped mode.

Make owner_pid's overrides the ones every process reads. async: false only – see Ambient.ProcessOverride.set_shared/2.

Current UTC time. Delegates to DateTime.utc_now/0 unless overridden – and in a build without overrides compiled in, is DateTime.utc_now/0, with no lookup at all.

Today's UTC Date, derived from utc_now/0 so the clock override applies. Use instead of Date.utc_today/0.

Functions

advance(seconds)

@spec advance(integer() | keyword()) :: DateTime.t()

Advance the clock and return the new time.

Accepts a keyword list with any of :seconds, :minutes, :hours, :days (summed) – or a bare integer treated as seconds. Values may be negative to move backwards.

Ambient.Clock.advance(days: 1)
Ambient.Clock.advance(hours: 1, minutes: 30)   # 5400 seconds
Ambient.Clock.advance(-90)                     # back 90 seconds

allow(child_pid, owner_pid \\ self())

@spec allow(pid(), pid()) :: :ok

Authorise child_pid to read owner_pid's overrides. For long-lived processes that don't appear in the $callers chain.

delete_all()

@spec delete_all() :: :ok

Drop every override this process owns in this module's table.

delete_override(key)

@spec delete_override(term()) :: :ok

Drop this process's override for key. No-op if there isn't one.

naive_utc_now()

@spec naive_utc_now() :: NaiveDateTime.t()

Current UTC time as a NaiveDateTime, derived from utc_now/0. Use instead of NaiveDateTime.utc_now/0.

now(time_zone)

@spec now(Calendar.time_zone()) :: {:ok, DateTime.t()} | {:error, term()}

Current DateTime in the given timezone, derived from utc_now/0. Mirrors the return shape of DateTime.now/1 (requires a configured time zone database) so it's a drop-in replacement.

overridden?()

@spec overridden?() :: boolean()

Whether a clock override is currently in effect for this process.

overridden?(key)

@spec overridden?(term()) :: boolean()

Whether an override for key is in scope for the calling process.

put_override(key, value)

@spec put_override(term(), term()) :: :ok

Set a process-local override for key. Auto-cleaned when the process exits.

reset()

@spec reset() :: :ok

Drop the override; utc_now/0 returns to the real clock.

set(dt)

@spec set(DateTime.t()) :: DateTime.t()

Freeze the clock at dt for the current process (and its children).

set_private()

@spec set_private() :: :ok

Return this module's table to private, process-scoped mode.

set_shared(owner_pid \\ self())

@spec set_shared(pid()) :: :ok

Make owner_pid's overrides the ones every process reads. async: false only – see Ambient.ProcessOverride.set_shared/2.

utc_now()

@spec utc_now() :: DateTime.t()

Current UTC time. Delegates to DateTime.utc_now/0 unless overridden – and in a build without overrides compiled in, is DateTime.utc_now/0, with no lookup at all.

utc_today()

@spec utc_today() :: Date.t()

Today's UTC Date, derived from utc_now/0 so the clock override applies. Use instead of Date.utc_today/0.