VirtualTimeGenServer (GenServerVirtualTime v0.6.0)

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.

Summary

Functions

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.

Sets GLOBAL stats tracking for all child processes.

Warning-free version of enable_stats_tracking/0 for intentional global usage.

Gets the current global trace collector.

Gets the current time backend.

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 a GLOBAL trace collector for all child processes.

Warning-free version of set_global_trace_collector/1 for intentional global usage.

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.

Uses real time (default behavior).

Uses real time without emitting warnings.

Functions

call(server, request, timeout \\ 5000)

Makes a synchronous call to a server.

When stats tracking is enabled (in simulations), this tracks the sent message. Otherwise, it's a direct passthrough to GenServer.call with zero overhead.

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.

When stats tracking is enabled (in simulations), this tracks the sent message. Otherwise, it's a direct passthrough to GenServer.cast with zero overhead.

enable_stats_tracking()

Sets GLOBAL stats tracking for all child processes.

⚠️ WARNING: This can cause race conditions in tests and production!

Consider using test-local stats injection instead:

# ❌ Global (can cause race conditions)
VirtualTimeGenServer.enable_stats_tracking()
{:ok, server} = MyServer.start_link([])

# ✅ Test-local (isolated, safe)
{:ok, server} = MyServer.start_link([], stats_enabled: true)

For coordinated stats tracking, use global tracking intentionally. For isolated testing, use test-local stats injection.

enable_stats_tracking(atom, explanation)

Warning-free version of enable_stats_tracking/0 for intentional global usage.

get_global_trace_collector()

Gets the current global trace collector.

get_time_backend()

Gets the current time backend.

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_global_trace_collector(collector_pid)

Sets a GLOBAL trace collector for all child processes.

⚠️ WARNING: This can cause race conditions in tests and production!

Consider using test-local trace injection instead:

# ❌ Global (can cause race conditions)
VirtualTimeGenServer.set_global_trace_collector(collector_pid)
{:ok, server} = MyServer.start_link([])

# ✅ Test-local (isolated, safe)
{:ok, server} = MyServer.start_link([], trace_collector: collector_pid)

For coordinated tracing, use global collectors intentionally. For isolated testing, use test-local trace injection.

set_global_trace_collector(collector_pid, atom, explanation)

Warning-free version of set_global_trace_collector/1 for intentional global usage.

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

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