Powered by AppSignal & Oban Pro

Flow Dependencies

guides/flows/flow-dependencies.livemd

Flow Dependencies

Run this Livebook to see how Jido derives execution order from your Flow, and how to add order where no data moves. Run the cells from top to bottom.

Mix.install([
  {:jido, github: "mikehostetler/new_jido", branch: "main"}
])

Record what runs

This Action sends a message when it starts and when it finishes, so the examples can observe order. The calling process passes itself through context.

defmodule FlowDeps.Work do
  use Jido.Action, name: "flow_deps_work"

  @impl true
  def run(%{name: name} = params, %{observer: observer}) do
    send(observer, {:started, name})
    send(observer, {:finished, name})
    {:ok, Map.put(params, :done, true)}
  end
end

defmodule FlowDeps.Events do
  def drain(events \\ []) do
    receive do
      event -> drain([event | events])
    after
      0 -> Enum.reverse(events)
    end
  end
end

Data creates order

A result reference makes one component depend on another. Source order does not matter. Here "save" appears first, but it reads from "load", so "load" runs first.

defmodule FlowDeps.ByData do
  use Jido.Flow, name: "flow_deps_by_data"

  flow do
    step "save",
      action: FlowDeps.Work,
      params: %{name: "save", order: result("load", :name)}

    step "load", action: FlowDeps.Work, params: %{name: "load"}

    output result("save")
  end
end

{:ok, %{name: "save", order: "load"}} =
  Jido.Exec.run(FlowDeps.ByData, %{}, %{observer: self()})

[
  {:started, "load"},
  {:finished, "load"},
  {:started, "save"},
  {:finished, "save"}
] = FlowDeps.Events.drain()

needs adds order without data

Use needs when a component must run after another but does not read its result. It takes one name or a list of names, on any component kind.

defmodule FlowDeps.ByNeeds do
  use Jido.Flow, name: "flow_deps_by_needs"

  flow do
    step "reserve", action: FlowDeps.Work, params: %{name: "reserve"}
    step "audit", action: FlowDeps.Work, params: %{name: "audit"}

    step "charge",
      action: FlowDeps.Work,
      params: %{name: "charge"},
      needs: ["reserve", "audit"]

    output result("charge")
  end
end

{:ok, %{name: "charge"}} = Jido.Exec.run(FlowDeps.ByNeeds, %{}, %{observer: self()})

[{:started, "charge"}, {:finished, "charge"}] =
  FlowDeps.Events.drain() |> Enum.take(-2)

"charge" starts only after both others finish. "reserve" and "audit" depend on nothing, so they have no order between them.

Prefer a result reference when data moves. Use needs only for order that the data does not express, such as an external side effect.

Inspect the graph

Jido.Flow.dependencies/1 reports each component's explicit needs, reference dependencies, and the effective set that Jido uses.

{:ok, dependencies} = Jido.Flow.dependencies(FlowDeps.ByNeeds.flow())

%{needs: ["reserve", "audit"], references: [], effective: ["audit", "reserve"]} =
  dependencies["charge"]

{:ok, dependencies} = Jido.Flow.dependencies(FlowDeps.ByData.flow())

%{needs: [], references: ["load"], effective: ["load"]} = dependencies["save"]

Jido.Flow.explain/1 returns the same dependency map with the rest of the canonical Flow. See Inspect Flows.

Independent components can overlap

Components with no path between them can run at the same time. Jido.Exec.run/4 takes a :max_concurrency option. Its default comes from application configuration, or the lower of the scheduler count and 10. Set it to 1 to run one component at a time.

{:ok, %{name: "charge"}} =
  Jido.Exec.run(FlowDeps.ByNeeds, %{}, %{observer: self()}, max_concurrency: 1)

6 = FlowDeps.Events.drain() |> length()

Dependencies hold at every setting. Do not rely on order between unrelated components. See Execution.

Every component runs

The output does not choose what runs. A component whose result nothing reads still runs, and the Flow returns only after it finishes.

defmodule FlowDeps.Unread do
  use Jido.Flow, name: "flow_deps_unread"

  flow do
    step "audit", action: FlowDeps.Work, params: %{name: "audit"}
    step "charge", action: FlowDeps.Work, params: %{name: "charge"}

    output result("charge")
  end
end

{:ok, %{name: "charge"}} = Jido.Exec.run(FlowDeps.Unread, %{}, %{observer: self()})

true = {:finished, "audit"} in FlowDeps.Events.drain()

Every reference counts

Every result reference in a component's fields is a static dependency. This holds for fields that may not be used at runtime, such as a Choice option that is not selected or an operand that a short-circuit skips. Jido analyzes the graph before any data exists.

defmodule FlowDeps.Route do
  use Jido.Flow, name: "flow_deps_route"

  flow do
    step "expensive_lookup", action: FlowDeps.Work, params: %{name: "expensive_lookup"}

    choice "route" do
      option "use_lookup",
        condition: input(:deep),
        action: FlowDeps.Work,
        params: %{name: "deep", found: result("expensive_lookup", :name)}

      otherwise action: FlowDeps.Work, params: %{name: "shallow"}
    end

    output result("route")
  end
end

{:ok, %{name: "shallow"}} = Jido.Exec.run(FlowDeps.Route, %{deep: false}, %{observer: self()})

true = {:finished, "expensive_lookup"} in FlowDeps.Events.drain()

The lookup ran even though the selected fallback does not read it. To keep work lazy, put it inside the Action that needs it.

Graph errors

The compiler checks the graph. These declarations raise CompileError at the declaration line:

Problem Message
A result or needs entry names an unknown component "Flow reference points to an unknown component"
Two components depend on each other, or one reads its own result "flow dependency graph contains a cycle"
A needs list repeats a name "component needs contains a duplicate"
source = """
defmodule FlowDeps.Cycle do
  use Jido.Flow, name: "flow_deps_cycle"

  flow do
    step "first", action: FlowDeps.Work, params: %{name: result("second", :name)}
    step "second", action: FlowDeps.Work, params: %{name: result("first", :name)}
    output result("second")
  end
end
"""

error =
  try do
    Code.compile_string(source)
  rescue
    error in CompileError -> error
  end

true = Exception.message(error) =~ "flow dependency graph contains a cycle"

Effects follow dependency order

When Actions return effect requests, the Flow returns them in canonical dependency order, then by component name. Nested Flows keep their parent's position, and collections use item order. See Outputs and Effects.

Next steps