anotherpit

anotherpit

Workflow: downstream dependency on a graft is dropped when the grafting workflow is itself grafted (nested graft)

Summary

A downstream job that deps on a graft correctly waits for the grafted jobs at one graft level, but stops waiting when the workflow containing that graft is itself grafted into a parent workflow (a nested / graft-within-a-graft). The downstream job cascades as soon as the grafter node finishes, before the grafted jobs run.

This looks adjacent to the 1.7.7 fix “Wait for sub-workflows grafted via add_workflow/4 — that fix holds for a single graft level but does not extend to a second level of nesting.

Environment

  • oban_pro 1.7.7
  • oban 2.22.1
  • ecto_sql 3.13.5, postgrex 0.22.2
  • Elixir 1.20.0 / OTP 27, PostgreSQL 16
  • engine: Oban.Pro.Engines.Smart

Expected vs actual

A workflow with an inner graft node :inner (which grafts a :leaf job) and a :consumer with deps: [:inner]:

  • Expected: :consumer waits for the grafted :leaf in all cases.
  • Actual: it waits when the workflow is inserted directly (1 graft level), but not when the same workflow is grafted in by an outer grafter (2 graft levels) — :consumer runs before :leaf.

Minimal reproduction

Single-file script (own repo + Oban instance, no app code). Needs a Postgres via DATABASE_URL (defaults to a local one). Deps: {:oban, "~> 2.22"}, {:oban_pro, "~> 1.7", repo: "oban"}, {:ecto_sql, "~> 3.12"}, {:postgrex, ">= 0.0.0"}. Run with mix run --no-start repro.exs.

Application.put_env(:repro, Repro.Repo,
  url: System.get_env("DATABASE_URL") || "postgres://postgres:postgres@localhost/repro_dev",
  pool_size: 10
)

defmodule Repro.Repo do
  use Ecto.Repo, otp_app: :repro, adapter: Ecto.Adapters.Postgres
end

defmodule Repro.Migration do
  use Ecto.Migration

  def up do
    Oban.Migration.up()
    Oban.Pro.Migration.up()
  end

  def down do
    Oban.Pro.Migration.down()
    Oban.Migration.down()
  end
end

# Shared recorder to observe execution order across worker processes.
defmodule Repro.Recorder do
  def start, do: Agent.start_link(fn -> %{} end, name: __MODULE__)
  def put(key, val), do: Agent.update(__MODULE__, &Map.put(&1, key, val))
  def get(key), do: Agent.get(__MODULE__, &Map.get(&1, key))
end

# The grafted job. Sleeps so a consumer with no real dependency runs before it finishes.
defmodule Repro.Leaf do
  use Oban.Pro.Worker, queue: :default, max_attempts: 1

  @impl Oban.Pro.Worker
  def process(%Oban.Job{args: %{"run" => run}}) do
    Process.sleep(400)
    Repro.Recorder.put({run, :leaf_done}, true)
    :ok
  end
end

# Downstream consumer. Records whether :leaf had finished at the moment it ran.
defmodule Repro.Consumer do
  use Oban.Pro.Worker, queue: :default, max_attempts: 1

  @impl Oban.Pro.Worker
  def process(%Oban.Job{args: %{"run" => run}}) do
    Repro.Recorder.put({run, :consumer_saw_leaf}, Repro.Recorder.get({run, :leaf_done}) == true)
    :ok
  end
end

# Inner grafter: grafts :leaf into the workflow it is running inside.
defmodule Repro.InnerGrafter do
  use Oban.Pro.Worker, queue: :default, max_attempts: 1

  @impl Oban.Pro.Worker
  def process(%Oban.Job{args: %{"run" => run}}) do
    Oban.Pro.Workflow.new()
    |> Oban.Pro.Workflow.add(:leaf, Repro.Leaf.new(%{run: run}))
    |> Oban.Pro.Workflow.apply_graft()
    |> Oban.insert_all()

    :ok
  end
end

# "gen" workflow, built identically in both scenarios: an inner graft that spawns :leaf,
# and a :consumer that depends on that graft.
defmodule Repro.Gen do
  def workflow(run) do
    Oban.Pro.Workflow.new()
    |> Oban.Pro.Workflow.add_graft(:inner, Repro.InnerGrafter.new(%{run: run}))
    |> Oban.Pro.Workflow.add(:consumer, Repro.Consumer.new(%{run: run}), deps: [:inner])
  end
end

# Outer grafter: grafts the WHOLE gen workflow in — this is the second graft level.
defmodule Repro.OuterGrafter do
  use Oban.Pro.Worker, queue: :default, max_attempts: 1

  @impl Oban.Pro.Worker
  def process(%Oban.Job{args: %{"run" => run}}) do
    Repro.Gen.workflow(run)
    |> Oban.Pro.Workflow.apply_graft()
    |> Oban.insert_all()

    :ok
  end
end

# ---- boot ----
{:ok, _} = Application.ensure_all_started(:postgrex)
{:ok, _} = Application.ensure_all_started(:ecto_sql)
{:ok, _} = Application.ensure_all_started(:oban)
{:ok, _} = Application.ensure_all_started(:oban_pro)

{:ok, _} = Repro.Repo.start_link()
Ecto.Migrator.run(Repro.Repo, [{0, Repro.Migration}], :up, all: true)

{:ok, _} =
  Oban.start_link(
    repo: Repro.Repo,
    engine: Oban.Pro.Engines.Smart,
    queues: [default: 10],
    plugins: [Oban.Pro.Plugins.DynamicLifeline]
  )

{:ok, _} = Repro.Recorder.start()

# CONTROL — one graft level: the gen workflow is inserted directly.
Repro.Gen.workflow("control") |> Oban.insert_all()

# NESTED — two graft levels: the gen workflow is itself grafted in by an outer grafter.
Oban.Pro.Workflow.new()
|> Oban.Pro.Workflow.add_graft(:outer, Repro.OuterGrafter.new(%{run: "nested"}))
|> Oban.insert_all()

Enum.reduce_while(1..100, nil, fn _, _ ->
  if Repro.Recorder.get({"control", :consumer_saw_leaf}) != nil and
       Repro.Recorder.get({"nested", :consumer_saw_leaf}) != nil do
    {:halt, :ok}
  else
    Process.sleep(100)
    {:cont, nil}
  end
end)

control = Repro.Recorder.get({"control", :consumer_saw_leaf})
nested = Repro.Recorder.get({"nested", :consumer_saw_leaf})

IO.puts("control (1 graft level): consumer saw :leaf finished? #{inspect(control)}   (expected true)")
IO.puts("nested  (2 graft levels): consumer saw :leaf finished? #{inspect(nested)}   (expected true)")

Output:

control (1 graft level): consumer saw :leaf finished? true   (expected true)
nested  (2 graft levels): consumer saw :leaf finished? false   (expected true)

Question

Is nested grafting (a graft inside a workflow that is itself grafted) intended to be supported? If so, the root_id computation in apply_graft/2 seems to need to preserve the current graft’s graft_id for the grafted jobs (or reparent_grafted/3 needs to tie them back to it) rather than collapsing to the outermost grafted_* id, so that a downstream dependency on the inner graft still resolves.

Most Liked

sorentwo

sorentwo

Oban Core Team

Thanks for the report, this is fixed on main.

In the future, please avoid posting “root cause” analysis from the LLM or anything that includes Pro source code :slightly_smiling_face:

sorentwo

sorentwo

Oban Core Team

No need, I already edited it to remove the source code portions.

Where Next?

Popular in Troubleshooting Top

rayex
When I compile hex package io_ansi_plus, I get error “Codepoint failed”. Why? and why are the “Got:” and “Hint:” codepoints identical? It...
New
ChrisAmelia
Currently reading and experimenting through, which is in 1.6 Chapter 7: Sign up | Phoenix Tutorial (Phoenix 1.6) | Softcover.io In regar...
New
bugnano
Hello everyone. I’m the author of GitHub - bugnano/wtransport-elixir: Elixir bindings for the WTransport WebTransport library · GitHub ,...
New
anotherpit
Summary A downstream job that deps on a graft correctly waits for the grafted jobs at one graft level, but stops waiting when the workflo...
New
rathoud96
Environment Elixir 1.18.3-otp-27 / OTP 27.3.4 Oban 2.20.2 Phoenix 1.7.x db_connection 2.8.1 / Postgrex 0.21.1 Infrastructure: Google Cl...
New
ktayah
Environment Oban Pro: 1.7.6 Oban: 2.22.1 Issue When a sub-workflow built with Workflow.put_context/2 is attached to a parent via ...
New
AstraLuma
Experienced programmer writer her first Elixir, and maybe bit off more than she can chew? I’m trying to start two GenServers, where the ...
New
anotherpit
A graft inside another graft causes Oban.Pro.Workflow.status to recurse forever. oban_pro 1.7.5 defmodule App.NestedGraftRepro.Test do ...
New
Lotoen
Hi, I’m on Linux Mint 22.3 - Cinnamon 64-bit, and I encountered an error when trying to build an elixir phoenix container 577.1 Reading...
New
hakarabakara1
I have three modules, a Business which is associated Tags and Categories though join tables business_tags and business_categories. I inte...
New

Other popular topics Top

vertexbuffer
Hello, can anybody help here..? I have a list of players and I what to delete an element, but every for loop the list is reverting to ori...
New
Harrisonl
We have an ECS cluster with 4 services, where each task joins a single cluster, via discovery ECS discovery service. Currently when I de...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod – where is this set? Thanks.
New
chrismccord
Phoenix 1.4.0 released Phoenix 1.4 is out! This release ships with exciting new features, most notably with HTTP2 support, improved deve...
688 31013 112
New
Qqwy
Original source of discussion: This topic on the Pragmatic Programmers’ Functional Web Development with Elixir, OTP, and Phoenix forum. ...
New
AstonJ
Please see the new poll here: Which code editor or IDE do you use? (Poll) (2022 Edition) It’s been a while since we first asked this, I...
208 31265 143
New
shijith.k
I am trying to start a new phoenix project with elixir 1.9, but mix phx.new does not work. It says that ** (Mix) The task "phx.new" could...
New
Qqwy
Update: How to use the Blogs & Podcasts section You can post links to your blog posts or podcasts either in one of the Official Blog...
3271 127089 1222
New
JakeBecker
TL;DR: I’ve just released an implementation of Microsoft’s IDE-independent Language Server Protocol for Elixir. It adds language support ...
1144 54120 245
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement