Ramal (Ramal v0.2.0)

Copy Markdown View Source

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 branch the calling process is running in, or nil outside run/2.

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.

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.

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.

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

branch_or_id()

@type branch_or_id() :: Ramal.Branch.t() | String.t()

Functions

aggregate(query, kind, field \\ nil, opts \\ [])

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

aggregate!(query, kind, field \\ nil, opts \\ [])

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

approve(branch_or_id, opts \\ [])

@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, default nil
  • :reason - why, a string, default nil

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

branch(domain, opts \\ [])

@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

current()

@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

diff(branch_or_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}}

discard(branch_or_id)

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

fork(branch_or_id, opts \\ [])

@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

get(id)

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

list(domain, opts \\ [])

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

merge(branch_or_id, opts \\ [])

@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? - default true
  • :on_stale - :error (default), :ignore, or a function from the stale rows to :continue or :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)

propose(branch_or_id, opts \\ [])

@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, default nil
  • :reason - why, a string, default nil

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

reject(branch_or_id, opts \\ [])

@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, default nil
  • :reason - why, a string, default nil

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

run(branch_or_id, fun)

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

run!(branch_or_id, fun)

@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