Packages

Asset-first data orchestration framework for Elixir/BEAM.

Current section

Files

Jump to
flowstone lib flowstone.ex
Raw

lib/flowstone.ex

defmodule FlowStone do
@moduledoc """
Entry point for FlowStone APIs.
"""
alias FlowStone.{DAG, Partition, Registry, RunConfig}
alias FlowStone.Workers.AssetWorker
@doc """
Return assets declared in a pipeline module.
"""
def assets(pipeline_module) do
pipeline_module.__flowstone_assets__()
end
@doc """
Build a DAG from a pipeline module.
"""
def dag(pipeline_module) do
pipeline_module
|> assets()
|> DAG.from_assets()
end
@doc """
Register all assets from a pipeline into the registry.
"""
def register(pipeline_module, opts \\ []) do
server = Keyword.get(opts, :registry, Registry)
ensure_registry(server)
Registry.register_assets(assets(pipeline_module), server: server)
end
@doc """
Materialize a single asset.
When Oban is running, this enqueues a job for async execution.
Otherwise, executes synchronously.
Returns:
- `{:ok, %Oban.Job{}}` when job is enqueued
- `:ok` when executed synchronously and successful
- `{:error, reason}` on failure
"""
def materialize(asset, opts) do
{args, run_config} = build_args(asset, opts)
if oban_running?() do
store_run_config(args["run_id"], run_config)
enqueue_asset(args)
else
perform_asset(args, run_config)
end
end
@doc """
Enqueue materialization via Oban if running; otherwise performs synchronously.
"""
def materialize_async(asset, opts) do
{args, run_config} = build_args(asset, opts)
case oban_running?() do
true ->
store_run_config(args["run_id"], run_config)
enqueue_asset(args)
false ->
perform_asset(args, run_config)
end
end
@doc """
Materialize an asset and all its dependencies in topological order.
"""
def materialize_all(asset, opts) do
registry = Keyword.get(opts, :registry, Registry)
io_opts = Keyword.get(opts, :io, [])
resource_server = Keyword.get(opts, :resource_server)
lineage_server = Keyword.get(opts, :lineage_server)
materialization_store = Keyword.get(opts, :materialization_store)
partition = Keyword.fetch!(opts, :partition)
use_repo = Keyword.get(opts, :use_repo, true)
run_id = Keyword.get_lazy(opts, :run_id, &Ecto.UUID.generate/0)
assets = Registry.list(server: registry)
{:ok, graph} = DAG.from_assets(assets)
subset = dependencies_for(asset, graph)
shared_opts = [
partition: partition,
registry: registry,
io: io_opts,
resource_server: resource_server,
lineage_server: lineage_server,
materialization_store: materialization_store,
use_repo: use_repo,
run_id: run_id
]
if oban_running?() do
subset
|> Enum.map(fn name -> materialize_async(name, shared_opts) end)
|> List.last()
else
subset
|> Enum.map(fn name -> materialize(name, shared_opts) end)
|> List.last()
end
end
@doc """
Backfill an asset across multiple partitions.
Options:
- `:partitions` - list of partitions to backfill
- `:force` - re-run even if already materialized (default: false)
- `:max_parallel` - max concurrent materializations (default: 1)
- `:timeout` - timeout for parallel execution (default: :infinity)
"""
def backfill(asset, opts) do
registry = Keyword.get(opts, :registry, Registry)
io_opts = Keyword.get(opts, :io, [])
resource_server = Keyword.get(opts, :resource_server)
lineage_server = Keyword.get(opts, :lineage_server)
materialization_store = Keyword.get(opts, :materialization_store)
use_repo = Keyword.get(opts, :use_repo, true)
force? = Keyword.get(opts, :force, false)
max_parallel = Keyword.get(opts, :max_parallel, 1)
timeout = Keyword.get(opts, :timeout, :infinity)
run_id = Keyword.get_lazy(opts, :run_id, &Ecto.UUID.generate/0)
{:ok, asset_struct} = Registry.fetch(asset, server: registry)
backfill_opts =
case asset_struct.partition_fn do
fun when is_function(fun) -> Keyword.put_new(opts, :partition_fn, fun)
_ -> opts
end
partitions = FlowStone.Backfill.generate(backfill_opts)
partitions_to_run =
Enum.reject(partitions, fn partition ->
not force? and
existing_materialization?(asset, partition, materialization_store, use_repo)
end)
oban_running = oban_running?()
shared_opts = [
registry: registry,
io: io_opts,
resource_server: resource_server,
lineage_server: lineage_server,
materialization_store: materialization_store,
use_repo: use_repo,
run_id: run_id
]
runner = fn partition ->
target_fun = if oban_running, do: &materialize_async/2, else: &materialize/2
target_fun.(asset, Keyword.put(shared_opts, :partition, partition))
end
results =
run_backfill_partitions(partitions_to_run, runner, oban_running, max_parallel, timeout)
skipped = partitions -- partitions_to_run
{:ok, %{run_id: run_id, partitions: partitions, skipped: skipped, results: results}}
end
@doc """
Register a cron schedule.
"""
def schedule(asset, opts) do
schedule = %FlowStone.Schedule{
asset: asset,
cron: Keyword.fetch!(opts, :cron),
registry: Keyword.get(opts, :registry, FlowStone.Registry),
io: Keyword.get(opts, :io, []),
resource_server: Keyword.get(opts, :resource_server, FlowStone.Resources),
lineage_server: Keyword.get(opts, :lineage_server, FlowStone.Lineage),
use_repo: Keyword.get(opts, :use_repo, true),
timezone: Keyword.get(opts, :timezone, "UTC"),
partition_fn: Keyword.get(opts, :partition, fn -> Date.utc_today() end)
}
server = Keyword.get(opts, :store, FlowStone.ScheduleStore)
ensure_schedule_store(server)
FlowStone.ScheduleStore.put(schedule, server)
:ok
end
def unschedule(asset, opts \\ []) do
server = Keyword.get(opts, :store, FlowStone.ScheduleStore)
ensure_schedule_store(server)
FlowStone.ScheduleStore.delete(asset, server)
:ok
end
def list_schedules(opts \\ []) do
server = Keyword.get(opts, :store, FlowStone.ScheduleStore)
ensure_schedule_store(server)
FlowStone.ScheduleStore.list(server)
end
defp ensure_registry(server) when is_atom(server) do
case Process.whereis(server) do
nil -> Registry.start_link(name: server)
_pid -> :ok
end
end
defp ensure_registry(_server), do: :ok
defp ensure_schedule_store(server) when is_atom(server) do
case Process.whereis(server) do
nil -> FlowStone.ScheduleStore.start_link(name: server)
_ -> :ok
end
end
defp ensure_schedule_store(_server), do: :ok
defp dependencies_for(asset_name, %{edges: edges}) do
deps = Map.get(edges, asset_name, [])
Enum.flat_map(deps, &dependencies_for(&1, %{edges: edges})) ++ [asset_name]
end
defp existing_materialization?(asset, partition, store, use_repo) do
case FlowStone.MaterializationContext.latest(asset, partition,
store: store,
use_repo: use_repo
) do
%FlowStone.Materialization{status: :success} -> true
%{status: :success} -> true
_ -> false
end
end
defp oban_running?,
do: Process.whereis(Oban.Registry) != nil and Process.whereis(Oban.Config) != nil
# Run backfill partitions with appropriate parallelism strategy
defp run_backfill_partitions(partitions, runner, true = _oban_running, _max_parallel, _timeout) do
# When Oban is running, enqueue jobs (enqueue is fast, no need for Task.async_stream)
Enum.map(partitions, runner)
end
defp run_backfill_partitions(partitions, runner, false, max_parallel, timeout)
when max_parallel > 1 do
# When running synchronously with parallelism, use Task.async_stream
partitions
|> Task.async_stream(runner, max_concurrency: max_parallel, timeout: timeout)
|> Enum.map(fn {:ok, res} -> res end)
end
defp run_backfill_partitions(partitions, runner, false, _max_parallel, _timeout) do
# When running synchronously without parallelism
Enum.map(partitions, runner)
end
# Build JSON-safe args for Oban jobs and a separate run_config for runtime values.
#
# Oban persists job args as JSON, so we can only include:
# - strings, numbers, booleans, lists, maps
#
# Runtime values (servers, functions, keywords) are stored in RunConfig
# and looked up by run_id when the job executes.
defp build_args(asset, opts) do
partition = Keyword.fetch!(opts, :partition)
run_id = Keyword.get_lazy(opts, :run_id, &Ecto.UUID.generate/0)
use_repo = Keyword.get(opts, :use_repo, true)
io_opts = Keyword.get(opts, :io, [])
# JSON-safe args for Oban
args = %{
"asset_name" => to_string(asset),
"partition" => Partition.serialize(partition),
"run_id" => run_id,
"use_repo" => use_repo
}
# Runtime config stored separately (not JSON-safe)
# For synchronous execution, we keep the original io_opts
# For async execution, the io_config is converted to a JSON-safe format
run_config = [
registry: Keyword.get(opts, :registry, Registry),
resource_server: Keyword.get(opts, :resource_server),
lineage_server: Keyword.get(opts, :lineage_server),
materialization_store: Keyword.get(opts, :materialization_store),
io_opts: io_opts,
io_config: build_io_config(io_opts)
]
{args, run_config}
end
# Convert IO options to a serializable config map
defp build_io_config(io_opts) when is_list(io_opts) do
config = Keyword.get(io_opts, :config, %{})
# Extract only JSON-safe values from the config
json_safe_config =
config
|> Enum.filter(fn {_k, v} -> json_safe?(v) end)
|> Map.new()
%{
"manager" => io_manager_name(Keyword.get(io_opts, :manager)),
"config" => json_safe_config
}
end
defp build_io_config(_), do: %{}
defp io_manager_name(nil), do: nil
defp io_manager_name(mod) when is_atom(mod), do: to_string(mod)
defp io_manager_name(other), do: to_string(other)
defp json_safe?(v) when is_binary(v), do: true
defp json_safe?(v) when is_number(v), do: true
defp json_safe?(v) when is_boolean(v), do: true
defp json_safe?(v) when is_nil(v), do: true
defp json_safe?(v) when is_atom(v), do: true
defp json_safe?(v) when is_list(v), do: Enum.all?(v, &json_safe?/1)
defp json_safe?(v) when is_map(v),
do: Enum.all?(v, fn {k, val} -> json_safe?(k) and json_safe?(val) end)
defp json_safe?(_), do: false
defp store_run_config(run_id, config) do
if Process.whereis(RunConfig) do
RunConfig.put(run_id, config)
end
:ok
end
defp enqueue_asset(args) do
AssetWorker.new(args) |> Oban.insert()
end
defp perform_asset(args, run_config) do
AssetWorker.perform(%Oban.Job{args: args}, run_config)
end
end