VirtualTimeGenServer (GenServerVirtualTime v1.0.1)

Copy Markdown View Source

A behavior module for GenServers with virtual time support.

This module wraps GenServer and provides a send_after/3 function that can work with either real time (production) or virtual time (testing).

Example

defmodule MyTimedServer do
  use VirtualTimeGenServer

  def start_link(opts) do
    VirtualTimeGenServer.start_link(__MODULE__, :ok, opts)
  end

  @impl true
  def init(:ok) do
    # Schedule a tick every 1000ms
    schedule_tick()
    {:ok, %{count: 0}}
  end

  @impl true
  def handle_info(:tick, state) do
    new_count = state.count + 1
    schedule_tick()
    {:noreply, %{state | count: new_count}}
  end

  defp schedule_tick do
    VirtualTimeGenServer.send_after(self(), :tick, 1000)
  end
end

Testing with Virtual Time - Global Clock (Coordinated Simulation)

test "server ticks correctly" do
  {:ok, clock} = VirtualClock.start_link()
  VirtualTimeGenServer.set_virtual_clock(clock)

  {:ok, server} = MyTimedServer.start_link([])

  # Advance virtual time by 5 seconds
  VirtualClock.advance(clock, 5000)

  # Server will have ticked 5 times
  assert get_count(server) == 5
end

Testing with Virtual Time - Local Clock (Isolated Simulation)

test "isolated simulation with local clock" do
  {:ok, clock} = VirtualClock.start_link()

  # Pass clock directly to this specific server
  {:ok, server} = VirtualTimeGenServer.start_link(MyTimedServer, :ok, virtual_clock: clock)

  # Advance only this server's timeline
  VirtualClock.advance(clock, 5000)

  assert get_count(server) == 5
end

Clock Configuration Options

The virtual clock can be configured in three ways (in priority order):

  1. Local Clock Injection - Pass virtual_clock: clock_pid to start_link/3:

    • Highest priority, overrides global settings
    • Useful for isolated simulations or testing components independently
    • Each server can have its own timeline
  2. Global Clock - Use set_virtual_clock/1:

    • Inherited by all child processes
    • Essential for coordinated actor systems where timing relationships matter
    • All actors share the same timeline
  3. Real Time - Pass real_time: true or use use_real_time/0:

    • Default behavior, uses Process.send_after/3
    • For production or integration tests with external systems

See the "Development Documentation" for a detailed explanation of when to use global vs local clocks.

Observing a server

Counting messages, tracing them or drawing diagrams of them is a different concern from controlling time, so this library does none of it. It offers a port instead: a module implementing VirtualTimeGenServer.Instrumentation sees every message a server receives and sends, and snapshot/1 asks it what it collected.

Summary

Functions

Turns the module into a virtual-time server.

Makes a synchronous call to a server.

Cancels a timer created with send_after/3. Uses the appropriate backend based on the current process configuration.

Sends an asynchronous request to a server.

Gets the current time backend.

Instruments the calling process with instrumentation.

Sends a message to a process after a delay in milliseconds. Uses the appropriate backend based on the current process configuration.

Sends a message immediately in virtual time.

Sets the virtual clock for the current process. All child processes will inherit this setting.

Sets the virtual clock for the current process without emitting warnings.

Sleeps for the specified duration in milliseconds.

Asks an instrumented server what its observer collected.

Uses real time (default behavior).

Uses real time without emitting warnings.

Functions

__using__(opts)

(macro)

Turns the module into a virtual-time server.

The options are handed to GenServer, so use VirtualTimeGenServer, restart: :transient means what use GenServer, restart: :transient means — which is also what makes use GenServer a mechanical rewrite.

call(server, request, timeout \\ 5000)

Makes a synchronous call to a server.

If the calling process is instrumented (see instrument/1), the message is reported to the observer before it leaves. Otherwise this is a direct passthrough to GenServer.call/3.

cancel_timer(ref)

Cancels a timer created with send_after/3. Uses the appropriate backend based on the current process configuration.

cast(server, request)

Sends an asynchronous request to a server.

If the calling process is instrumented (see instrument/1), the message is reported to the observer before it leaves. Otherwise this is a direct passthrough to GenServer.cast/2.

get_time_backend()

Gets the current time backend.

instrument(instrumentation)

@spec instrument(VirtualTimeGenServer.Instrumentation.t()) :: :ok

Instruments the calling process with instrumentation.

Every message the calling process sends through call/3 or cast/2 is then reported to the observer's on_send/2 callback. Starting a server with the :instrumentation option instruments that server in the same way, so a server reports both what it sends and what it receives.

instrumentation is a module implementing VirtualTimeGenServer.Instrumentation, or {module, context} when the callbacks need context. Pass nil to stop observing.

Example

defmodule MyServer do
  use VirtualTimeGenServer
  # ...
end

{:ok, clock} = VirtualClock.start_link()
VirtualTimeGenServer.instrument({MessageCounter, %{label: :test}})

{:ok, server} =
  VirtualTimeGenServer.start_link(MyServer, :ok, virtual_clock: clock)

VirtualTimeGenServer.call(server, :ping)

send_after(dest, message, delay_ms)

Sends a message to a process after a delay in milliseconds. Uses the appropriate backend based on the current process configuration.

send_immediately(dest, message)

Sends a message immediately in virtual time.

With virtual time, this schedules the message for the current virtual time, ensuring it gets processed in the next event cycle. With real time, this sends the message immediately.

This is useful for triggering immediate responses or state changes within the virtual time simulation.

Examples

# Send immediate message to self
VirtualTimeGenServer.send_immediately(self(), :process_now)

# Send immediate message to another process
VirtualTimeGenServer.send_immediately(other_pid, {:urgent, data})

set_virtual_clock(clock)

Sets the virtual clock for the current process. All child processes will inherit this setting.

Example

iex> {:ok, clock} = VirtualClock.start_link()
iex> VirtualTimeGenServer.set_virtual_clock(clock)
VirtualTimeBackend
iex> VirtualTimeGenServer.get_time_backend()
VirtualTimeBackend

set_virtual_clock(clock, atom, explanation)

Sets the virtual clock for the current process without emitting warnings.

Use this when you intentionally want global virtual clock behavior and understand the implications. The explanation message should describe why global clock is needed.

Example

iex> {:ok, clock} = VirtualClock.start_link()
iex> VirtualTimeGenServer.set_virtual_clock(clock, :i_know_what_i_am_doing, "coordinated simulation")
VirtualTimeBackend

sleep(duration_ms)

Sleeps for the specified duration in milliseconds.

With virtual time, this advances the actor's position in the timeline without consuming real time. With real time, this blocks using Process.sleep/1.

This is useful for simulating work or processing delays within actors.

Examples

# In a GenServer callback - simulate 100ms of processing time
def handle_call(:compute, _from, state) do
  VirtualTimeGenServer.sleep(100)  # Simulated work
  {:reply, :done, state}
end

# With virtual time: returns instantly but advances virtual clock
# With real time: blocks for 100ms

snapshot(server)

@spec snapshot(GenServer.server()) :: {:ok, term()} | {:error, :not_instrumented}

Asks an instrumented server what its observer collected.

The observer's snapshot/1 runs in the server process, so it can read state the observer keeps there. The result is the observer's own term, handed back unchanged.

Example

{:ok, clock} = VirtualClock.start_link()

{:ok, server} =
  VirtualTimeGenServer.start_link(MyServer, :ok,
    virtual_clock: clock,
    instrumentation: MessageCounter
  )

VirtualTimeGenServer.call(server, :ping)
{:ok, %{received: 1}} = VirtualTimeGenServer.snapshot(server)

start(module, init_arg, opts \\ [])

start_link(module, init_arg, opts \\ [])

stop(server, reason \\ :normal, timeout \\ :infinity)

See GenServer.stop/3.

use_real_time()

Uses real time (default behavior).

use_real_time(atom, explanation)

Uses real time without emitting warnings.

Use this when you intentionally want global real time behavior and understand the implications. The explanation message should describe why global real time is needed.

Example

iex> VirtualTimeGenServer.use_real_time(:i_know_what_i_am_doing, "production mode")
RealTimeBackend