Data branches as BEAM processes.
A branch is a process holding an overlay over an Ash application's data. Inside run/2, reads see the base
data patched with the branch's changes, and writes land in the overlay instead of the data layer. A branch can
be forked, diffed, merged back through the application's own actions, or discarded.
Example
Add Ramal.Supervisor to the application's supervision tree first, then:
{:ok, branch} = Ramal.branch(MyApp.Farm, name: "move herd R7 to paddock L18")
{:ok, herds} = Ramal.run(branch, fn -> Ash.read!(MyApp.Farm.Herd) end)
:ok = Ramal.discard(branch)Every function that takes a branch also takes its id, and starts the branch process from storage when it is not running, so callers never need to know whether a branch is in memory.
Summary
Functions
Computes an aggregate over the rows a read of query returns. Inside run/2 those rows are the branch's: base rows
patched with the overlay, filtered, sorted and limited as the query says. Outside a branch it is Ash.count/2,
Ash.sum/3 and the like, so the same call works in both places.
Like aggregate/4 but returns the value or raises.
Approves a proposed branch: merges it exactly as merge/2 would (staleness check, replay through the real actions,
one transaction) and records the decision on the branch: who, why, when, how many entries were replayed and which
stale rows were let through.
Creates an open branch over domain and starts its process.
The changes a branch holds, as Ramal.Change structs in the order the branch first touched each row.
Discards an open or proposed branch (a proposer withdrawing a proposal): its process stops and its overlay and log are dropped.
Forks a branch: a new open branch, with its own process, that starts with the parent's overlay, log and base.
The branch with that id, with its overlay and log as they are now.
The branches of domain as last persisted, oldest first.
Merges a branch into the database by replaying its log through the application's own actions, in one transaction.
Runs fun with the branch as the current branch of the calling process and returns {:ok, result}.
Like run/2 but returns the result and raises Ramal.Error instead of returning an error tuple.
Types
@type branch_or_id() :: Ramal.Branch.t() | String.t()
Functions
@spec aggregate( Ash.Query.t() | module(), Ramal.Aggregate.kind(), atom() | nil, keyword() ) :: {:ok, term()} | {:error, Ash.Error.t()}
Computes an aggregate over the rows a read of query returns. Inside run/2 those rows are the branch's: base rows
patched with the overlay, filtered, sorted and limited as the query says. Outside a branch it is Ash.count/2,
Ash.sum/3 and the like, so the same call works in both places.
This exists because the aggregates Ash hands to the data layer (Ash.count/2, aggregates do ... end) never see the
overlay. The answer is exact and its cost follows the overlay, not the table: the database aggregates the rows the
overlay does not touch, and the branch reads only the rows it does. A query with a limit, an offset or a
distinct is answered by reading every matching row instead, because the aggregate then depends on which rows come
first. See Ramal.Aggregate.
The rows the database aggregates are not read, so they are not fingerprinted and do not join the merge staleness check (ADR 0013): a branch that only aggregated a row and never read or wrote it does not go stale when that row changes.
kind is :count, :exists, :sum, :min, :max or :avg; field is an attribute (not needed for :count and
:exists). opts go to the read: :actor, :authorize?, :tenant, :domain. Returns {:ok, value} or the error
Ash returned. Raises ArgumentError for an unknown kind or a field that is not an attribute, in a branch or not.
Confirmed against Ash 3.37 (lib/ash.ex): Ash.count/2 takes no field, and the others take (query, field, opts).
Example
{:ok, branch} = Ramal.branch(MyApp.Farm)
Ramal.run!(branch, fn ->
Ash.create!(MyApp.Farm.Herd, %{name: "R10", head: 5})
{:ok, total} = Ramal.aggregate(MyApp.Farm.Herd, :sum, :head)
total
#=> the base total plus 5
end)
@spec aggregate!( Ash.Query.t() | module(), Ramal.Aggregate.kind(), atom() | nil, keyword() ) :: term()
Like aggregate/4 but returns the value or raises.
Example
Ramal.run!(branch, fn -> Ramal.aggregate!(MyApp.Farm.Herd, :count) end)
@spec approve(branch_or_id(), keyword()) :: {:ok, Ramal.Merge.Result.t()} | {:error, Ramal.Error.t()}
Approves a proposed branch: merges it exactly as merge/2 would (staleness check, replay through the real actions,
one transaction) and records the decision on the branch: who, why, when, how many entries were replayed and which
stale rows were let through.
Takes the options of merge/2, so :actor is the actor the replayed actions run as, and two more:
:decided_by- who approves it, defaultnil:reason- why, a string, defaultnil
A stale base or a failing replay returns the same errors as merge/2 and leaves the branch :proposed, so the
decider can retry with on_stale: :ignore or reject/2 it. Returns :not_open for a branch that is not proposed.
Example
{:ok, %Ramal.Merge.Result{branch: branch}} =
Ramal.approve(proposed, decided_by: manager, actor: manager, reason: "checked on site")
branch.proposal.decision.status
#=> :approved
@spec branch(module(), keyword()) :: {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}
Creates an open branch over domain and starts its process.
Options
:name- a label for people:metadata- a map the host uses freely
Returns {:error, %Ramal.Error{reason: :not_started}} when Ramal.Supervisor is not running, and
:no_branchable_resources when no resource of the domain uses Ramal.Resource (or all of them opted out), since
such a branch could never change what a read returns, and :limit_reached when the supervisor's
:max_active_branches or :branch_rate is reached (both off by default, see Ramal.Supervisor).
Example
{:ok, branch} = Ramal.branch(MyApp.Farm, name: "what if", metadata: %{ticket: 42})
branch.status
#=> :open
@spec current() :: Ramal.Branch.t() | nil
The branch the calling process is running in, or nil outside run/2.
Example
Ramal.current()
#=> nil
Ramal.run!(branch, fn -> Ramal.current().id end)
#=> branch.id
@spec diff(branch_or_id()) :: {:ok, [Ramal.Change.t()]} | {:error, Ramal.Error.t()}
The changes a branch holds, as Ramal.Change structs in the order the branch first touched each row.
Never queries the database: updated and deleted rows are compared with the copy the branch kept when it first wrote them. A row the branch changed and then changed back is left out. A fork's diff includes what it inherited.
Example
{:ok, [change]} = Ramal.diff(branch)
change.kind
#=> :updated
change.changes
#=> %{head: {30, 31}}
@spec discard(branch_or_id()) :: :ok | {:error, Ramal.Error.t()}
Discards an open or proposed branch (a proposer withdrawing a proposal): its process stops and its overlay and log are dropped.
The record stays in storage with status :discarded, so list/2 can show it and later calls return
:not_open. Discarding an already discarded branch is a no-op. A merged branch cannot be discarded.
Example
{:ok, branch} = Ramal.branch(MyApp.Farm)
:ok = Ramal.discard(branch)
{:error, %Ramal.Error{reason: :not_open}} = Ramal.run(branch, fn -> :never end)
@spec fork(branch_or_id(), keyword()) :: {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}
Forks a branch: a new open branch, with its own process, that starts with the parent's overlay, log and base.
The fork sees the parent's changes as of the moment of the fork and nothing after; the parent never sees the fork's.
Both share the same database. metadata.forked_from holds the parent's id. A fork counts against the same limits
as branch/2 (:limit_reached), counted for the parent's domain.
Options
:name- default: the parent's name:metadata- default: the parent's metadata
Example
{:ok, branch} = Ramal.branch(MyApp.Farm)
{:ok, fork} = Ramal.fork(branch, name: "variant")
fork.metadata.forked_from == branch.id
#=> true
@spec get(String.t()) :: {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}
The branch with that id, with its overlay and log as they are now.
Example
{:ok, branch} = Ramal.branch(MyApp.Farm)
{:ok, ^branch} = Ramal.get(branch.id)
@spec list(module(), keyword()) :: {:ok, [Ramal.Branch.t()]} | {:error, Ramal.Error.t()}
The branches of domain as last persisted, oldest first.
This reads storage and never loads a process, so a branch that is in memory shows the state of its last persist
(its creation, its last unload, or its status change). Use get/1 for the live state.
Options
:status- only branches with that status (:open,:proposed,:merged,:rejected,:discarded)
Example
{:ok, open} = Ramal.list(MyApp.Farm, status: :open)
@spec merge(branch_or_id(), keyword()) :: {:ok, Ramal.Merge.Result.t()} | {:error, Ramal.Error.t()}
Merges a branch into the database by replaying its log through the application's own actions, in one transaction.
First every base row the branch read or wrote is compared with what the database holds now; if any changed, the
merge is refused unless on_stale allows it. Then the log is replayed oldest first with real validations and
policies. If anything fails, nothing is written and the branch stays open. On success the branch is :merged, its
process stops, and the result holds what the replay wrote. See Ramal.Merge for the details and the limits.
Options
:actor- the actor for every replayed action. Default: the actor each write was captured with.:authorize?- defaulttrue:on_stale-:error(default),:ignore, or a function from the stale rows to:continueor:abort:mode- only:replay
Example
{:ok, branch} = Ramal.branch(MyApp.Farm)
Ramal.run!(branch, fn -> Ash.update!(herd, %{head: 31}, authorize?: false) end)
{:ok, %Ramal.Merge.Result{replayed: 1}} = Ramal.merge(branch, authorize?: false)
@spec propose(branch_or_id(), keyword()) :: {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}
Proposes an open branch for a decision: the branch is frozen (status :proposed, no more reads, writes or forks) and
persisted, with who proposed it and why. Someone with the right to decide then calls approve/2, which merges it, or
reject/2. The proposer can still discard/1 it to withdraw it.
Ramal does not decide who may approve: the host checks that before calling approve/2 or reject/2, in its own
policies. The people involved are whatever terms the host passes (a user struct, an id); a record loses its
sensitive? attributes when stored (ADR 0010).
Options
:proposed_by- who proposes it, defaultnil:reason- why, a string, defaultnil
Returns {:error, %Ramal.Error{reason: :not_open}} for a branch that is not open, including one already proposed.
List the pending ones with list(domain, status: :proposed); the diff to review is diff/1.
Example
{:ok, branch} = Ramal.branch(MyApp.Farm)
Ramal.run!(branch, fn -> Ash.update!(herd, %{head: 31}, authorize?: false) end)
{:ok, proposed} = Ramal.propose(branch, proposed_by: user, reason: "R7 has 31 head after the count")
proposed.status
#=> :proposed
@spec reject(branch_or_id(), keyword()) :: {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}
Rejects a proposed branch: the decision is recorded and persisted, the branch becomes :rejected and its process
stops. The overlay and log are kept so the rejected diff can still be read with get/1 and diff/1; discard/1
does not apply to a rejected branch.
Options
:decided_by- who rejects it, defaultnil:reason- why, a string, defaultnil
Returns :not_open for a branch that is not proposed.
Example
{:ok, rejected} = Ramal.reject(proposed, decided_by: manager, reason: "count it again")
rejected.status
#=> :rejected
@spec run(branch_or_id(), (-> result)) :: {:ok, result} | {:error, Ramal.Error.t()} when result: term()
Runs fun with the branch as the current branch of the calling process and returns {:ok, result}.
The previous state is restored when fun returns and also when it raises, throws or exits. Branches do not nest:
calling run/2 inside another run/2 returns {:error, %Ramal.Error{reason: :nested_branch}} without running
fun. Tasks started inside fun do not inherit the branch; call run/2 inside the task with the same branch.
Returns :not_found for an unknown id and :not_open for a merged or discarded branch.
Creates, updates and destroys inside fun land in the branch instead of the data layer (Ramal.Capture), and the
notifications they produce are dropped unless the resource sets notify_in_branch? true.
Example
{:ok, branch} = Ramal.branch(MyApp.Farm)
{:ok, nil} = Ramal.run(branch, fn -> Ramal.current() && nil end)
@spec run!(branch_or_id(), (-> result)) :: result when result: term()
Like run/2 but returns the result and raises Ramal.Error instead of returning an error tuple.
Example
{:ok, branch} = Ramal.branch(MyApp.Farm)
Ramal.run!(branch, fn -> :done end)
#=> :done