Orkestra.Projector.Lifecycle (orkestra v0.2.0)

Copy Markdown View Source

Pure functions for projector error classification and retry decisions.

No I/O, no process state, no GenServer. All functions return plain values and are safe to call from any context, including async: true ExUnit tests.

The Phase 2 Projector GenServer calls these functions to decide:

  • How long to wait before the next retry (next_delay/2)
  • Whether to retry or park the failing event to dead-letter (classify/2)
  • Whether to halt the projector after exhausting retries (should_halt?/2)

Configuration

All three functions accept a config map (or use @default_config):

%{
  max_retries: 5,
  backoff_base_ms: 500,
  backoff_cap_ms: 30_000
}

Per D-04 in CONTEXT.md, retry count and backoff are configurable per projector. D-05 mandates that this module is pure — no I/O.

Summary

Functions

Returns :retry or :park based on the current attempt count vs max_retries.

Returns the backoff delay in milliseconds for the given attempt number (0-indexed).

Returns true when the projector should halt (attempts exhausted), false otherwise.

Types

config()

@type config() :: %{
  max_retries: non_neg_integer(),
  backoff_base_ms: non_neg_integer(),
  backoff_cap_ms: non_neg_integer()
}

Functions

classify(attempts, config \\ %{backoff_base_ms: 500, backoff_cap_ms: 30000, max_retries: 5})

@spec classify(non_neg_integer(), config()) :: :retry | :park

Returns :retry or :park based on the current attempt count vs max_retries.

Returns :retry when attempts < config.max_retries, :park when exhausted (attempts >= max_retries). Mirrors the attempts <= max_retries model in CommandEnvelope.retryable?/1 but uses strict < so the event parks exactly when retries are exhausted (D-04).

Examples

iex> Lifecycle.classify(4, %{max_retries: 5, backoff_base_ms: 500, backoff_cap_ms: 30_000})
:retry

iex> Lifecycle.classify(5, %{max_retries: 5, backoff_base_ms: 500, backoff_cap_ms: 30_000})
:park

next_delay(attempt, config \\ %{backoff_base_ms: 500, backoff_cap_ms: 30000, max_retries: 5})

@spec next_delay(non_neg_integer(), config()) :: non_neg_integer()

Returns the backoff delay in milliseconds for the given attempt number (0-indexed).

Uses integer exponential backoff: base * 2^attempt, capped at backoff_cap_ms. The shift amount is clamped to avoid integer overflow for large attempt values (BEAM integers are arbitrary-precision but the cap makes clamping a correctness signal, not an overflow guard — per RESEARCH.md Pitfall 5).

Examples

iex> Lifecycle.next_delay(0, %{backoff_base_ms: 500, backoff_cap_ms: 30_000, max_retries: 5})
500

iex> Lifecycle.next_delay(1, %{backoff_base_ms: 500, backoff_cap_ms: 30_000, max_retries: 5})
1_000

should_halt?(attempts, config \\ %{backoff_base_ms: 500, backoff_cap_ms: 30000, max_retries: 5})

@spec should_halt?(non_neg_integer(), config()) :: boolean()

Returns true when the projector should halt (attempts exhausted), false otherwise.

The halt decision (ERR-03) is made when attempts >= config.max_retries. The Phase 2 GenServer calls this after parking the failing event to decide whether to stop the projector or continue processing.

Examples

iex> Lifecycle.should_halt?(5, %{max_retries: 5, backoff_base_ms: 500, backoff_cap_ms: 30_000})
true

iex> Lifecycle.should_halt?(4, %{max_retries: 5, backoff_base_ms: 500, backoff_cap_ms: 30_000})
false