PropertyDamage.Model.Projection.Liveness (PropertyDamage v0.2.0)

View Source

Projection that tracks pending operations and asserts progress (liveness).

Traditional SPBT focuses on safety properties - invariants that must never be violated. Liveness properties are different: they guarantee that something good eventually happens.

Safety vs Liveness

Property TypeExampleDetection
Safety"Balance never goes negative"State assertion
Liveness"Every request eventually completes"Timeout on pending

What This Projection Detects

  • Deadlock - System hangs forever, no invariant violated
  • Livelock - System is busy but makes no useful progress
  • Starvation - One client always succeeds, another never does
  • Infinite retry loops - System keeps retrying without bound

How It Works

  1. Track commands that start operations (e.g., CreateTransfer)
  2. Track events that complete operations (e.g., TransferCompleted, TransferFailed)
  3. Periodically check for operations pending "too long"
  4. Report stuck operations as liveness violations

Configuration

defmodule MyModel do
  def assertion_projections do
    [
      {PropertyDamage.Model.Projection.Liveness, [
        max_pending_duration_ms: 10_000,
        required_completions: %{
          CreateTransfer => [TransferCompleted, TransferFailed],
          CreateOrder => [OrderConfirmed, OrderRejected]
        }
      ]}
    ]
  end
end

Options

  • :max_pending_duration_ms - How long before an operation is "stuck" (default: 10_000)
  • :required_completions - Map of command module => list of completion event modules
  • :check_interval - How often to check (in step count, default: 10)

Limitations

  • Requires wall-clock time, complicating deterministic replay
  • Timeout thresholds are arbitrary
  • Can't detect "infinite but slow progress" (livelock with occasional success)

Summary

Functions

Check for stuck operations.

Get operations that have been pending longer than the specified milliseconds.

Get count of currently pending operations.

Get list of pending operation details.

Types

pending_operation()

@type pending_operation() :: %{
  command_module: module(),
  started_at: integer(),
  command_index: non_neg_integer(),
  expected_completions: [module()]
}

t()

@type t() :: %PropertyDamage.Model.Projection.Liveness{
  check_interval: pos_integer(),
  current_step: non_neg_integer(),
  max_pending_duration_ms: pos_integer(),
  pending_operations: %{required(reference()) => pending_operation()},
  required_completions: %{required(module()) => [module()]}
}

Functions

check_liveness(state, ctx \\ %{})

@spec check_liveness(t(), map()) :: :ok | {:error, String.t()}

Check for stuck operations.

Returns :ok if no operations have been pending too long. Returns {:error, reason} if operations appear stuck.

operations_pending_longer_than(state, threshold_ms)

@spec operations_pending_longer_than(t(), pos_integer()) :: [pending_operation()]

Get operations that have been pending longer than the specified milliseconds.

pending_count(state)

@spec pending_count(t()) :: non_neg_integer()

Get count of currently pending operations.

pending_operations(state)

@spec pending_operations(t()) :: [pending_operation()]

Get list of pending operation details.