Current section

Files

Jump to
bpe lib BPE.ex
Raw

lib/BPE.ex

defmodule BPE do
@moduledoc """
Business Process Engine (BPE).
This module serves as the primary entry point for managing BPMN 2.0 processes.
It handles process lifecycle, persistence, state transitions, events, gateways,
and history execution trace routing. BPE connects abstract BPMN workflows to
actual executable code.
"""
require Record
@doc """
Compiles BPMN XML file into executable Erlang records.
"""
defmacro xml(source) do
mod = __CALLER__.module
file = Mix.Project.app_path() <> source
proc = Macro.escape(BPE.XML.load(to_charlist(file), mod))
quote do: unquote(proc)
end
for {name, fields} <- Record.extract_all(from_lib: "bpe/include/bpe.hrl") do
Record.defrecord(name, fields)
end
for {name, fields} <- Record.extract_all(from_lib: "bpe/include/doc.hrl") do
Record.defrecord(name, fields)
end
for {name, fields} <- Record.extract_all(from_lib: "kvs/include/kvs.hrl") do
Record.defrecord(name, fields)
end
for {name, fields} <- Record.extract_all(from_lib: "kvs/include/metainfo.hrl") do
Record.defrecord(name, fields)
end
# ── private helpers ────────────────────────────────────────────────────────
defp shutdown_timeout, do: :application.get_env(:bpe, :shutdown_timeout, 5_000)
defp call_timeout, do: :application.get_env(:bpe, :timeout, 6_000)
defp driver, do: :application.get_env(:bpe, :driver, :exclusive)
defp bpe_id(record_name) do
case :application.get_env(:kvs, :dba_seq, :kvs_rocks) do
:kvs_rocks -> :kvs.seq([], [])
_ -> :kvs.seq(record_name, 1)
end
end
# Private helper: get sched record(s) for a process
defp proc_sched(process(id: proc_id)), do: :kvs.all(key("/bpe/flow/", proc_id))
defp proc_sched(proc_id) when is_binary(proc_id) or is_list(proc_id),
do: :kvs.all(key("/bpe/flow/", proc_id))
defp proc_sched(step(proc: proc_id) = s) do
k =
case :application.get_env(:kvs, :dba, :kvs_mnesia) do
:kvs_rocks -> key("/bpe/flow/", proc_id)
:kvs_mnesia -> :sched
end
case :kvs.get(k, s) do
{:ok, x} -> x
_ -> []
end
end
# ── key helpers ────────────────────────────────────────────────────────────
@doc "Constructs a step key or passes through unmodified PID keys."
def key({:step, n, [208 | _] = pid}), do: {:step, n, :erlang.list_to_binary(pid)}
def key(pid), do: pid
@doc "Creates a composite binary key out of a prefix and a PID."
def key(prefix, pid), do: :erlang.iolist_to_binary([prefix, pid, "/"])
# ── load / cleanup ─────────────────────────────────────────────────────────
@doc """
Loads a BPE process instance by its ID.
"""
def load(id), do: load(id, [])
@doc """
Loads a BPE process instance by its ID, returning a default value if not found.
"""
def load(id, default) do
case :application.get_env(:kvs, :dba, :kvs_mnesia) do
:kvs_mnesia ->
case :kvs.get(:process, id) do
{:ok, p} -> p
{:error, _} -> default
end
:kvs_rocks ->
case :kvs.get("/bpe/proc", id) do
{:ok, p} -> p
{:error, _} -> default
end
end
end
@doc "Cleans up history, scheduling data, and process record for a given process ID."
def cleanup(p) do
for hist(id: id) <- proc_hist(process(p, :id)), do: :kvs.delete("/bpe/hist", id)
:kvs.delete(:writer, key("/bpe/hist/", p))
for sched(id: id) <- proc_sched(p), do: :kvs.delete("/bpe/flow", id)
:kvs.delete(:writer, key("/bpe/flow/", p))
:kvs.delete("/bpe/proc", p)
end
@doc "Marks the process as deleted, removes its active record and unblocks gateways."
def delete(process(id: pid, parent: parent, monitor: mid) = proc) do
gw_unblock(pid)
unsubscribe(pid, parent)
:kvs.remove(proc, "/bpe/proc")
case :kvs.get(key("/bpe/mon/", mid), pid) do
{:ok, x} -> :kvs.remove(x, key("/bpe/mon/", mid))
_ -> []
end
:kvs.append(process(proc, status: "deleted"), "/bpe/deleted")
process(proc, status: "deleted")
end
# ── current task ───────────────────────────────────────────────────────────
@doc "Retrieves the current task currently being executed by the process instance."
def current_task(process(id: id) = proc) do
case head(id) do
[] -> {:empty, first_task(proc)}
hist(id: {:step, h, _}, task: sequenceFlow(target: t)) -> {h, t}
hist(id: {:step, h, _}, task: t) -> {h, t}
end
end
# ── trace / history ────────────────────────────────────────────────────────
@doc "Appends a trace event to the process history."
def add_trace(proc, name, task) do
add_hist(key("/bpe/hist/", process(proc, :id)), proc, name, task)
end
@doc "Appends an error to the process history and logs a notice."
def add_error(proc, name, task) do
:logger.notice(~c"BPE: Error for PID ~ts: ~p ~p", [process(proc, :id), name, task])
add_hist(key("/bpe/error/", process(proc, :id)), proc, name, task)
end
@doc "Appends an entry to the general history track for the process."
def add_hist(k, process(executors: executors) = proc, name, task) do
w = :kvs.writer(k)
h = hist(
id: key({:step, writer(w, :count), process(proc, :id)}),
name: name,
time: ts(time: :calendar.local_time()),
docs: process(proc, :docs),
task: task,
executors: executors
)
:kvs.append(h, k)
h
end
@doc "Appends a scheduling state representation to the process flow."
def add_sched(proc, pointer, state) do
k = key("/bpe/flow/", process(proc, :id))
w = :kvs.writer(k)
:kvs.append(
sched(
id: key({:step, writer(w, :count), process(proc, :id)}),
pointer: pointer,
state: state
),
k
)
end
# ── start / monitors ───────────────────────────────────────────────────────
@doc """
Starts a BPE process instance.
If the second argument is an empty list, it initializes using the documents in the process record.
Otherwise, it starts using the specified options.
"""
def start(process(docs: docs) = proc, []) do
start(proc, docs, {[], procRec()})
end
def start(proc, options) do
start(proc, options, {[], procRec()})
end
@doc "Starts an instance under `BPE.OTP` dynamically, binding to its given task."
def start(proc0, options, {monitor, proc_rec}) do
id =
:erlang.iolist_to_binary([
case process(proc0, :id) do
[] -> bpe_id(:process)
x -> x
end
])
{hist_val, task} = current_task(process(proc0, id: id))
pid_notif = :proplists.get_value(:notification, options, :undefined)
s_proc =
process(proc0,
id: id,
docs: options,
notifications: pid_notif,
modified: ts(time: :calendar.local_time()),
started: ts(time: :calendar.local_time())
)
proc =
case hist_val do
:empty ->
hist(task: stage) = add_trace(s_proc, [], task)
add_sched(s_proc, 1, [first_flow(s_proc)])
process(s_proc, stage: stage)
_ -> s_proc
end
child_spec = {id, {BPE.Proc, :start_link, [proc]}, :transient, shutdown_timeout(), :worker, [BPE.Proc]}
case cache(:terminateLocks, {:terminate, id}) do
p when is_pid(p) ->
mon = :erlang.monitor(:process, p)
receive do
{:DOWN, ^mon, :process, ^p, _} ->
receive do {:EXIT, ^p, _r} -> :ok after 10 -> :shutdown end
:erlang.unlink(p)
:erlang.demonitor(mon)
after shutdown_timeout() ->
:logger.error(~c"BPE SHUTDOWN TIMEOUT: ~tp", [id])
:erlang.unlink(p)
:erlang.demonitor(mon)
end
_ -> []
end
case :supervisor.start_child(BPE.OTP, child_spec) do
{:ok, _} -> mon_link(monitor, proc, proc_rec); {:ok, id}
{:ok, _, _} -> mon_link(monitor, proc, proc_rec); {:ok, id}
{:error, :already_present} -> :supervisor.restart_child(BPE.OTP, id); {:ok, id}
{:error, reason} -> {:error, reason}
end
end
defp terminate_lock(pid) do
id = :erlang.integer_to_binary(:erlang.unique_integer([:positive, :monotonic]))
:kvs.put(terminateLock(id: id, pid: pid), kvs(mod: :kvs_mnesia))
id
end
@doc "Links a monitor to a newly started or existing BPE process."
def mon_link(mon, proc, proc_rec), do: mon_link(mon, proc, proc_rec, false)
def mon_link([], proc, _, _), do: :kvs.append(proc, "/bpe/proc")
@doc false
def mon_link(monitor(parent: []) = m, process(parentMonitor: pmid) = p, pr, e) when pmid != [] do
mon_link(monitor(m, parent: pmid), p, pr, e)
end
@doc false
def mon_link(monitor(id: mid, parent: pmid) = m, process(id: proc_id) = proc, proc_rec, embedded) do
k = key("/bpe/mon/", mid)
:kvs.append(m, "/bpe/monitors")
update_parent_monitor(m)
memo_proc =
if embedded do
proc
else
msg_id = terminate_lock(proc_id)
:gen_server.call(pid(proc_id), {msg_id, :mon_link, mid})
end
:kvs.append(process(memo_proc, monitor: mid, parentMonitor: pmid), "/bpe/proc")
:kvs.append(procRec(proc_rec, id: proc_id), k)
p = process(memo_proc, monitor: mid, parentMonitor: pmid)
if embedded do
p
else
msg_id2 = terminate_lock(proc_id)
:gen_server.call(pid(proc_id), {msg_id2, :set, p})
p
end
end
defp update_parent_monitor(monitor(parent: pmid, creator: c) = x) do
cr = if c == [], do: "default", else: c
case :kvs.get("/bpe/monitors", pmid) do
{:ok, monitor()} -> :kvs.append(x, key(key("/bpe/submonitors/", pmid), cr))
_ -> []
end
end
@doc "Retrieves children monitors."
def mon_children(mid), do: :kvs.all(key("/bpe/mon/", mid))
@doc "Fetches the `pid` of the given process ID from the registry cache."
def pid(id), do: cache(:processes, {:process, :erlang.iolist_to_binary([id])})
@doc "Ensures the given process has a valid monitor attached."
def ensure_mon(process(monitor: [], id: id) = proc) do
mon = monitor(id: bpe_id(:monitor))
proc_rec = procRec()
{mon, mon_link(mon, proc, procRec(proc_rec, id: id), true)}
end
@doc false
def ensure_mon(process(monitor: mid) = proc) do
case :kvs.get("/bpe/monitors", mid) do
{:error, x} -> throw({:error, x})
{:ok, mon} -> {mon, proc}
end
end
# ── API: proc / update / persist ──────────────────────────────────────────
@doc "Retrieves the current process state state by process ID."
def proc(proc_id) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :get}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc "Updates the process instance with the new internal state."
def update(proc_id, state) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :set, state}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def update(proc_id, state, continue) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :set, state, continue}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc "Persists the modified process layout and states."
def persist(proc_id, state) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :persist, state}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def persist(proc_id, state, continue) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :persist, state, continue}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
# ── assign ─────────────────────────────────────────────────────────────────
@doc "Assigns the process to an executor, starting it if not yet started."
def assign(proc_id) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :ensure_mon}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def assign(proc_id, continue) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :ensure_mon, continue}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
# ── complete ───────────────────────────────────────────────────────────────
@doc "Directs the execution to complete the current stage and step."
def complete(proc_id) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :complete}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def complete(proc_id, [continue() | _] = cont) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :complete, cont}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc "Directs the execution to complete the process task at a given explicit stage."
def complete(proc_id, stage) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :complete, stage}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def complete(proc_id, stage, continue) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :complete, stage, continue}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
# ── next ───────────────────────────────────────────────────────────────────
@doc "Steps the process state to the next task in the workflow."
def next(proc_id) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :next}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def next(proc_id, [continue() | _] = cont) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :next, cont}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc "Steps explicitly to a given target stage directly."
def next(proc_id, stage) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :next, stage}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def next(proc_id, stage, continue) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :next, stage, continue}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
# ── amend / discard / modify ───────────────────────────────────────────────
@doc "Amends a document form to the process instance."
def amend(proc_id, form) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :amend, form}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def amend(proc_id, form, continue) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :amend, form, continue}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc "Discards a given document form from the process instance."
def discard(proc_id, form) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :discard, form}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def discard(proc_id, form, continue) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :discard, form, continue}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc "Modifies existing process arguments or documents."
def modify(proc_id, form, arg) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :modify, form, arg}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def modify(proc_id, form, arg, continue) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :modify, form, arg, continue}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
# ── events ─────────────────────────────────────────────────────────────────
@doc "Sends a synchronous message event to the process instance."
def message_event(proc_id, event) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :messageEvent, event}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def message_event(proc_id, event, continue) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.call(pid(proc_id), {id, :messageEvent, event, continue}, call_timeout())
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc "Casts an asynchronous message event to the process instance."
def async_event(proc_id, event) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.cast(pid(proc_id), {id, :asyncEvent, event})
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc false
def async_event(proc_id, event, continue) do
id = terminate_lock(proc_id)
start(load(proc_id), [])
try do
:gen_server.cast(pid(proc_id), {id, :asyncEvent, event, continue})
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
@doc "Broadcasts an event message topic to its subscribed process elements."
def broadcast_event(topic, broadcastEvent(type: :immediate) = ev) do
Enum.each(
:kvs.index(:subscription, :topic, topic, kvs(mod: :kvs_mnesia)),
fn subscription(who: sub_pid) ->
id = terminate_lock(sub_pid)
start(load(sub_pid), [])
try do
:gen_server.cast(pid(sub_pid), {id, :broadcastEvent,
broadcastEvent(ev, id: :kvs.seq([], []), topic: topic)})
catch
:exit, {:normal, _} -> {:exit, :normal}
_, z -> {:error, z}
end
end
)
end
@doc false
def broadcast_event(topic, broadcastEvent() = ev) do
Enum.each(
:kvs.index(:subscription, :topic, topic, kvs(mod: :kvs_mnesia)),
fn subscription(who: sub_pid) ->
:kvs.append(
broadcastEvent(ev, id: :kvs.seq([], []), topic: topic),
key("/bpe/messages/queue/", sub_pid)
)
end
)
end
# ── gateway block / subscribe ──────────────────────────────────────────────
@doc "Blocks progression at a particular gateway pending an event."
def gw_block(bpe_pid, gw, subject) do
case :kvs.index_match(gw_block(id: :_, pid: bpe_pid, subject: subject, gw: gw), :pid, kvs(mod: :kvs_mnesia)) do
[] -> :kvs.put(gw_block(id: :kvs.seq([], []), pid: bpe_pid, subject: subject, gw: gw), kvs(mod: :kvs_mnesia))
_ -> :exist
end
end
@doc "Unblocks all gateway blocks for a process."
def gw_unblock(bpe_pid) do
:kvs.index_match(gw_block(id: :_, pid: bpe_pid, subject: :_, gw: :_), :pid, kvs(mod: :kvs_mnesia))
|> Enum.each(fn gw_block(id: id) -> :kvs.delete(:gw_block, id, kvs(mod: :kvs_mnesia)) end)
end
@doc "Unblocks a specific gateway target for a process instance."
def gw_unblock(bpe_pid, gw, subject) do
:kvs.index_match(gw_block(id: :_, pid: bpe_pid, subject: subject, gw: gw), :subject, kvs(mod: :kvs_mnesia))
|> Enum.each(fn gw_block(id: id) -> :kvs.delete(:gw_block, id, kvs(mod: :kvs_mnesia)) end)
end
@doc "Subscribes a process element to a pub-sub topic."
def subscribe(bpe_pid, topic) do
case :kvs.index_match(subscription(id: :_, who: bpe_pid, topic: topic), :who, kvs(mod: :kvs_mnesia)) do
[] -> :kvs.put(subscription(id: :kvs.seq([], []), who: bpe_pid, topic: topic), kvs(mod: :kvs_mnesia))
_ -> :exist
end
end
@doc "Unsubscribes a process element from a topic."
def unsubscribe(bpe_pid, topic) do
:kvs.index_match(subscription(id: :_, who: bpe_pid, topic: topic), :who, kvs(mod: :kvs_mnesia))
|> Enum.each(fn subscription(id: id) -> :kvs.delete(:subscription, id, kvs(mod: :kvs_mnesia)) end)
end
# ── syn helpers ────────────────────────────────────────────────────────────
@doc "Publishes a message to a registered process pool."
def send(pool, message), do: :syn.publish(:devices, :erlang.term_to_binary(pool), message)
@doc "Registers the current process in the global syn registry."
def reg(pool), do: reg(pool, :undefined)
@doc false
def reg(pool, _value) do
case :erlang.get({:pool, pool}) do
:undefined ->
:syn.join(:devices, :erlang.term_to_binary(pool), self())
:erlang.put({:pool, pool}, pool)
_ -> :skip
end
end
@doc "Unregisters the current process from the syn pool."
def unreg(pool) do
case :erlang.get({:pool, pool}) do
:undefined -> :skip
_ ->
:syn.leave(:devices, pool, self())
:erlang.erase({:pool, pool})
end
end
# ── flow / task helpers ────────────────────────────────────────────────────
@doc "Gets the first sequence flow ID of a process instance."
def first_flow(process(beginEvent: begin_event, flows: flows)) do
f = Enum.find(flows, fn f -> sequenceFlow(f, :source) == begin_event end)
sequenceFlow(f, :id)
end
@doc "Gets the target task ID of the first begin event."
def first_task(process(tasks: tasks)) do
case for beginEvent(id: n) <- tasks, do: n do
[] -> []
[name | _] -> name
end
end
# ── head / hist / sched ────────────────────────────────────────────────────
@doc "Retrieves the history head step from the process storage."
def head(proc_id) do
k =
case :application.get_env(:kvs, :dba, :kvs_mnesia) do
:kvs_rocks -> key("/bpe/hist/", proc_id)
:kvs_mnesia -> :hist
end
case :kvs.get(:writer, key("/bpe/hist/", proc_id)) do
{:ok, w} ->
case :kvs.get(k, key({:step, writer(w, :count) - 1, proc_id})) do
{:ok, x} -> x
_ -> []
end
_ -> []
end
end
@doc "Retrieves the scheduled head step from the flow storage."
def sched_head(proc_id) do
k =
case :application.get_env(:kvs, :dba, :kvs_mnesia) do
:kvs_rocks -> key("/bpe/flow/", proc_id)
:kvs_mnesia -> :sched
end
case :kvs.get(:writer, key("/bpe/flow/", proc_id)) do
{:ok, w} ->
case :kvs.get(k, key({:step, writer(w, :count) - 1, proc_id})) do
{:ok, x} -> x
_ -> []
end
_ -> []
end
end
@doc "Fetches all errors recorded for the process instance."
def errors(proc_id), do: :kvs.all(key("/bpe/error/", proc_id))
# renamed from hist/1, hist/2 to avoid clash with hist record macro
@doc "Fetches full process history based on process step or ID."
def proc_hist(step(proc: proc_id, id: n)), do: proc_hist(proc_id, n)
@doc false
def proc_hist(proc_id), do: :kvs.all(key("/bpe/hist/", proc_id))
@doc false
def proc_hist(proc_id, n) do
k =
case :application.get_env(:kvs, :dba, :kvs_mnesia) do
:kvs_rocks -> key("/bpe/hist/", proc_id)
:kvs_mnesia -> :hist
end
case :kvs.get(k, key({:step, n, proc_id})) do
{:ok, res} -> res
{:error, _} -> []
end
end
# renamed from step/2 to avoid clash with step record macro
@doc "Finds a specific step/task in the process by name."
def find_step(proc, name) do
case for t <- tasks(proc), elem(t, 1) == name, do: t do
[t] -> t
[] -> task()
e -> e
end
end
@doc "Returns documents initialized for the process."
def docs(proc) do
h = head(process(proc, :id))
hist(h, :docs)
end
@doc "Returns the process tasks."
def tasks(proc), do: process(proc, :tasks)
@doc "Returns the process flows."
def flows(proc), do: process(proc, :flows)
@doc "Returns the process events."
def events(proc), do: process(proc, :events)
@doc "Extracts an environment document mapping corresponding to the process."
def doc(r, proc) do
{x, _} = BPE.Env.find(:env, proc, r)
x
end
@doc "Locates a sequence_flow structurally by id."
def flow(flow_id, process(flows: flows)) do
Enum.find(flows, fn f -> sequenceFlow(f, :id) == flow_id end) || false
end
@doc "Extracts current flow id scheduled on a given pointer state."
def flow_id(sched(state: flows, pointer: n)), do: Enum.at(flows, n - 1)
# ── cache ──────────────────────────────────────────────────────────────────
@doc """
Manages cached values dynamically.
Deletes a cache entry if the value is `:undefined`.
Otherwise, sets a default cached value with dynamic TTL.
"""
def cache(table, key_val, :undefined), do: :ets.delete(table, key_val)
def cache(table, key_val, value) do
:ets.insert(table, {key_val, till(:calendar.local_time(), ttl()), value})
value
end
@doc "Stores a key-value pair in a cache table explicitly until `till_val` expiration."
def cache(table, key_val, value, till_val) do
:ets.insert(table, {key_val, till_val, value})
value
end
@doc "Retrieves a value from a cache table returning `:undefined` upon expiry."
def cache(table, key_val) do
val =
case :ets.lookup(table, key_val) do
[] -> :undefined
[v] -> v
vs -> vs
end
case val do
:undefined -> :undefined
{_, :infinity, x} -> x
{_, expire, x} ->
if expire < :calendar.local_time() do
:ets.delete(table, key_val)
:undefined
else
x
end
end
end
@doc "Default time-to-live for cache properties."
def ttl, do: :application.get_env(:bpe, :ttl, 60 * 15)
@doc "Calculates gregorian seconds until a ttl duration given a present timestamp."
def till(now, ttl_val) do
if is_atom(ttl_val) do
ttl_val
else
:calendar.gregorian_seconds_to_datetime(
:calendar.datetime_to_gregorian_seconds(now) + ttl_val
)
end
end
@doc "Hot reloads code and purges old application states."
def reload(module) do
case :code.get_object_code(module) do
:error -> {:load_error, module}
{^module, binary, filename} ->
case :code.load_binary(module, filename, binary) do
{:module, ^module} -> {:reloaded, module}
{:error, reason} -> {:load_error, module, reason}
end
end
end
# ── construct result ───────────────────────────────────────────────────────
@doc "Normalizes internal BPE results for proper process replies."
def construct_result(result(type: :reply, opt: [], reply: r, state: st)),
do: {:reply, r, st}
@doc false
def construct_result(result(type: :reply, opt: o, reply: r, state: st)),
do: {:reply, r, st, flatten_opt(o)}
@doc false
def construct_result(result(type: :noreply, opt: [], state: st)),
do: {:noreply, st}
@doc false
def construct_result(result(type: :noreply, opt: opt, state: st)),
do: {:noreply, st, flatten_opt(opt)}
@doc false
def construct_result(result(type: :stop, reply: [], reason: reason, state: st)),
do: {:stop, reason, st}
@doc false
def construct_result(result(type: :stop, reply: reply, reason: reason, state: st)),
do: {:stop, reason, reply, st}
@doc false
def construct_result(_),
do: {:stop, :error, "Invalid return value", []}
defp flatten_opt({:continue, c}), do: {:continue, List.flatten(c)}
defp flatten_opt(x), do: x
# ── process flow ───────────────────────────────────────────────────────────
@doc "Forced specific flow initiation from the process instance."
def process_flow(forced_flow_id, process() = proc) do
case flow(forced_flow_id, proc) do
false ->
add_error(proc, "No such sequenceFlow", forced_flow_id)
construct_result(result(type: :reply,
reply: {:error, "No such sequenceFlow", forced_flow_id}, state: proc))
forced_flow ->
threads = sched_head(process(proc, :id)) |> sched(:state)
case :string.str(threads, [forced_flow_id]) do
0 ->
add_error(proc, "Unavailable flow", forced_flow)
construct_result(result(type: :reply,
reply: {:error, "Unavailable flow", forced_flow}, state: proc))
new_pointer ->
add_sched(proc, new_pointer, threads)
add_trace(proc, "Forced Flow", forced_flow)
process_flow(proc)
end
end
end
@doc "Primary process logic runner traversing the internal scheduler pointer."
def process_flow(process() = proc) do
construct_result(process_sched(sched_head(process(proc, :id)), proc))
end
@doc false
def process_sched(sched(state: []), proc) do
result(type: :stop, reason: :normal, reply: :Final, state: proc)
end
@doc false
def process_sched(sched() = s, proc) do
f = flow(flow_id(s), proc)
src = sequenceFlow(f, :source)
dst = sequenceFlow(f, :target)
# elem index 1 = id field (0-based), shared by all TASK-based records
source_task = Enum.find(tasks(proc), fn t -> is_tuple(t) and elem(t, 1) == src end)
target_task = Enum.find(tasks(proc), fn t -> is_tuple(t) and elem(t, 1) == dst end)
module = process(proc, :module)
# elem index 6 = roles field (0-based): tuple is {rec, id, name, input, output, prompt, roles, ...}
authorized = apply(module, :auth, [elem(source_task, 6)])
process_authorized(authorized, source_task, target_task, f, s, proc)
end
@doc false
def process_authorized(false, source_task, _target_task, flow_rec, _sched_rec, proc) do
add_error(proc, "Access denied", flow_rec)
result(type: :reply, reply: {:error, "Access denied", source_task}, state: proc)
end
@doc false
def process_authorized(true, _, target_task, flow_rec,
sched(id: sched_id, pointer: pointer, state: threads), proc) do
sequenceFlow(id: next, source: src, target: dst) = flow_rec
result(state: state, reason: reason, type: status, executed: executed) = res =
BPE.Task.task_action(process(proc, :module), src, dst, proc)
inserted = get_inserted(target_task, flow_rec, sched_id, state)
new_threads = Enum.slice(threads, 0, pointer - 1) ++ inserted ++ Enum.drop(threads, pointer)
new_pointer = if pointer == length(threads), do: 1, else: pointer + length(inserted)
new_executed = add_executed(proc, executed)
add_sched(state, new_pointer, new_threads)
new_state0 = process(state, executors: executors_for(state, flow_rec))
new_result0 = result(res, state: new_state0, executed: new_executed)
hist(task: new_task) = add_trace(new_state0, [], flow_rec)
new_state = process(new_state0, stage: new_task)
new_result = result(new_result0, state: new_state)
BPE.Proc.debug(new_state, next, src, dst, status, reason)
:kvs.append(new_state, "/bpe/proc")
flow_callback(flow_rec, new_result, proc)
new_result
end
defp flow_callback(sequenceFlow(source: src, target: tgt, callbacks: [{:callback, fun} | t]) = f, res, process(module: mod) = prev) do
flow_callback(sequenceFlow(f, callbacks: t), apply(mod, fun, [{:callback, src, tgt}, res, prev]), prev)
end
defp flow_callback(sequenceFlow(source: src, target: tgt, callbacks: [{:callback, fun, mod} | t]) = f, res, prev) do
flow_callback(sequenceFlow(f, callbacks: t), apply(mod, fun, [{:callback, src, tgt}, res, prev]), prev)
end
defp flow_callback(sequenceFlow(source: src, target: tgt, callbacks: [{:callback, fun, mod, arg} | t]) = f, res, prev) do
flow_callback(sequenceFlow(f, callbacks: t), apply(mod, fun, [{:callback, src, tgt}, res, arg]), prev)
end
defp flow_callback(sequenceFlow(callbacks: []), r, _), do: r
defp flow_callback(_, r, _), do: r
defp add_executed(process(id: id, executors: prev_executors), executed0) do
k = key("/bpe/hist/", id)
w = :kvs.writer(k)
time = :calendar.local_time()
executed =
Enum.map(executed0, fn
executor(executed: []) = x -> executor(x, executed: ts(time: time))
x -> x
end)
new_executed =
Enum.map(prev_executors, fn executor(id: eid, executed: e) = r ->
case Enum.find(executed, fn executor(id: i) -> i == eid end) do
executor(executed: x) when e == [] -> executor(r, executed: x)
_ -> r
end
end)
tab =
case :application.get_env(:kvs, :dba, :kvs_mnesia) do
:kvs_rocks -> k
:kvs_mnesia -> :hist
end
case :kvs.get(tab, key({:step, writer(w, :count) - 1, id})) do
{:error, _} -> []
{:ok, hist() = h} -> :kvs.append(hist(h, executors: new_executed), k)
end
executed
end
defp executors_for(process(executors: e),
sequenceFlow(source: s, target: t, expression: {:save_executors, task_name})) do
if s == task_name or t == task_name, do: e, else: []
end
defp executors_for(process(module: module) = state, sequenceFlow(source: s, target: t)) do
if :erlang.function_exported(module, :executors, 2) do
handle_executors(apply(module, :executors, [{:request, s, t}, state]))
else
[]
end
end
defp handle_executors(execs) do
Enum.map(execs, fn executor() = r -> executor(r, received: ts(time: :calendar.local_time())) end)
end
# ── get_inserted ───────────────────────────────────────────────────────────
# output field is at 1-based tuple position 5, 0-based index 4
@doc false
def get_inserted(t, flow_rec, sched_id, proc) do
if :erlang.element(5, t) == [] do
[]
else
do_get_inserted({t, flow_rec, sched_id, proc})
end
end
defp do_get_inserted({gateway(type: :exclusive, output: out, def: []) = gw, _, _, proc}) do
case first_matched_flow(out, proc) do
[] ->
add_error(proc, "All conditions evaluate to false in exclusive gateway without default",
gateway(gw, :id))
[]
x -> x
end
end
defp do_get_inserted({gateway(type: :exclusive, output: out, def: def_flow), _, _, proc}) do
case first_matched_flow(out -- [def_flow], proc) do
[] -> [def_flow]
x -> x
end
end
defp do_get_inserted({gateway(type: type, input: in_flows, output: out), flow_rec, sched_id, _proc})
when type in [:inclusive, :parallel] do
case check_all_flows(in_flows -- [sequenceFlow(flow_rec, :id)], sched_id) do
true -> out
false -> []
end
end
defp do_get_inserted({t, _, _, proc}), do: apply(__MODULE__, driver(), [t, proc])
@doc "Evaluates exclusive path by checking conditional logic of flow sequences."
def exclusive(t, proc), do: first_matched_flow(:erlang.element(5, t), proc)
@doc "Selects the last active sequence flow path."
def last(t, _proc), do: [List.last(:erlang.element(5, t))]
@doc "Selects the first declared sequence flow path."
def first(t, _proc), do: [hd(:erlang.element(5, t))]
@doc "Randomly selects an outgoing sequence path."
def random(t, _proc) do
out = :erlang.element(5, t)
[Enum.at(out, :rand.uniform(length(out)) - 1)]
end
@doc "Checks if all incoming necessary flows have historically activated before the gateway pointer."
def check_all_flows([], _), do: true
@doc false
def check_all_flows(_, step(id: 0)), do: false
@doc false
def check_all_flows(needed, step(id: id) = sched_id) do
case proc_hist(sched_id) do
hist(task: sequenceFlow(id: fid)) ->
check_all_flows(needed -- [fid], step(sched_id, id: id - 1))
_ -> false
end
end
@doc "Helper resolving the first flow sequentially whose boolean condition meets execution."
def first_matched_flow([], _proc), do: []
@doc false
def first_matched_flow([h | flows], proc) do
case check_flow_condition(flow(h, proc), proc) do
true -> [h]
false -> first_matched_flow(flows, proc)
end
end
@doc "Evaluates declarative conditional properties embedded inside sequenceFlow definitions."
def check_flow_condition(sequenceFlow(condition: {:compare, bpe_doc_param, field, const}), proc) do
case doc(bpe_doc_param, proc) do
[] ->
add_error(proc, "No such document", bpe_doc_param)
false
docs_list when is_list(docs_list) ->
# field is 1-based (Erlang convention); convert to 0-based for elem/2
elem(hd(docs_list), field - 1) == const
end
end
@doc false
def check_flow_condition(sequenceFlow(source: gw, condition: {:service, :gw_block}), process(id: bpe_pid)) do
:kvs.index_match(gw_block(id: :_, gw: gw, subject: bpe_pid, pid: :_), :subject, kvs(mod: :kvs_mnesia)) == []
end
@doc false
def check_flow_condition(sequenceFlow(condition: {:service, fun}), process(module: module) = proc) do
apply(module, fun, [proc])
end
@doc false
def check_flow_condition(sequenceFlow(condition: {:service, fun, mod}), proc) do
apply(mod, fun, [proc])
end
@doc false
def check_flow_condition(sequenceFlow(condition: []), _proc), do: true
end