Sourced.Decider (sourced v0.2.0)

Copy Markdown View Source

Runs a decision: read the slice, decide on it, append under the condition that it is still current — and when it is not, fold in whatever beat you and decide again.

Sourced.Decider.decide(store, %{placed: placed?(1), shipped: shipped?(1)}, fn
  %{placed: false} -> {:error, :not_placed}
  %{shipped: true} -> :noop
  _state -> {:ok, [%OrderShipped{id: 1}]}
end)

Sugar over Sourced.DecisionModel, which stays fully usable by hand — this closes the loop for the common case, it is not the only way to write one.

The decision function

It is handed the folded state and returns one of:

  • {:ok, events} — append them under the model's condition.
  • :noop — there is nothing to do. An empty {:ok, []} means the same thing: on a retry the writer that beat you may have already done the work, and a decision that correctly concludes so is a success, not a failure.
  • {:error, reason} — the decision refuses. Returned as-is, and never retried; a domain rejection is not a race.

It must be pure. It runs once per attempt, so anything with an effect in it happens once per attempt too. Effects belong after decide/4 returns, on the events it appended.

Retrying

Only Sourced.EventStore.OptimisticConcurrencyError is retryable — every other error comes straight back. A conflict means a competing append already committed, so the stale model is refreshed with Sourced.DecisionModel.refresh/2 rather than rebuilt, and the decision runs again on the state the winner left behind.

There is no backoff, deliberately: the contended state is readable the instant the error arrives, and waiting does not improve the odds. Jitter to break up a herd of writers conflicting on one slice is a different thing, and belongs to the application.

Attempts default to three, and giving up returns the last conflict.

Not inside your own transaction

decide/4 owns the append, so it cannot be called from inside a transaction you opened — see "Appending inside your own transaction" in Sourced.EventStore.Postgres. A conflict there aborts the whole transaction, which leaves the loop nothing to retry into, and the failure is invisible until two writers actually collide. Use Sourced.DecisionModel.append/3 directly in that case and let the transaction be the unit that gets retried.

Summary

Types

What a decision concludes from the state it was handed.

Functions

Decides over projections against store and appends what decision returns.

Types

decision()

@type decision() :: (Sourced.Projection.state() -> verdict())

opts()

@type opts() :: [{:attempts, pos_integer()}]

projections()

@type projections() ::
  Sourced.Projection.t()
  | %{optional(Sourced.Projection.key()) => Sourced.Projection.t()}

verdict()

@type verdict() ::
  {:ok, [Sourced.EventStore.Behaviour.event() | struct()]}
  | :noop
  | {:error, term()}

What a decision concludes from the state it was handed.

Functions

decide(store, projections, decision, opts \\ [])

@spec decide(Sourced.EventStore.t(), projections(), decision(), opts()) ::
  {:ok, non_neg_integer()} | :noop | {:error, term()}

Decides over projections against store and appends what decision returns.

Returns {:ok, sequence} for the last appended event, :noop when the decision concluded there was nothing to do, or {:error, reason} — the decision's own refusal, a store error, or the final Sourced.EventStore.OptimisticConcurrencyError once the attempts run out.

Options

  • :attempts — how many times to decide, counting the first. Defaults to 3. 1 disables retrying.