Cracow decisions on the DomovoyCore workflow
What this notebook shows
The engine notebook priced one flat with one stage: a diamond-shaped graph
run by DomovoyCore.Engine, remembered by the store and the journal. This
notebook answers the three bullets of its last section, in order.
- A person joins the run. The stage moves into a
DomovoyCore.Workflow, after aDomovoyCore.Decisionwith the default decider,DomovoyCore.Decider.Person. The run stops, the person reads the estimate and either accepts it or sends the stage back for a re-run at the next generation.DomovoyCore.Runfolds the journal to know where it is, andDomovoyCore.Workflow.Serverowns the run as a process. - A branch is swapped without touching the graph.
runners:replaces the location model at execution time, and the engine checks the replacement against the declared input schema before anything runs. - A flaky branch retries, on a budget. One node gets an exponential,
jittered
retry:, its runner fails twice with a retryable error and succeeds on the third attempt, and the store keeps every attempt. A second run exhaustsmax_attempts, a third runs out of itsdeadline_ms, and a fourth trips thebreakerof a runner that two branches share.
This notebook stands alone: it rebuilds the estimate stage it needs, so evaluate it top to bottom on its own. If the engine notebook ran first in the same server, the cells below print redefinition warnings on the shared attached node; the new definitions are identical, and everything still runs.
Every coefficient here is illustrative, as before. Nothing is fitted to real Kraków listings.
Start a runtime
The same host as in the engine notebook: a named DomovoyCore.Runtime
owned by the notebook, which every run names.
case DomovoyCore.Runtime.start_link(name: Cracow.Runtime) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
end
alias DomovoyCore.{Choice, Context, Decision, Engine, Graph, Job, Journal, Node, Run, Stage, Store, Type, Value, Workflow}
alias DomovoyCore.Workflow.Server
The estimate stage, again
The same five runners as in the engine notebook, compressed to what this notebook needs: a float type the engine does not ship, the illustrative price model, one validator, and the diamond. Read the engine notebook for why each piece looks this way; here they are the stage the workflow runs.
defmodule Cracow.Type.Float do
@moduledoc "A `DomovoyCore.Type` for a float, such as an area in square metres."
use DomovoyCore.Type
@impl Ecto.Type
def type, do: :float
@impl DomovoyCore.Type
def cast(raw, _metadata) when is_float(raw), do: {:ok, raw}
def cast(raw, _metadata) when is_integer(raw), do: {:ok, raw * 1.0}
def cast(_raw, _metadata), do: :error
end
defmodule Cracow.Market do
@moduledoc "An illustrative price model for flats in Kraków."
@districts %{
"Stare Miasto" => 1.35,
"Kazimierz" => 1.30,
"Krowodrza" => 1.12,
"Dębniki" => 1.08,
"Podgórze" => 1.05,
"Bronowice" => 1.02,
"Prądnik Biały" => 0.95,
"Bieżanów-Prokocim" => 0.88,
"Nowa Huta" => 0.80
}
def districts, do: @districts |> Map.keys() |> Enum.sort()
def district_factor(district), do: Map.fetch!(@districts, district)
def base_value(area), do: area * (17_800 - 42 * area + 0.16 * area * area)
def layout_factor(rooms, bathrooms, area) do
crowding = max(0.0, rooms - area / 18)
1 + 0.025 * (rooms - 1) + 0.04 * (bathrooms - 1) - 0.02 * crowding * crowding
end
def location_factor(district, floor, balcony?, year_built) do
district_factor(district) * floor_factor(floor) * balcony_factor(balcony?) *
age_factor(year_built)
end
defp floor_factor(0), do: 0.96
defp floor_factor(floor) when floor >= 5, do: 1.03
defp floor_factor(_floor), do: 1.0
defp balcony_factor(true), do: 1.03
defp balcony_factor(false), do: 1.0
defp age_factor(year) when year >= 2015, do: 1.08
defp age_factor(year) when year < 1960, do: 1.02
defp age_factor(year) when year <= 1989, do: 0.94
defp age_factor(_year), do: 1.0
end
defmodule Cracow.Validator.Listing do
@moduledoc "Refuses a listing no flat in Kraków could match."
@behaviour DomovoyCore.Validator
import Ecto.Changeset
@impl DomovoyCore.Validator
def validate(changeset, %DomovoyCore.Context{}) do
changeset
|> validate_number(:rooms, greater_than_or_equal_to: 1)
|> validate_number(:bathrooms, greater_than_or_equal_to: 1)
|> validate_number(:area, greater_than: 0)
|> validate_number(:floor, greater_than_or_equal_to: 0)
|> validate_inclusion(:district, Cracow.Market.districts())
end
end
defmodule Cracow.Runner.Normalize do
@moduledoc "Checks the listing and gives one feature map to every branch."
use DomovoyCore.Runner
input do
field :rooms, DomovoyCore.Type.Integer
field :bathrooms, DomovoyCore.Type.Integer
field :area, Cracow.Type.Float
field :district, DomovoyCore.Type.String
field :floor, DomovoyCore.Type.Integer, default: 1
field :balcony, DomovoyCore.Type.Boolean, default: false
field :year_built, DomovoyCore.Type.Integer, default: 2005
end
required [:rooms, :bathrooms, :area, :district]
validators [Cracow.Validator.Listing]
@impl DomovoyCore.Runner
def run(%Input{} = listing, %DomovoyCore.Context{}) do
features =
listing
|> Map.from_struct()
|> Map.new(fn {field, value} -> {Atom.to_string(field), value} end)
|> Map.put("m2_per_room", listing.area / listing.rooms)
{:ok, features}
end
end
defmodule Cracow.Runner.AreaModel do
@moduledoc "The base value of the flat, from its area alone."
use DomovoyCore.Runner
input do
field :features, DomovoyCore.Type.Map
end
required [:features]
@impl DomovoyCore.Runner
def run(%Input{features: features}, %DomovoyCore.Context{}) do
{:ok, Cracow.Market.base_value(features["area"])}
end
end
defmodule Cracow.Runner.LayoutModel do
@moduledoc "The rooms and bathrooms factor."
use DomovoyCore.Runner
input do
field :features, DomovoyCore.Type.Map
end
required [:features]
@impl DomovoyCore.Runner
def run(%Input{features: features}, %DomovoyCore.Context{}) do
%{"rooms" => rooms, "bathrooms" => bathrooms, "area" => area} = features
{:ok, Cracow.Market.layout_factor(rooms, bathrooms, area)}
end
end
defmodule Cracow.Runner.LocationModel do
@moduledoc "The district, floor, balcony and age factor."
use DomovoyCore.Runner
input do
field :features, DomovoyCore.Type.Map
end
required [:features]
@impl DomovoyCore.Runner
def run(%Input{features: features}, %DomovoyCore.Context{}) do
%{"district" => district, "floor" => floor, "balcony" => balcony, "year_built" => year} =
features
{:ok, Cracow.Market.location_factor(district, floor, balcony, year)}
end
end
defmodule Cracow.Runner.Estimate do
@moduledoc "Joins the three terms into one price."
use DomovoyCore.Runner
input do
field :features, DomovoyCore.Type.Map
field :base, Cracow.Type.Float
field :layout, Cracow.Type.Float
field :location, Cracow.Type.Float
end
required [:features, :base, :layout, :location]
@impl DomovoyCore.Runner
def run(%Input{} = input, %DomovoyCore.Context{}) do
price = input.base * input.layout * input.location
{:ok,
%{
"price" => round(price),
"per_m2" => round(price / input.features["area"]),
"components" => %{
"base" => round(input.base),
"layout" => input.layout,
"location" => input.location
}
}}
end
end
The graph is the same diamond, and the stage now names what runs next: the decision. A stage has one control successor; a stage that branches points at a decision.
graph =
Graph.new([
Node.new(%{
name: "normalize",
runner: Cracow.Runner.Normalize,
type: Type.Map,
bind: %{
rooms: {"rooms", Type.Integer},
bathrooms: {"bathrooms", Type.Integer},
area: {"area", Cracow.Type.Float},
district: {"district", Type.String},
floor: {"floor", Type.Integer},
balcony: {"balcony", Type.Boolean},
year_built: {"year_built", Type.Integer}
}
}),
Node.new(%{
name: "area_model",
runner: Cracow.Runner.AreaModel,
type: Cracow.Type.Float,
bind: %{features: {"normalize", Type.Map}}
}),
Node.new(%{
name: "layout_model",
runner: Cracow.Runner.LayoutModel,
type: Cracow.Type.Float,
bind: %{features: {"normalize", Type.Map}}
}),
Node.new(%{
name: "location_model",
runner: Cracow.Runner.LocationModel,
type: Cracow.Type.Float,
bind: %{features: {"normalize", Type.Map}}
}),
Node.new(%{
name: "estimate",
runner: Cracow.Runner.Estimate,
type: Type.Map,
bind: %{
features: {"normalize", Type.Map},
base: {"area_model", Cracow.Type.Float},
layout: {"layout_model", Cracow.Type.Float},
location: {"location_model", Cracow.Type.Float}
}
})
])
estimate_stage = Stage.new(%{name: "estimate_price", graph: graph, next: "review_estimate"})
The workflow: a stage, then a person
The decision asks what happens next. It runs no runner and declares no
bindings; the store holds what the person reads. Two choices: take the
estimate and end the workflow, or correct the district and run the stage
again. The revise choice declares one typed input, so the person can send
a new district with the answer, and the record of that district lands in
the store at the new generation.
review =
Decision.new(%{
name: "review_estimate",
prompt: "A flat in Kraków was priced. Accept the estimate, or send the listing back with a corrected district?",
choices: [
Choice.new(%{
name: "accept",
description: "Take the estimate. The workflow ends.",
target: :halt
}),
Choice.new(%{
name: "revise",
description: "Correct the district. The stage runs again at the next generation.",
target: {:rerun, "estimate_price"},
inputs: %{"district" => Type.String}
})
]
})
The workflow holds the two vertices, the start, and the listing fields a
run begins with. Data travels through the store, not on the arrows: the
seven inputs are records the stage reads, and the choice input district
writes a record under the same name a workflow input wrote. The file
adapters keep every run under its own directory, cleared here so the
sections below start empty.
root = Path.join(System.tmp_dir!(), "domovoy_cracow_sale")
File.rm_rf!(root)
workflow =
Workflow.new!(%{
name: "cracow_sale",
vertices: %{"estimate_price" => estimate_stage, "review_estimate" => review},
start: "estimate_price",
inputs: %{
"rooms" => %{type: Type.Integer},
"bathrooms" => %{type: Type.Integer},
"area" => %{type: Cracow.Type.Float},
"district" => %{type: Type.String},
"floor" => %{type: Type.Integer, default: 1},
"balcony" => %{type: Type.Boolean, default: false},
"year_built" => %{type: Type.Integer, default: 2005}
},
store: {Store.FileSystem, [root: root]},
journal: {Journal.FileSystem, [root: root]}
})
Workflow.new!/1 checked the whole shape before anything runs: every
graph input of the stage is a workflow input, the re-run points at a
stage, and no node name collides with an input. The control picture is
two arrows; only a decision branches, and :halt draws none.
flowchart LR
estimate_price[estimate_price]
review_estimate{review_estimate}
halt((halt))
estimate_price --> review_estimate
review_estimate -->|revise| estimate_price
review_estimate -->|accept| halt
The Elixir cell regenerates the arrows from the workflow, so the picture
and the workflow cannot drift apart. The generated source carries no
labels — the static block above adds the choice names by hand — but every
arrow comes from workflow.arrows.
for arrow <- workflow.arrows, do: " #{arrow.from} --> #{arrow.to}"
A few helpers for the cells that follow: the estimate out of the store, and small tables.
defmodule Cracow.Sale do
@moduledoc "Glue between the notebook cells and the workflow."
alias DomovoyCore.{Journal, Record, Store, Value}
@doc "The value of the `estimate` node at `generation`."
def estimate(store, generation) do
{:ok, %Record{result: %Value{value: value}}} = Store.get(store, "estimate", generation)
value
end
@doc "The kinds of every journaled event, in order."
def event_kinds(journal) do
{:ok, events} = Journal.events(journal)
Enum.map(events, & &1.kind)
end
@doc "One row per record: where it sits in the store and what it holds."
def records_table(records) when is_list(records) do
for record <- records do
%{
node: record.node,
generation: record.job.generation,
attempt: record.job.attempt,
status: record.status,
result: describe(record.result)
}
end
end
def records_table(records) when is_map(records),
do: records |> Map.values() |> Enum.sort_by(& &1.node) |> records_table()
@doc "Formats a PLN amount with thousands separators."
def pln(amount) when is_number(amount) do
amount
|> round()
|> Integer.to_string()
|> String.reverse()
|> String.replace(~r/(\d{3})(?=\d)/, "\\1 ")
|> String.reverse()
|> Kernel.<>(" PLN")
end
defp describe(%Value{value: value}) when is_map(value), do: inspect(value, limit: 6)
defp describe(%Value{value: value}), do: inspect(value)
defp describe(%DomovoyCore.Error{type: type}), do: "error: #{type}"
defp describe(nil), do: ""
end
Run it to the decision
Run.start/4 casts the raw listing through the declared workflow inputs,
writes one record per input, appends :run_started and puts the cursor on
start. run_to_decision/2 steps until something waits for a person: the
stage runs its five runners, the cursor reaches the review, and the default
decider answers :await.
listing = %{
"rooms" => 3,
"bathrooms" => 1,
"area" => 58.5,
"district" => "Krowodrza",
"floor" => 3,
"balcony" => false,
"year_built" => 2010
}
run = Run.start(Cracow.Runtime, workflow, listing, job: Job.new("sale-1"))
run = Run.run_to_decision(workflow, run)
{run.status, run.cursor, run.job.generation}
The run is small: a status, a cursor and a job. The records hold the weight. This cell is the person's screen: the prompt, the offered choices with what each one does, and the estimate the store holds at generation 0.
decision = Workflow.vertex(workflow, run.cursor)
{
decision.prompt,
Enum.map(decision.choices, &%{choice: &1.name, does: &1.description}),
Cracow.Sale.pln(Cracow.Sale.estimate(run.store, 0)["price"])
}
The journal of the run tells the same story as events: the run began, the stage began, five nodes started and finished, the stage finished, and the decision waits.
Cracow.Sale.event_kinds(run.journal)
The person sends it back
A wrong choice refuses softly: the run keeps waiting with the error set, so the person can answer again. Nothing is journaled for a refusal.
refused = Run.decide(workflow, run, "maybe", %{})
{refused.status, refused.cursor, refused.error.type}
The district is wrong — the flat is in Stare Miasto, not Krowodrza — so
the person revises and sends the corrected district with the answer. The
answer is recorded under the decision, the generation rises, the district
lands in the store at generation 1, the :decided event is journaled, and
the cursor is back on the stage.
run = Run.decide(workflow, run, "revise", %{"district" => "Stare Miasto"})
{run.status, run.cursor, run.job.generation}
The stage runs again, and waits again. Generation 0 still holds the first estimate; generation 1 holds the second. The district factor rose from 1.12 to 1.35, and the price follows it.
run = Run.run_to_decision(workflow, run)
{run.status, run.cursor, run.job.generation}
gen0 = Cracow.Sale.estimate(run.store, 0)["price"]
gen1 = Cracow.Sale.estimate(run.store, 1)["price"]
{Cracow.Sale.pln(gen0), Cracow.Sale.pln(gen1)}
The journal knows where the run is
The run above waits with generation 1, but that state lives in one
variable of this notebook. Run.replay/4 rebuilds the same state from
the journal alone: it opens the store and the journal by run id, folds
every event with the pure Run.apply/2, and hands back a run a new
process can continue. The cursor, the status and the generation come from
the events, not from memory.
replayed = Run.replay(Cracow.Runtime, workflow, "sale-1")
{replayed.status, replayed.cursor, replayed.job.generation}
The person accepts the second estimate. The choice halts the workflow, so
the last event is :run_halted and the cursor is nil: there is nowhere
left to go.
run = Run.decide(workflow, run, "accept", %{})
{run.status, run.cursor, List.last(Cracow.Sale.event_kinds(run.journal))}
A process owns the run
Run is data a caller drives. DomovoyCore.Workflow.Server is a process
that drives itself: it holds one run, executes the start and every step in
tasks under the runtime, and stays responsive while they run. state/1
gives a view — workflow, run id, status, cursor, generation, error — and
never the open store or journal.
Subscribe first, then start: the journal broadcasts every event on the
run's topic, and the mailbox below collects them while the server works.
A second start/4 with the same run id, workflow name and inputs gives
the same pid instead of a second run.
server_job = Job.new("sale-server-1")
:ok = Server.subscribe(Cracow.Runtime, workflow.name, server_job.id)
{:ok, server} = Server.start(Cracow.Runtime, workflow, listing, job: server_job)
is_pid(server)
The server drives in the background, so this cell waits for the state it
wants instead of assuming it. state/1 answers {:error, :starting}
while initialization runs; then the stage runs; then the run waits.
wait_for = fn server, status ->
Enum.reduce_while(1..200, nil, fn n, _ ->
case Server.state(server) do
%{status: ^status} = view -> {:halt, view}
_ when n < 200 -> Process.sleep(50); {:cont, nil}
other -> {:halt, {:timeout, other}}
end
end)
end
awaiting = wait_for.(server, :awaiting_decision)
{awaiting.status, awaiting.cursor, awaiting.generation}
The :decision_awaited event arrived over PubSub while the server drove,
like in the engine notebook — the server sends no broadcasts of its own,
the journal append does.
receive do
{:domovoy_event, %DomovoyCore.Event{kind: :decision_awaited}} -> :awaited
after
10_000 -> :timeout
end
The person answers through the server. decide/3 applies Run.decide/4
in the server process and gives the post-decide view; the background drive
then runs the stage at generation 1 while the caller goes on. Answering
while a step still runs gives run_busy instead — the wait above is what
keeps this cell out of that race.
{:ok, decided} = Server.decide(server, "revise", %{"district" => "Kazimierz"})
{decided.status, decided.cursor, decided.generation}
Wait for the second review, accept through the server, and read the final
view. Halting journals :run_halted inside the decide call, so the view
is already finished.
awaiting_again = wait_for.(server, :awaiting_decision)
{awaiting_again.status, awaiting_again.cursor, awaiting_again.generation}
{:ok, accepted} = Server.decide(server, "accept", %{})
{accepted.status, accepted.cursor}
Server.state(server)
Stopping the server changes nothing on disk: the store and the journal
stay, keyed by workflow name and run id. resume/4 folds the journal back
into a run and starts a fresh owner for it — a cold owner replays and
carries on. The resumed view below is the finished run, rebuilt from
events.
:ok = Server.stop(server)
{:ok, resumed} = Server.resume(Cracow.Runtime, workflow, "sale-server-1")
Server.state(resumed)
Leave no processes behind for the next section.
:ok = Server.stop(resumed)
Swap a branch without touching the graph
The location model is one module behind the name "location_model". The
runners: option replaces the module at execution time — on
Engine.run/4, and through it on Run.start/4, which validates every
override before any stage runs and keeps them through decisions and
generations. This replacement declares the same input schema, a features
map, and computes a forecast: the location factor with a 10% premium.
defmodule Cracow.Runner.LocationModelPremium do
@moduledoc "A forecast: the location factor with a 10% premium."
use DomovoyCore.Runner
input do
field :features, DomovoyCore.Type.Map
end
required [:features]
@impl DomovoyCore.Runner
def run(%Input{features: features}, %DomovoyCore.Context{}) do
%{"district" => district, "floor" => floor, "balcony" => balcony, "year_built" => year} =
features
{:ok, Cracow.Market.location_factor(district, floor, balcony, year) * 1.10}
end
end
The graph, the workflow and the listing are untouched; only the execution changes. The estimate comes back exactly 10% above the standard one, under the same node names.
premium_run =
Run.start(Cracow.Runtime, workflow, listing,
job: Job.new("sale-premium-1"),
runners: %{"location_model" => Cracow.Runner.LocationModelPremium}
)
premium_run = Run.run_to_decision(workflow, premium_run)
{premium_run.status, premium_run.cursor}
standard = Cracow.Sale.estimate(run.store, 0)["price"]
premium = Cracow.Sale.estimate(premium_run.store, 0)["price"]
{standard, premium, Float.round(premium / standard, 3)}
The override survives the person's answer: deciding keeps the replacement
for the re-run, because the run carries runners and Run revalidates
nothing mid-flight.
premium_run = Run.decide(workflow, premium_run, "revise", %{"district" => "Kazimierz"})
{premium_run.status, premium_run.cursor, premium_run.job.generation}
premium_run.runners
The check runs before anything else. This replacement declares
features as a string, so its schema is incompatible with the declared
runner, and the run fails before it opens a store: status is :failed,
and there are no records and no journal — validation runs before anything
is written.
defmodule Cracow.Runner.LocationModelBroken do
@moduledoc "A replacement with an incompatible input schema."
use DomovoyCore.Runner
input do
field :features, DomovoyCore.Type.String
end
required [:features]
@impl DomovoyCore.Runner
def run(%Input{}, %DomovoyCore.Context{}), do: {:ok, 1.0}
end
broken =
Run.start(Cracow.Runtime, workflow, listing,
job: Job.new("sale-broken-1"),
runners: %{"location_model" => Cracow.Runner.LocationModelBroken}
)
{broken.status, broken.error.type, broken.error.reason}
A flaky branch retries
Back down a level, to one node of the graph. Node.new/1 takes retry:, a
DomovoyCore.Retry: max_attempts, a backoff, its jitter, a
timeout_ms per attempt and, further down, a deadline_ms per node and a
breaker per runner. This cell gives the location model an exponential
backoff on the same graph, by replacing the retry of one node — the graph
construction above is untouched.
alias DomovoyCore.Retry
flaky_retry =
Retry.new(
max_attempts: 3,
backoff: {:exponential, 250, 2, 2_000},
jitter: :equal,
timeout_ms: 5_000
)
flaky_graph = put_in(graph.nodes_by_name["location_model"].retry, flaky_retry)
flaky_graph.nodes_by_name["location_model"].retry
backoff and jitter are strategies: a module that implements
DomovoyCore.Retry.Backoff or DomovoyCore.Retry.Jitter with its options.
The struct above holds that {module, opts} form; the shorthands in the
cell are what Retry.new/2 expands. {:exponential, 250, 2, 2_000} waits
250 ms before the first retry and doubles the wait for each later one, up
to 2 s; {:fixed, ms} — or the older backoff_ms: ms — waits the same
every time. Retry.backoff_ms/3 gives the wait before the retry that
follows an attempt, and Retry.delay_ms/3 the same wait after jitter.
:equal draws from half the backoff to the whole, :full from zero
to the whole, so runs that fail together do not retry together. The
default, :none, keeps the exact backoff.
for attempt <- 1..3 do
%{
after_attempt: attempt,
backoff_ms: Retry.backoff_ms(flaky_retry, attempt),
delay_ms: Retry.delay_ms(flaky_retry, attempt)
}
end
Backoff strategies side by side
Five built-in backoffs, one row per retry. Every column starts at 250 ms;
the difference is how fast each one reaches its 2 s cap. :decorrelated
draws each wait from the base up to three times the previous one, so the
cell threads the previous wait through Retry.Info the way the engine does
inside a run — evaluate it again and that column moves, the others do not.
alias DomovoyCore.Retry.Info
backoffs = [
fixed: Retry.new(backoff: {:fixed, 250}),
linear: Retry.new(backoff: {:linear, 250, 250, 2_000}),
exponential: Retry.new(backoff: {:exponential, 250, 2, 2_000}),
decorrelated: Retry.new(backoff: {:decorrelated, 250, 2_000}),
schedule: Retry.new(backoff: {:schedule, [250, 250, 1_000, 2_000]})
]
# One column per strategy, ten retries deep. `previous` carries the wait of
# each strategy into its next retry, as the engine does.
{backoff_rows, _previous} =
Enum.map_reduce(1..10, %{}, fn retry, previous ->
waits =
Map.new(backoffs, fn {name, policy} ->
info = Info.new(previous_ms: previous[name])
{name, Retry.backoff_ms(policy, retry, info)}
end)
{Map.put(waits, :retry, retry), waits}
end)
backoff_rows
The same ten retries as a running total — the time a node has spent waiting
before retry n starts, with no time for the attempts themselves. This is
the number to hold against a deadline_ms: a policy whose total passes the
deadline never reaches that retry, whatever max_attempts allows.
totals =
Enum.scan(backoff_rows, %{}, fn row, sums ->
Map.new(row, fn
{:retry, retry} -> {:retry, retry}
{name, wait} -> {name, Map.get(sums, name, 0) + wait}
end)
end)
totals
With deadline_ms: 5_000, read the exponential column: its total passes
5 s at retry 5, so the engine refuses that retry and attempt 5 is the last,
even with max_attempts: 10. fixed still has retries to spare. The cell
lists, per retry, the strategies whose total still fits the budget.
for row <- totals, into: %{} do
fits = for {name, total} <- row, name != :retry, total <= 5_000, do: name
{row.retry, Enum.sort(fits)}
end
Jitter modes: desynchronizing concurrent retries
Jitter spreads the waits so that many concurrent runs do not retry in lockstep. Four built-in strategies on the same exponential backoff, one row per retry: the exact backoff, then one draw of each jitter. Evaluate the cell again and the draws move; the bounds do not.
exponential = {:exponential, 250, 2, 2_000}
jitters = [
none: Retry.new(backoff: exponential, jitter: :none),
equal: Retry.new(backoff: exponential, jitter: :equal),
full: Retry.new(backoff: exponential, jitter: :full),
proportional_20: Retry.new(backoff: exponential, jitter: {:proportional, 0.2})
]
for retry <- 1..10 do
jitters
|> Map.new(fn {name, policy} -> {name, Retry.delay_ms(policy, retry)} end)
|> Map.put(:retry, retry)
end
:none(default): exact wait. All 100 concurrent runs retry together.:equal: between half the backoff and the full backoff. Spreads retries across a narrower range — good for gentle de-synchronization.:full: between zero and the full backoff. Spreads retries across the widest range — good when you want maximum separation of retry storms.{:proportional, 0.2}: the backoff plus or minus a fifth. The one that may wait longer than the backoff, so its total against a deadline can be a fifth higher than the:nonecolumn above.
Why jitter, and why a decorrelated backoff, in one table. A hundred runs
fail at the same moment and retry three times. The cell adds up the waits
of each run and asks, for every policy, how many of the hundred come back
inside the same 100 ms window at their third retry — the size of the
largest herd — and how many windows the hundred spread over. :none sends
all hundred together. The jitters spread them, :full the most, but
every run on its third retry still draws from the same band of the
exponential curve. :decorrelated grows each wait from the previous one
instead of the attempt number, so runs that drew differently once diverge
further at every later retry, and a run that drew a long wait can draw a
short one next: the range always starts at the base. Evaluate the cell a
few times.
herd_policies =
jitters ++ [decorrelated: Retry.new(backoff: {:decorrelated, 250, 2_000})]
# When does run `i` come back for its third retry? The sum of its three waits,
# each grown from the previous one the way the engine does.
third_retry_at = fn policy ->
for _run <- 1..100 do
{waits, _previous} =
Enum.map_reduce(1..3, nil, fn retry, previous ->
wait = Retry.delay_ms(policy, retry, Info.new(previous_ms: previous))
{wait, wait}
end)
Enum.sum(waits)
end
end
for {name, policy} <- herd_policies do
windows = policy |> third_retry_at.() |> Enum.frequencies_by(&div(&1, 100))
%{
policy: name,
largest_herd: Enum.max(Map.values(windows)),
windows_used: map_size(windows),
earliest_ms: windows |> Map.keys() |> Enum.min() |> Kernel.*(100),
latest_ms: windows |> Map.keys() |> Enum.max() |> Kernel.*(100)
}
end
That is the trade: :full breaks up the herd well and :decorrelated
better still, over a wider and less predictable span. Marc Brooker's
simulation for the AWS Architecture Blog, where the decorrelated jitter
comes from, found the two close in completion time with the decorrelated
one making slightly more calls, and both far ahead of :none and
:equal. :decorrelated is the one to reach for when many runs contend
for one shared thing and a storm costs more than a predictable curve;
Polly's recommended DecorrelatedJitterBackoffV2 and redis-py's
DecorrelatedJitterBackoff are the same idea. Exponential with :full
is the one to keep when a deadline_ms has to be budgeted, because its
running total is the :none column of the totals table at most; the
decorrelated total is not.
Every strategy draws through the random of its Retry.Info, and
Engine.run/4 takes a random_fun: that replaces it for the whole run.
The bounds of each jitter are then plain to see: a draw that always gives
zero shows the least each one waits, a draw that always gives the maximum
the most.
least = Info.new(random: fn _max -> 0 end)
most = Info.new(random: fn max -> max end)
for retry <- 1..10 do
jitters
|> Enum.flat_map(fn {name, policy} ->
[
{:"#{name}_min", Retry.delay_ms(policy, retry, least)},
{:"#{name}_max", Retry.delay_ms(policy, retry, most)}
]
end)
|> Map.new()
|> Map.put(:retry, retry)
end
A strategy of your own sees the same Retry.Info: the error that failed
the attempt, the previous wait, what is left of the deadline and the draw.
This one honours a retry_after_ms that a service put in the reason of its
error and falls back to the exponential curve otherwise — the case of a
rate limit that says when to come back.
defmodule Cracow.Retry.RetryAfter do
@moduledoc "Waits what the error asked for, or falls back to another backoff."
@behaviour DomovoyCore.Retry.Backoff
alias DomovoyCore.Retry.Backoff
@impl true
def validate(fallback: fallback), do: Backoff.validate_strategy(fallback)
def validate(_opts), do: {:error, "takes fallback, a backoff strategy"}
@impl true
def backoff_ms([fallback: fallback], attempt, info) do
case info.error do
%DomovoyCore.Error{reason: %{retry_after_ms: ms}} when is_integer(ms) and ms >= 0 ->
ms
_other ->
{module, opts} = Backoff.normalize(fallback)
module.backoff_ms(opts, attempt, info)
end
end
end
retry_after = Retry.new(max_attempts: 5, backoff: {Cracow.Retry.RetryAfter, fallback: exponential})
asked = %DomovoyCore.Error{type: :rate_limited, reason: %{retry_after_ms: 1_500}, retryable?: true}
silent = %DomovoyCore.Error{type: :location_unavailable, retryable?: true}
for {label, error} <- [asked: asked, silent: silent], retry <- 1..3 do
%{error: label, retry: retry, wait_ms: Retry.delay_ms(retry_after, retry, Info.new(error: error))}
end
The runner below fails its first two attempts and computes on the third.
It knows the attempt from the job: the engine counts attempts inside one
run, so context.job.attempt is 1, then 2, then 3. Only an error with
retryable?: true retries — a refusal like the validator's never would.
defmodule Cracow.Runner.FlakyLocation do
@moduledoc "Fails its first two attempts with a retryable error, then computes."
use DomovoyCore.Runner
alias DomovoyCore.Error
input do
field :features, DomovoyCore.Type.Map
end
required [:features]
@impl DomovoyCore.Runner
def run(%Input{features: features}, %DomovoyCore.Context{job: job}) do
if job.attempt < 3 do
{:error, Error.new(%{type: :location_unavailable, retryable?: true})}
else
%{"district" => district, "floor" => floor, "balcony" => balcony, "year_built" => year} =
features
{:ok, Cracow.Market.location_factor(district, floor, balcony, year)}
end
end
end
A fresh context with its own files, and the engine called directly with the override — the bullet's shape. The listing is cast through the graph inputs exactly as the first notebook's helper did.
retry_root = Path.join(System.tmp_dir!(), "domovoy_cracow_retry")
File.rm_rf!(retry_root)
open_retry_run = fn run_id ->
{:ok, store} =
Store.open(Store.FileSystem, run_id, workflow: workflow.name, root: retry_root)
{:ok, journal} =
Journal.open(Cracow.Runtime, Journal.FileSystem, run_id,
workflow: workflow.name,
root: retry_root
)
%Context{
job: Job.new(run_id),
workflow: workflow.name,
stage: "estimate_price",
store: store,
journal: journal,
runtime: Cracow.Runtime
}
end
retry_context = open_retry_run.("flaky-1")
typed =
Map.new(listing, fn {name, raw} ->
{name, Value.cast!(raw, Map.fetch!(flaky_graph.inputs, name))}
end)
{:ok, flaky_records} =
Engine.run(flaky_graph, typed, retry_context,
runners: %{"location_model" => Cracow.Runner.FlakyLocation}
)
Cracow.Sale.pln(flaky_records["estimate"].result.value["price"])
Every attempt is its own record, and the store keeps all three: two errors, then the value. The run succeeds because the third attempt does.
{:ok, kept} = Store.all(retry_context.store)
kept
|> Enum.filter(&(&1.node == "location_model"))
|> Enum.sort_by(& &1.job.attempt)
|> Cracow.Sale.records_table()
The journal kept the waits too: one :node_retried per backoff, carrying
the job of the attempt that comes next, "backoff_ms", the exact wait of
the strategy — 250 ms before attempt 2, 500 ms before attempt 3 — and
"wait_ms", what :equal jitter made of it: between 125 and 250 ms, then
between 250 and 500 ms. Evaluate the cell above again and the waits move;
the backoffs and the bounds do not. The [:node, :retry] telemetry event
of the same moment carries more: the two numbers, the previous wait, what
was left of the deadline, the error, and the whole policy — everything the
engine had in hand.
{:ok, retry_events} = Journal.events(retry_context.journal)
for %DomovoyCore.Event{kind: :node_retried} = event <- retry_events,
do: %{next_attempt: event.job.attempt, payload: event.payload}
An exhausted retry fails the run
The same graph and the same policy, but a runner that never recovers. Three attempts fail, the budget is spent, and the failure is terminal: the estimate never runs, so it is skipped, and the run returns the error with every record the attempts left behind.
defmodule Cracow.Runner.AlwaysDown do
@moduledoc "Never recovers: every attempt fails with a retryable error."
use DomovoyCore.Runner
alias DomovoyCore.Error
input do
field :features, DomovoyCore.Type.Map
end
required [:features]
@impl DomovoyCore.Runner
def run(%Input{}, %DomovoyCore.Context{}) do
{:error, Error.new(%{type: :location_unavailable, retryable?: true})}
end
end
down_context = open_retry_run.("down-1")
{:error, down_error, down_records} =
Engine.run(flaky_graph, typed, down_context,
runners: %{"location_model" => Cracow.Runner.AlwaysDown}
)
{down_error.type, Cracow.Sale.records_table(down_records)}
A deadline caps the budget
max_attempts counts calls; deadline_ms counts time. It is the budget of
one node inside one run: it starts with the first attempt of the node and
covers every later attempt and the waits between them. The engine refuses
an attempt that would start after the deadline — and, sooner, a retry whose
wait would end after it — with a :deadline_exceeded error. The refusal is
a terminal failure with a record of its own, so the store and the journal
show where the budget ran out.
The policy below allows ten attempts but one second in all. Without jitter the waits are exactly 200, 400 and 800 ms: three attempts fail by about 600 ms, and the fourth would start at 1 400 ms, past the deadline. The engine refuses it at once instead of waiting 800 ms for a retry it would refuse anyway, so the run fails in about 600 ms.
deadline_graph =
put_in(
graph.nodes_by_name["location_model"].retry,
Retry.new(max_attempts: 10, backoff: {:exponential, 200, 2, 5_000}, deadline_ms: 1_000)
)
deadline_context = open_retry_run.("deadline-1")
{elapsed_us, {:error, deadline_error, _deadline_records}} =
:timer.tc(fn ->
Engine.run(deadline_graph, typed, deadline_context,
runners: %{"location_model" => Cracow.Runner.AlwaysDown}
)
end)
%{
error: deadline_error.type,
reason: deadline_error.reason,
elapsed_ms: div(elapsed_us, 1_000)
}
Three attempts called the runner; the fourth never did. Its record has no
started_at, and in the journal its :node_failed has no :node_started
before it — the shape of every attempt the engine refuses before it runs.
{:ok, deadline_kept} = Store.all(deadline_context.store)
deadline_kept
|> Enum.filter(&(&1.node == "location_model"))
|> Enum.sort_by(& &1.job.attempt)
|> Enum.map(&%{attempt: &1.job.attempt, status: &1.status, started_at: &1.started_at})
{:ok, deadline_events} = Journal.events(deadline_context.journal)
for event <- deadline_events, event.subject == "location_model",
do: {event.kind, event.job.attempt, event.payload}
A breaker stops hammering a runner
A retry policy looks at one node. A breaker looks at the runner behind
it: %{threshold: n, cool_down_ms: ms} counts consecutive failed calls of
that runner inside one run, across every node that uses it with a breaker,
and opens after n. While it is open the engine refuses an attempt of the
runner with a :breaker_open error, without calling it, and the run stops.
After the cool-down the next attempt probes the runner: success closes the
breaker, failure opens it again.
Here the layout model and the location model both run
Cracow.Runner.AlwaysDown — the override checks that its input schema
matches, and both nodes take one features map — with a breaker that opens
at two failures. max_concurrency: 1 runs one node at a time, so the story
reads top to bottom: one branch fails once, the other branch fails once, the
breaker of the runner opens, and the first retry to come up is refused
before it can call the runner. The other retry is cancelled with the run.
breaker_retry =
Retry.new(
max_attempts: 5,
backoff: {:fixed, 0},
breaker: %{threshold: 2, cool_down_ms: 60_000}
)
breaker_graph = put_in(graph.nodes_by_name["layout_model"].retry, breaker_retry)
breaker_graph = put_in(breaker_graph.nodes_by_name["location_model"].retry, breaker_retry)
breaker_context = open_retry_run.("breaker-1")
{:error, breaker_error, breaker_records} =
Engine.run(breaker_graph, typed, breaker_context,
runners: %{
"layout_model" => Cracow.Runner.AlwaysDown,
"location_model" => Cracow.Runner.AlwaysDown
},
max_concurrency: 1
)
{breaker_error.type, breaker_error.reason}
The runner ran twice in the whole run, once per branch. The refused attempt
is the record with error: breaker_open; the other branch was cancelled
while it waited for its retry, and the estimate was skipped.
Cracow.Sale.records_table(breaker_records)
{:ok, breaker_events} = Journal.events(breaker_context.journal)
for event <- breaker_events, event.subject in ["layout_model", "location_model"],
do: {event.subject, event.kind, event.job.attempt, event.payload}
Where to go next
- The review here waits on
Run.decide/4in a notebook cell. In a real host a LiveView renders the prompt, the choices and the records, and callsServer.decide/3on the run's owner — the same calls as above, behind buttons. - The forecast override is hand-written. A
DomovoyCore.Deciderthat reads the estimate from the store can answer cheap flats on its own and leave only the expensive ones to a person:{:ok, choice}under a threshold,:awaitabove it. - The engine events of the first notebook never needed the journal, and
neither does a dashboard: a
Telemetry.Metrics.summary/2of thedurationof[:domovoy_core, :engine, :node, :stop]per node, next to a counter of[:domovoy_core, :engine, :node, :retry]and one of[:domovoy_core, :engine, :node, :breaker_open]per runner, is the latency and the flakiness of every branch in production.