Sourced.DecisionModel (sourced v0.2.0)

Copy Markdown View Source

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}])
end

A 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

t()

: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

t()

@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

append(store, decision_model, events)

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
end

Raises ArgumentError if events is empty — a decision with nothing to append should not reach the store at all.

build(store, projections)

@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.

refresh(store, model)

@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
end

Same 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.