ALLM.Pipeline.Lifecycle (allm_pipeline v0.1.0)

Copy Markdown View Source

The run lifecycle guard, as ONE implementation with two consumers.

A PipelineRun must reach a terminal status on every exit path — success, a named failure, a raise, an exit and a throw. use ALLM.Pipeline generates that guarantee for a declared pipeline; this module is where the guarantee actually lives, so a hand-written entry point can have it by calling owned_run/4 instead of growing a third hand-rolled copy of try/rescue/catch + complete/2 + fail_pipeline_run/2.

That was not a hypothetical. Before Phase 4 the tree carried four orchestrator entry points that terminated their run on no path, and two more with no rescue at all; the DSL closed the declared ones and left the hand-written *_single helpers — which resolve their own working set and therefore have no declaration to express — one copy short.

The two consumers

ConsumerUses
ALLM.Pipeline.Dsl.Runtime.execute/4guard/2 and settle/4 separately, because resource teardown (Phase 4 D3) runs between them
A hand-written entry pointowned_run/4, which composes create → guard/2settle/4

Splitting guard/2 from settle/4 is what lets D3's ordering — stages → outcome computed → teardown → terminal write — be expressed as three statements in Runtime rather than as a flag threaded through one function.

Versus ALLM.Pipeline.Executor.finish_run/2

There are two complete-or-fail tails in this framework and the boundary is which one owns creation and the guard:

Executor.finish_run/2Lifecycle.owned_run/4
Creates the runno — you hand it one you already ownyes
Metadata argumentnoyes, from the body's return
Guards raise / exit / thrownoyes
Teardown-error mergingnovia settle/4
Returnsyour result, unchanged{:ok, value} / {:error, reason}

So a hand-written entry point that creates its own run wants owned_run/4: finish_run/2 on its own leaves every raise, exit and throw between the create and the tail unterminated, which is the defect that put four such entry points in the tree. finish_run/2 remains right for a caller that already holds an owning handle, has no metadata to write, and is already inside somebody else's guard.

Ownership

owned_run/4 mints its handle through ALLM.Pipeline.Executor.create_pipeline_run/3 and hands the body the borrowed projection, exactly as the generated run/1 does. The owning handle never leaves this module, so a body cannot complete its own parent run, and this module introduces no third mint point (PipelineRun.create/3 and PipelineRun.assume_ownership/1 remain the only two).

Summary

Types

What guard/2 produced. {:raised, …} carries the kind so settle/4 can re-raise it unchanged — reraise alone cannot, because an exit is not an exception.

What settle/4 writes. {:ok, value, metadata} completes the run with metadata and returns value; {:error, reason} and {:raised, …} fail it.

Functions

Run fun, converting a raise, an exit or a throw into {:raised, …}.

Run fun under a fresh, fully-guarded PipelineRun.

Write the run's terminal status and return the caller's value.

Types

guarded(value)

@type guarded(value) ::
  {:ok, value}
  | {:raised, :error | :exit | :throw, term(), Exception.stacktrace()}

What guard/2 produced. {:raised, …} carries the kind so settle/4 can re-raise it unchanged — reraise alone cannot, because an exit is not an exception.

settlement()

@type settlement() ::
  {:ok, term(), map()}
  | {:error, term()}
  | {:raised, :error | :exit | :throw, term(), Exception.stacktrace()}

What settle/4 writes. {:ok, value, metadata} completes the run with metadata and returns value; {:error, reason} and {:raised, …} fail it.

Functions

guard(label, fun)

@spec guard(String.t(), (-> value)) :: guarded(value) when value: var

Run fun, converting a raise, an exit or a throw into {:raised, …}.

rescue alone is insufficient and that is the whole point: two orchestrators in this tree had only a rescue, so an exit or a throw stranded their run at status = :running. label names the unit in the log line.

The failure is logged here and re-raised by settle/4, never swallowed.

owned_run(name, metadata, attrs, fun)

@spec owned_run(
  String.t(),
  map(),
  keyword(),
  (ALLM.Pipeline.PipelineRun.t() ->
     {:ok, term(), map()} | {:ok, term()} | {:error, term()})
) :: {:ok, term()} | {:error, term()}

Run fun under a fresh, fully-guarded PipelineRun.

The framework affordance for a hand-written entry point — one that resolves its own working set and therefore has no use ALLM.Pipeline declaration to express. It gives that entry point the same guarantee the generated run/1 has: the run reaches a terminal status on every exit path, including an exit and a throw.

def run_single(record, opts) do
  Lifecycle.owned_run("meeting_single", %{id: record.id}, [], fn run ->
    case process(run, record, opts) do
      {:ok, stats}     -> {:ok, stats, serialize(stats)}
      {:error, reason} -> {:error, reason}
    end
  end)
end

fun receives the borrowed run and returns {:ok, value, metadata} (complete the run with metadata, return {:ok, value}), {:ok, value} (complete with %{}), or {:error, reason} (fail the run, return {:error, reason}). attrs reaches Executor.create_pipeline_run/3 — that is where :parent_run_id and :trigger go.

settle(label, owning, arg, teardown_errors)

Write the run's terminal status and return the caller's value.

owning must be an owning handle. teardown_errors are ALLM.Pipeline.Dsl.Resource.release/2's output; when non-empty they are merged into the run's metadata under "resource_teardown_errors" on either terminal path, and they never change the status — a leaked handle is an operational fault recorded beside the work's outcome, not a failure of it (Phase 4 D3).

Returns {:ok, value, completed_run} on success and {:error, reason} on a named failure. A {:raised, …} settlement fails the run and then re-raises the original kind/reason/stacktrace, so a caller's own error handling is unaffected — this adds the terminal write those paths were missing, and nothing else.

If the terminal write itself does not land — a refused non-owning handle, or a failed update — this returns {:error, {:not_completed, reason}} rather than the work's value. Reporting {:ok, …} there would be a fail-open: the row is left at :running with no completed_at, so a success report is false, and a false success on the one function whose entire job is the terminal write is the shape hardest to notice. Not reachable from either in-tree consumer, both of which hold an owning handle by construction.