PropertyDamage.Replay (PropertyDamage v0.2.0)

View Source

Step-by-step replay of failure sequences for debugging.

Replay mode lets you execute a failing sequence one command at a time, inspecting the state after each step. This is invaluable for understanding exactly how the system reached the failure state.

Replay is a thin stepping shell over the Executor: every command runs through the exact same engine path as a real run (ref/placeholder resolution, settle for probe/async commands, nemesis injection, injector and mock events, projection updates, @trigger assertions, and stutter). This is what makes a recorded failure replay to the identical step sequence and state.

Usage Modes

# Get all steps at once
{:ok, steps} = PropertyDamage.replay(failure)
for step <- steps do
  IO.puts("Command #{step.index}: #{step.command_name}")
  IO.inspect(step.projections, label: "State")
end

Interactive Mode (For LiveBook/IEx)

{:ok, session} = Replay.start(failure)
{:ok, session, step} = Replay.step(session)  # Execute first command
IO.inspect(step.projections)                  # Inspect state
{:ok, session, step} = Replay.step(session)  # Next command
# ...continue stepping...
:ok = Replay.stop(session)                    # Release the adapter/queue

Jump to Failure Point

{:ok, session} = Replay.start(failure)
{:ok, session, steps} = Replay.step_to(session, failure.failed_at_index)
# Now at the exact point where the failure occurred

Step Information

Each step returns:

  • index - Command index in sequence
  • command - The command struct
  • command_name - Short name for display
  • events - Events produced by this command (in chronological order)
  • projections - Projection states after this command
  • projections_before - Projection states before this command
  • refs - Ref resolution map
  • result - :ok, {:check_failed, name, exception}, or {:error, reason}

Limitations

  • Branching sequences are not steppable. Interactive stepping is linear by nature; the fork/merge semantics of a parallel sequence cannot be reproduced one command at a time. start/2 and run/2 return {:error, :branching_replay_unsupported} for a branching shrunk_sequence. Inspect a branching failure via the FailureReport fields or re-run it through PropertyDamage.Executor.run/4.
  • Stutter config is not stored in the report. The FailureReport records the model, adapter, and sequence, but not the stutter: config a run was given. If the original run used stutter and you want it re-applied during replay, pass it via opts (:stutter_config). It is deliberately not persisted yet: stutter is not seed-deterministic (decisions consume the process :rand stream at execution time), so persisting the config alone would not make replay reproduce which commands stuttered, and a {:custom, fn} comparison cannot survive the [:safe] term decode used by PropertyDamage.Persistence. Persisting it belongs with the determinism hardening that introduces a generation-time stutter plan; see that item in the project's fix checklist. (external_markers is not a gap: real runs never set it, and external paths are derived from the event struct definitions, which are reachable from the persisted model.)

Summary

Functions

Get the current state of projections.

Format all steps for display.

Format a step for display.

Get all executed steps so far.

Get the command at a specific index (without executing).

Replay an entire failure sequence, returning all steps.

Start an interactive replay session.

Execute the next command in the sequence.

Execute commands up to (and including) the specified index.

Clean up session resources.

Types

step()

@type step() :: %{
  index: non_neg_integer(),
  command: struct(),
  command_name: String.t(),
  events: [struct()],
  projections: map(),
  projections_before: map(),
  result: :ok | {:check_failed, atom(), Exception.t()} | {:error, term()}
}

t()

@type t() :: %PropertyDamage.Replay{
  adapter: module(),
  adapter_config: map(),
  adapter_context: map() | nil,
  commands: [struct()],
  current_index: integer(),
  event_queue: pid() | nil,
  exec_state: map() | nil,
  failure: PropertyDamage.FailureReport.t(),
  model: module(),
  status: :ready | :in_progress | :completed | :failed,
  steps: [step()]
}

Functions

current_state(replay)

@spec current_state(t()) :: map()

Get the current state of projections.

format_history(steps)

@spec format_history(t() | [step()]) :: String.t()

Format all steps for display.

format_step(step)

@spec format_step(step()) :: String.t()

Format a step for display.

history(replay)

@spec history(t()) :: [step()]

Get all executed steps so far.

peek(replay, index)

@spec peek(t(), non_neg_integer()) :: {:ok, struct()} | {:error, :out_of_bounds}

Get the command at a specific index (without executing).

run(failure, opts \\ [])

@spec run(
  PropertyDamage.FailureReport.t(),
  keyword()
) :: {:ok, [step()]} | {:error, term()}

Replay an entire failure sequence, returning all steps.

This is the simplest way to replay - it executes all commands and returns structured information about each step.

Options

  • :adapter_config - Override adapter configuration
  • :stop_on_failure - Stop at first failure (default: true)
  • :stutter_config - Stutter config to apply during replay (not stored in the report)
  • :external_markers - External markers to apply during replay (not stored in the report)

Returns

{:ok, [step]} where each step contains command, events, and state info.

Example

{:ok, steps} = PropertyDamage.replay(failure)

# Find where things went wrong
Enum.each(steps, fn step ->
  IO.puts("[#{step.index}] #{step.command_name}")
  case step.result do
    :ok -> IO.puts("  -> OK")
    {:check_failed, check, _} -> IO.puts("  -> FAILED: #{check}")
    {:error, reason} -> IO.puts("  -> ERROR: #{inspect(reason)}")
  end
end)

start(failure, opts \\ [])

@spec start(
  PropertyDamage.FailureReport.t(),
  keyword()
) :: {:ok, t()} | {:error, term()}

Start an interactive replay session.

Sets up the adapter and an event queue, then leaves the session positioned before the first command. Use step/1 to advance, and stop/1 when done to tear the adapter and queue down.

Options

See run/2 for the supported options.

Example

{:ok, session} = Replay.start(failure)
{:ok, session, step1} = Replay.step(session)
{:ok, session, step2} = Replay.step(session)
:ok = Replay.stop(session)

step(session)

@spec step(t()) :: {:ok, t(), step()} | {:done, t()}

Execute the next command in the sequence.

Returns

  • {:ok, session, step} - Command executed; step.result holds the outcome, including {:check_failed, ...} / {:error, ...} when the command failed
  • {:done, session} - No more commands to execute

step_to(session, target_index)

@spec step_to(t(), non_neg_integer()) :: {:ok, t(), [step()]}

Execute commands up to (and including) the specified index.

Returns

  • {:ok, session, [step]} - Commands executed; failed commands appear as steps whose result is {:check_failed, ...} or {:error, ...}

stop(session)

@spec stop(t()) :: :ok

Clean up session resources.

Stops any pollers spawned during stepping, the event queue, tears the adapter down, and runs teardown_each/1 if the model defines it. Safe to call more than once.