Packages

Run WebAssembly from Elixir. Load WASM modules in Rust, Go, C — call them like native functions.

Current section

Files

Jump to
firebird lib firebird pipeline.ex
Raw

lib/firebird/pipeline.ex

defmodule Firebird.Pipeline do
@moduledoc """
Chain multiple WASM function calls into a pipeline.
Pipelines let you compose WASM function calls where the output
of one function feeds into the next, similar to Elixir's `|>` operator.
## Examples
# Simple pipeline: add(5, 3) |> multiply(_, 2) |> factorial(_)
{:ok, instance} = Firebird.load("math.wasm")
result =
Firebird.Pipeline.new(instance)
|> Firebird.Pipeline.call(:add, [5, 3]) # => 8
|> Firebird.Pipeline.call(:multiply, [:_, 2]) # => 16 (8 * 2)
|> Firebird.Pipeline.call(:fibonacci, [:_]) # => fib(16)
|> Firebird.Pipeline.run()
# With transforms between steps
result =
Firebird.Pipeline.new(instance)
|> Firebird.Pipeline.call(:add, [10, 20])
|> Firebird.Pipeline.transform(fn [x] -> [x, x] end)
|> Firebird.Pipeline.call(:multiply, [:_, :_])
|> Firebird.Pipeline.run()
"""
defstruct instance: nil, steps: [], halted: false, error: nil
@type t :: %__MODULE__{
instance: pid(),
steps: list(),
halted: boolean(),
error: term() | nil
}
@doc """
Create a new pipeline for a WASM instance.
## Examples
pipeline = Firebird.Pipeline.new(instance)
"""
@spec new(pid()) :: t()
def new(instance) do
%__MODULE__{instance: instance, steps: []}
end
@doc """
Add a WASM function call to the pipeline.
Use `:_` in the args list to substitute the result from the previous step.
If no `:_` placeholder is used, the previous result is passed as the first argument.
## Examples
pipeline
|> Firebird.Pipeline.call(:add, [5, 3]) # First call, no substitution
|> Firebird.Pipeline.call(:multiply, [:_, 2]) # Previous result * 2
|> Firebird.Pipeline.call(:fibonacci, [:_]) # fib(previous result)
"""
@spec call(t(), atom() | String.t(), list()) :: t()
def call(%__MODULE__{} = pipeline, function, args \\ []) do
step = {:call, function, args}
%{pipeline | steps: pipeline.steps ++ [step]}
end
@doc """
Add a transform step that modifies the intermediate result using an Elixir function.
The transform function receives the result list and should return a new list.
## Examples
pipeline
|> Firebird.Pipeline.call(:add, [5, 3])
|> Firebird.Pipeline.transform(fn [x] -> [x * 2] end)
|> Firebird.Pipeline.call(:fibonacci, [:_])
"""
@spec transform(t(), (list() -> list())) :: t()
def transform(%__MODULE__{} = pipeline, func) when is_function(func, 1) do
step = {:transform, func}
%{pipeline | steps: pipeline.steps ++ [step]}
end
@doc """
Add a tap step that observes the intermediate result without modifying it.
Useful for logging or debugging.
## Examples
pipeline
|> Firebird.Pipeline.call(:add, [5, 3])
|> Firebird.Pipeline.tap(fn result -> IO.inspect(result, label: "after add") end)
|> Firebird.Pipeline.call(:multiply, [:_, 2])
"""
@spec tap(t(), (list() -> any())) :: t()
def tap(%__MODULE__{} = pipeline, func) when is_function(func, 1) do
step = {:tap, func}
%{pipeline | steps: pipeline.steps ++ [step]}
end
@doc """
Execute the pipeline and return the final result.
## Examples
{:ok, [result]} = pipeline |> Firebird.Pipeline.run()
{:error, reason} = bad_pipeline |> Firebird.Pipeline.run()
"""
@spec run(t()) :: {:ok, list()} | {:error, term()}
def run(%__MODULE__{steps: []}) do
{:error, :empty_pipeline}
end
def run(%__MODULE__{instance: instance, steps: steps}) do
Enum.reduce_while(steps, {:ok, []}, fn step, {:ok, prev_result} ->
case execute_step(instance, step, prev_result) do
{:ok, result} -> {:cont, {:ok, result}}
{:error, _} = error -> {:halt, error}
end
end)
end
@doc """
Execute the pipeline, raising on error.
## Examples
[result] = pipeline |> Firebird.Pipeline.run!()
"""
@spec run!(t()) :: list()
def run!(pipeline) do
case run(pipeline) do
{:ok, result} -> result
{:error, reason} -> raise "Pipeline failed: #{inspect(reason)}"
end
end
@doc """
Get the number of steps in the pipeline.
"""
@spec length(t()) :: non_neg_integer()
def length(%__MODULE__{steps: steps}), do: Kernel.length(steps)
# --- Private ---
defp execute_step(instance, {:call, function, args}, prev_result) do
resolved_args = resolve_args(args, prev_result)
Firebird.call(instance, function, resolved_args)
end
defp execute_step(_instance, {:transform, func}, prev_result) do
try do
{:ok, func.(prev_result)}
rescue
e -> {:error, {:transform_error, Exception.message(e)}}
end
end
defp execute_step(_instance, {:tap, func}, prev_result) do
try do
func.(prev_result)
rescue
_ -> :ok
end
{:ok, prev_result}
end
defp resolve_args([], prev_result) do
# No args specified and we have a previous result - use it as args
prev_result
end
defp resolve_args(args, prev_result) do
# Track which position of prev_result each :_ should consume
{resolved, _} =
Enum.map_reduce(args, 0, fn
:_, idx ->
val = Enum.at(prev_result, idx, 0)
{val, idx + 1}
other, idx ->
{other, idx}
end)
resolved
end
end