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
@type decision() :: (Sourced.Projection.state() -> verdict())
@type opts() :: [{:attempts, pos_integer()}]
@type projections() :: Sourced.Projection.t() | %{optional(Sourced.Projection.key()) => Sourced.Projection.t()}
@type verdict() :: {:ok, [Sourced.EventStore.Behaviour.event() | struct()]} | :noop | {:error, term()}
What a decision concludes from the state it was handed.
Functions
@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 to3.1disables retrying.