anotherpit

anotherpit

Nested grafts + Oban.Pro.Workflow.status = Infinite recursion

A graft inside another graft causes Oban.Pro.Workflow.status to recurse forever. oban_pro 1.7.5

defmodule App.NestedGraftRepro.Test do
  # Minimal, self-contained reproduction of an Oban.Pro nested-graft bug (oban_pro 1.7.5).
  #
  # A graft that executes while its own job's `workflow_id` is already "grafted_"-prefixed
  # (i.e. a graft nested inside a grafted workflow) produces a sub-job whose
  # `workflow_id == sup_workflow_id`. `Oban.Pro.Workflow.status/1` walks sub-workflows via
  # `sup_workflow_id -> workflow_id` with no visited-set, so that self-edge makes
  # `expand_status` recurse forever (here: child process killed at max_heap).
  #
  # Topology: 
  # - W -> add_graft(:outer); 
  #   - outer grafts a workflow containing add_graft(:inner); 
  #     - inner grafts a leaf -> leaf.workflow_id == leaf.sup_workflow_id.
  use ExUnit.Case, async: false
  # `prefix: "sunrise"` matches this app's Oban schema; upstream you'd drop it (default "public").
  use Oban.Pro.Testing, repo: App.Repo

  import Ecto.Query

  setup do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(App.Repo, shared: true)
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
    :ok
  end

  defmodule Leaf do
    use Oban.Pro.Worker, queue: :general, max_attempts: 1
    @impl Oban.Pro.Worker
    def process(%Oban.Job{}), do: :ok
  end

  defmodule InnerGrafter do
    use Oban.Pro.Worker, queue: :general, max_attempts: 1

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

      :ok
    end
  end

  defmodule OuterGrafter do
    use Oban.Pro.Worker, queue: :general, max_attempts: 1

    @impl Oban.Pro.Worker
    def process(%Oban.Job{}) do
      # Graft a workflow that itself contains a graft point -> nesting.
      Oban.Pro.Workflow.new()
      |> Oban.Pro.Workflow.add_graft(:inner, InnerGrafter.new(%{}))
      |> Oban.Pro.Workflow.apply_graft()
      |> Oban.insert_all()

      :ok
    end
  end

  test "nested graft produces workflow_id == sup_workflow_id, and status/1 never terminates" do
    Oban.Testing.with_testing_mode(:manual, fn ->
      Oban.Pro.Workflow.new()
      |> Oban.Pro.Workflow.add_graft(:outer, OuterGrafter.new(%{}))
      |> Oban.insert_all()

      # Recursive drain (default) runs the dynamically-grafted jobs as they're inserted.
      drain_jobs(queue: :general, with_safety: true)

      jobs =
        from(j in Oban.Job,
          select: %{
            name: fragment("?->>'name'", j.meta),
            wid: fragment("?->>'workflow_id'", j.meta),
            sup: fragment("?->>'sup_workflow_id'", j.meta)
          }
        )
        |> App.Repo.all()

      for j <- jobs, do: IO.puts("JOB #{j.name} wid=#{j.wid} sup=#{j.sup}")

      # 1. The broken state: a job whose workflow_id == its own sup_workflow_id.
      selfloop = Enum.find(jobs, fn j -> not is_nil(j.sup) and j.wid == j.sup end)
      assert selfloop, "expected a workflow_id == sup_workflow_id self-loop job, found none"
      IO.puts("SELF-LOOP NODE: name=#{selfloop.name} wid=#{selfloop.wid} sup=#{selfloop.sup}")

      # 2. status/1 on that workflow never returns (infinite expand_status recursion).
      #    Run it in a heap-capped child so the test process survives.
      parent = self()

      {pid, ref} =
        :erlang.spawn_opt(
          fn ->
            result = Oban.Pro.Workflow.status(selfloop.wid)
            send(parent, {:returned, result})
          end,
          [:monitor, {:max_heap_size, %{size: 2_000_000, kill: true, error_logger: false}}]
        )

      outcome =
        receive do
          {:returned, _} -> :returned
          {:DOWN, ^ref, :process, ^pid, reason} -> {:died, reason}
        after
          8_000 ->
            Process.exit(pid, :kill)
            :timeout
        end

      IO.puts("status/1 outcome: #{inspect(outcome)}")

      refute outcome == :returned,
             "status/1 returned — infinite recursion NOT reproduced (bug may be fixed)"
    end)
  end
end

Most Liked

sorenone

sorenone

Oban Core Team

Welcome :waving_hand: @anotherpit,

Thanks for catching a strange edge case and giving us the report. It’s fixed and ready for the next patch release.

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
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
hyperoceanic
Having read the book, I’m keen to explore Ash a bit more, but I’m falling at the first hurdle - the creation of a new project. Here’s my...
New
Lotoen
Hello, I am currently using Elixir 1.18.4 and OTP 27 with Linux Mint 22.3 - Cinnamon 64-bit as my operating system. I came across this er...
New
tellemiller
We recently started running Oban with Oban Web on PostgreSQL (AWS RDS gp3) and noticed our oban_jobs buffer cache hit ratio sitting at 65...
New
rayex
For hex package islands_score the source @spec for function format/2 is as such: @spec format(t, keyword) :: :ok Why does it show on 4 ...
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

Other popular topics Top

malloryerik
Hi, this is for people who, like me, have had some friction using .html.heex templates in VSCode. The solution seems to be, in a hyphena...
New
marius95
Hello everyone, I try to use an Javascript Event Handler in my root.html.leex file. Therefore I created a function in the app.js file: ...
New
Darmani72
If I have a post route which an argument: post /my_post_route/:my_param1, MyController.my_post_handler How would get the post params ...
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
jerry
Good day to you all. I have been struggling to get a query involving like and ilike to work. Can anyone assist me on this, please? pro...
New
SoCreat
i’m a new one to elixir which editor can i use vs code? or atom? Thanks! :smiley:
New
Emily
I have VueJS GUIs with the project generated using Webpack. I have Elixir modules that will need to be used by the VueJS GUIs. I forese...
New
Qqwy
Update: How to use the Blogs &amp; Podcasts section You can post links to your blog posts or podcasts either in one of the Official Blog...
3271 127089 1222
New
AstonJ
Seen any cool LiveView demos, sample apps or examples? Please post them here! :003:
New
lanycrost
Hi everyone! I need implement if…else if…else condition from my elixir code, and anymore of this control flow structures not work proper...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement