Pattern can never match the type in DAG Scheduler build function

Hi everyone,

I’m currently building QyCore, a lightweight open-source DAG task scheduler and execution engine written in Elixir. The origin goal is to create a backend for node-based editors similar to ComfyUI, as shown in this thread earlier.

I’ve hit a wall with a persistent Dialyzer error that I can’t seem to resolve, even after cleaning builds and explicitly defining specs.

The Context

I have a Scheduler.build/2 function that validates a Recipe struct and returns {:ok, context} or {:error, reason}. It works perfectly at runtime, all tests passed, but Dialyzer is convinced that the {:ok, …} path is impossible.

The Error

When checking Executor.Async and Executor.Serial(Scheduler.build/2’s downstream), Dialyzer complains:

lib/qy_core/executor/async.ex:17:13:pattern_match
The pattern can never match the type.

Pattern:
{:ok, _ctx}

Type:

  {:error,
   {:cyclic, [any()]} | {:missing_inputs, map()} | {:option_validation_failed, [any()]}}
________________________________________________________________________________
...
________________________________________________________________________________
lib/qy_core/executor/serial.ex:15:13:pattern_match
The pattern can never match the type.

Pattern:
{:ok, _ctx}

Type:

  {:error,
   {:cyclic, [any()]} | {:missing_inputs, map()} | {:option_validation_failed, [any()]}}


________________________________________________________________________________

This implies that my Scheduler.build/2 function (shown below) is inferred to only return {:error, …}.

The Code

defmodule QyCore.Scheduler do
  # ...

  @spec build(Recipe.t(), initial_params()) ::
          {:ok, Context.t()} | {:error, term()}
  def build(%Recipe{} = recipe, initial_params) do
    # ... (initial map preparation) ...
    initial_keys = Map.keys(initial_map)

    # Dialyzer seems to think this `with` block never reaches the `do` block
    with :ok <- validate_step_option(recipe.steps),
         :ok <- Recipe.Graph.validate(recipe.steps, initial_keys) do
      do_build(recipe, initial_map)
    else
      {:error, reason} -> {:error, reason}
    end
  end

  # I added an explicit spec here hoping to fix it, but it didn't work.
  @spec validate_step_option([Step.t()]) :: :ok | {:error, {:option_validation_failed, list()}}
  defp validate_step_option(steps) do
    errors =
      steps
      |> Enum.with_index()
      |> Enum.reduce([], fn {step, idx}, acc ->
         # ... validation logic (returns list of errors) ...
         # If valid, `acc` remains []
         # If validator not exist, no error appended
      end)

    case errors do
      [] -> :ok
      _ -> {:error, {:option_validation_failed, errors}}
    end
  end

  defp do_build(recipe, initial_map) do
    # ... logic ...
    {:ok, %Context{...}}
  end

  # ...
end

What I’ve tried

  1. mix do clean, compile multiple times.
  2. Added explicit @spec to the private function validate_step_option/1.
  3. Verified that Recipe.Graph.validate/2 (in another module) has a spec that includes :ok as a return type.

It feels like Dialyzer’s success typing analysis determines that validate_step_option (or Graph.validate) can never return :ok, thus marking the do_build call as unreachable code.

Has anyone encountered this specific behavior where Dialyzer ignores an explicit :ok path in a reducer?

Any insights would be appreciated! Also, if you are interested in DAG scheduling in Elixir, feel free to check out the repo structure.

Thanks!


P.S. Warings were ignored by configure .dialyzer_ignore.exs. Hovewer, It seems trigger warning within ALL downstream functions which invoked Scheduler.build/2.

As anticipated, I want to leverage the community’s resources after showcasing the demo to create a more production-ready, fault-tolerant and Elixit-like Executor. But it seems necessary to manually declare that alarms should be ignored in all relevant modules of the new application.

Is there a more-elegant way to solve?

1 Like

Problem solved, by refactoring executor’s API.

Now executor will receive a Scheduler.Context struct and execute work flow.

defmodule Orchid.Executor do
  @moduledoc """
  Executor behavoir.
  """

  @type executor :: module()
  @type executor_opts :: keyword()

  @type response :: {:ok, [Orchid.Param.t()]} | {:error, term()}

  @callback execute(Orchid.Scheduler.Context.t(), executor_opts()) ::
              response()
end

I also change the application’s name from qy_core into orchid, because it’s project-agnostic.

It also released on hex.pm, and I translated some comments and documents into English. This is a lightweight library, and its library has only about ~1k lines of code(exclude comments and test).

Anyway, thank you for your viewing, especially the group member who helped me in the elixir chat group.

3 Likes

Thank you for translating the README! I have checked your project at the time of your original post and bounced off when I saw it was in Chinese. Now I’ll check it out.

2 Likes