GgenIgniter.EngineRegistry (ggen_igniter v26.9.8)

Copy Markdown View Source

ADR-0008's orchestration layer over GgenIgniter.Engine.fetch!/1, not a replacement for it. Single-engine callers (the default, unchanged path through lib/mix/tasks/ggen_igniter.sync.ex's run_via_reactor/3 -> dispatch_reactor_reconcile/2 -> GgenIgniter.Reactors.ReconcileReactor's own internal target_opts[:engine] || "oxigraph" -> Engine.fetch!/1 resolution) never touch this module at all -- it exists only for the --engine comma-separated/"all" comparison-mode path.

Two real, distinct entry points:

  • resolve/1 (and resolve/2, taking the raw sync CLI opts so the "all" branch can run a real qlever-reachability precondition check -- see below) -- parses one of three --engine value shapes into a validated, deduplicated list of engine atoms.
  • run_all/4 -- fans every resolved engine atom out to GgenIgniter.Engine.fetch!/1's already-resolved module and that module's existing prepare!/2/run/2 (lib/ggen_igniter/engine.ex:21-22's @callbacks), via Task.async_stream/3 mirroring GgenIgniter.Reactors.ReconcileReactor's own real concurrency precedent (lib/ggen_igniter/reactors/reconcile_reactor.ex:1743, |> Task.async_stream(&actuate_one(&1, ...), max_concurrency: ..., timeout: :infinity)), returning a GgenIgniter.EngineComparisonReport.t().

Why not a formal behaviour for this module itself

GgenIgniter.Engine is already a real @callback-based behaviour for the per-engine prepare!/2/run/2 contract. This module is a different kind of thing: a one-shot orchestration layer over that existing behaviour, not a second engine contract -- a second formal @callback here would just wrap Engine.fetch!/1's existing dispatch in ceremony. See docs/architecture/adr/0008-evidence-ranked-multi-engine-registry.md ("Why not a formal behaviour for the registry itself") for the full reasoning this module implements verbatim.

"all"'s qlever-inclusion precondition -- real, not simulated

mix ggen_igniter.doctor's check 8 (lib/mix/tasks/ggen_igniter.doctor.ex, check_qlever_reachable/2) already establishes this repo's precedent for a real, non-simulated QLever reachability probe (load the named --store-id store from the loaded ontology graph, run a real ASK { ?s ?p ?o } against it, rescue a real connection/parse failure). qlever_reachable?/1 below reuses that exact real technique (same real GgenIgniter.Query.Qlever. load_store!/2 + GgenIgniter.Query.Qlever.run/2 + real ASK probe, rescue -> false) rather than inventing a second, simulated one -- check_qlever_reachable/2 itself stays untouched (it is defp, shaped around a pack_dir argument and a human-readable {:ok, msg} | {:error, msg} result the doctor task's own reporting needs, not the plain boolean this module's silent-exclusion-with-warning behavior needs).

Summary

Functions

Parses engine_spec (one of: a single engine name, a comma-separated list, or the literal "all") into a validated, deduplicated list of engine atoms, in the order named (never re-sorted, except "all"'s own expansion -- see below) -- order matters downstream: mix ggen_igniter.sync's comparison mode treats the FIRST resolved engine as the primary engine whose rows actually get rendered/actuated.

Fans query_text out to every engine in engines (each resolved via GgenIgniter.Engine.fetch!/1, exactly as the single-engine path already does), concurrently via Task.async_stream/3 (max_concurrency: length(engines), mirroring GgenIgniter.Reactors. ReconcileReactor's :actuate step's own real concurrency precedent), timing each candidate with System.monotonic_time(:microsecond). Every invocation is wrapped in rescue so one engine's crash becomes a %CandidateResult{status: :error} entry (never aborting the others) -- this generalizes the six known-real per-engine error-shape divergences test/ggen_igniter_engine_parity_test.exs already pins (raw MatchError on CONSTRUCT/malformed query on sparql-hex, Protocol.UndefinedError on bare FILTER NOT EXISTS, ...) into per-run, per-engine data. A task that exceeds opts[:timeout] (default 30_000 ms) is killed (on_timeout: :kill_task) and becomes %CandidateResult{status: :timeout} instead of hanging the whole comparison run.

Functions

resolve(engine_spec, opts \\ [])

@spec resolve(
  String.t(),
  keyword()
) :: {:ok, [atom()]} | {:error, term()}

Parses engine_spec (one of: a single engine name, a comma-separated list, or the literal "all") into a validated, deduplicated list of engine atoms, in the order named (never re-sorted, except "all"'s own expansion -- see below) -- order matters downstream: mix ggen_igniter.sync's comparison mode treats the FIRST resolved engine as the primary engine whose rows actually get rendered/actuated.

resolve/1 (no opts) is equivalent to resolve(engine_spec, []): every shape except "all" behaves identically either way, since only "all"'s qlever-inclusion precondition needs --store-id/--ontology/--pack context. Passing opts: [] to "all" is not an error -- it just means qlever's --store-id precondition is trivially unsatisfied, so "all" silently excludes qlever with a logged warning, exactly as if --store-id had genuinely been omitted.

Examples

iex> GgenIgniter.EngineRegistry.resolve("oxigraph")
{:ok, [:oxigraph]}

iex> GgenIgniter.EngineRegistry.resolve("oxigraph,sparql")
{:ok, [:oxigraph, :sparql]}

iex> GgenIgniter.EngineRegistry.resolve("oxigraph, oxigraph, sparql")
{:ok, [:oxigraph, :sparql]}

iex> GgenIgniter.EngineRegistry.resolve("nope")
{:error, "invalid --engine name(s): nope, must be one of: oxigraph, qlever, sparql"}

run_all(query_text, graph, engines, opts \\ [])

Fans query_text out to every engine in engines (each resolved via GgenIgniter.Engine.fetch!/1, exactly as the single-engine path already does), concurrently via Task.async_stream/3 (max_concurrency: length(engines), mirroring GgenIgniter.Reactors. ReconcileReactor's :actuate step's own real concurrency precedent), timing each candidate with System.monotonic_time(:microsecond). Every invocation is wrapped in rescue so one engine's crash becomes a %CandidateResult{status: :error} entry (never aborting the others) -- this generalizes the six known-real per-engine error-shape divergences test/ggen_igniter_engine_parity_test.exs already pins (raw MatchError on CONSTRUCT/malformed query on sparql-hex, Protocol.UndefinedError on bare FILTER NOT EXISTS, ...) into per-run, per-engine data. A task that exceeds opts[:timeout] (default 30_000 ms) is killed (on_timeout: :kill_task) and becomes %CandidateResult{status: :timeout} instead of hanging the whole comparison run.

graph is the already-loaded %RDF.Graph{} (GgenIgniter.Ontology. load!/1's return) -- each engine's own prepare!/2 (already implemented, reused verbatim here) turns it into whatever context that engine's run/2 actually needs (the graph itself for oxigraph/sparql, a resolved Gno.Store.Adapters.Qlever struct for qlever, via opts[:store_id]).