The state a decision is made on, and the condition that keeps it valid.
Building a decision model reads the slice of the stream a
Sourced.Projection describes, folds it into a state, and remembers the
sequence that slice was at. Deciding on that state and appending with
append/3 is the whole DCB write path: the append lands only if nothing
matching the query arrived in between.
{:ok, %{state: %{placed: placed?, shipped: shipped?}} = model} =
Sourced.DecisionModel.build(store, %{
placed: MyApp.Orders.placed?(1),
shipped: MyApp.Orders.shipped?(1)
})
if placed? and not shipped? do
Sourced.DecisionModel.append(store, model, [%OrderShipped{id: 1}])
endA competing writer that got there first makes that append fail with
Sourced.EventStore.OptimisticConcurrencyError. The model is stale in exactly
the events that beat it: refresh/2 folds those in, and the decision runs
again on the state the winner left behind.
The consistency boundary is the query, so it is exactly as wide as the projections the decision reads — two decisions over different slices never contend.
Summary
Types
:state is what the projection folded to, :query the slice it was folded
from, and :expected_sequence the sequence that slice was at — 0 when no
event matched.
Functions
Appends events under the condition that model is still current.
Reads store and folds a decision model out of projections.
Folds whatever has matched since model was last read into model.
Types
@type t() :: %Sourced.DecisionModel{ expected_sequence: non_neg_integer(), projection: Sourced.Projection.t(), query: Sourced.EventStore.Query.t(), state: Sourced.Projection.state() }
:state is what the projection folded to, :query the slice it was folded
from, and :expected_sequence the sequence that slice was at — 0 when no
event matched.
:projection is the composed projection that produced :state, kept so the
model can fold further events into itself. State alone cannot advance itself.
Functions
@spec append(Sourced.EventStore.t(), t(), [ Sourced.EventStore.Behaviour.event() | struct() ]) :: {:ok, non_neg_integer()} | {:error, Sourced.EventStore.Behaviour.append_error() | term()}
Appends events under the condition that model is still current.
The write half of the DCB cycle, and the one to reach for: the query and the sequence it was read at travel together, which is what makes the append conflict on exactly the slice the decision was made over.
case Sourced.DecisionModel.append(store, model, [%OrderShipped{id: 1}]) do
{:ok, sequence} -> ...
{:error, %Sourced.EventStore.OptimisticConcurrencyError{}} -> # refresh/2 and decide again
endRaises ArgumentError if events is empty — a decision with nothing to
append should not reach the store at all.
@spec build( Sourced.EventStore.t(), Sourced.Projection.t() | %{optional(Sourced.Projection.key()) => Sourced.Projection.t()} ) :: {:ok, t()} | {:error, term()}
Reads store and folds a decision model out of projections.
Takes either a Sourced.Projection or a map of them, composed with
Sourced.Projection.compose/1 so the state is keyed the same way.
The whole slice is read, and there are deliberately no :from, :to or
:limit options: a model built from part of its slice would carry an
:expected_sequence from the middle of the stream and admit an append that
should have conflicted.
@spec refresh(Sourced.EventStore.t(), t()) :: {:ok, t()} | {:error, term()}
Folds whatever has matched since model was last read into model.
A model that lost an append is stale only in the events that beat it, so the retry path is a bounded read from where the model stopped rather than another full read of the slice:
with {:error, %OptimisticConcurrencyError{}} <- append_under(model),
{:ok, model} <- Sourced.DecisionModel.refresh(store, model) do
# decide again on the state the winner left behind
endSame round trip as build/2 and a shorter fold, by as much as the slice is
long.
An empty batch leaves the model exactly as it was. A read reports the last
sequence it saw, which is 0 when nothing matched, and taking that as the
new :expected_sequence would assert that nothing has ever matched the query
— a condition no append can satisfy.
Folding forward is sound only because nothing can become visible below a
sequence already read. That comes from the adapter clamping reads to its
watermark, so it holds for anything built on Sourced.EventStore.query/2,
and only as far as the watermark has advanced.