ref:2b54c14bba04f24906f8e2d3318424d8e050cbb8

feat: top-level Graph-aware API (ahead_behind, commits_between, rebuild_graph) (#17)

PR 3 of 4 for fangorn/ex_git_objectstore#26. This is the entry point Anvil will call. Each query tries the persisted commit-graph first and falls back to the existing \`cat_object\` walker when the graph isn't built yet or doesn't cover one of the query SHAs. No behavior change for existing callers of \`ancestor?/3\` — only its speed. **Stacked on #16** — rebase-clean when that merges. ## New modules - **\`ExGitObjectstore.Graph.Cache\`** — \`:persistent_term\`-backed in-process cache keyed by \`{storage_module, repo_prefix}\`. Reads are lock-free and zero-copy. \`put/2\` triggers a global GC scan so it's only called on rebuild or first lazy-load. - **\`ExGitObjectstore.Graph.Fallback\`** — reference walker implementations of \`ahead_behind\`, \`commits_between\`, \`ancestor?\`. Bounded by \`:max_walk\` (default 10k) so a pathological history returns \`{:error, :walk_limit_exceeded}\` instead of running forever. ## New public API \`\`\`elixir ahead_behind(repo, base, head) :: {:ok, %{ahead, behind}} | {:error, _} commits_between(repo, base, head) :: {:ok, [sha]} | {:error, _} ancestor?(repo, anc, desc) :: {:ok, bool} | {:error, _} # existing, now graph-aware rebuild_graph(repo) :: :ok | {:error, _} \`\`\` All three queries follow the same routing: load-or-fetch-cached graph, verify both SHAs are members, answer from graph. On any failure (missing graph, missing member, corrupt blob), fall back to \`Graph.Fallback\`. Callers see one stable contract. \`rebuild_graph\` builds from refs, persists to storage, and seeds the cache. ## Tests 23 new tests on top of PR #16: - 6 Cache (hit/miss, overwrite, delete, per-repo keying) - 10 Fallback (edge cases, ahead_behind, commits_between ordering, ancestor? semantics, walk-limit ceiling, missing-commit error) - 7 integration tests exercising all routing paths: - no graph built — fallback - after \`rebuild_graph\` — graph path answers + cache seeded - stale graph (commit pushed after build) — falls back because new SHA isn't in graph Full suite: **704 tests, 0 failures** (was 681 on #16). Credo: unchanged from main. ## What's next Anvil PR: swap \`lib/anvil/git/objectstore.ex\` \`ahead_behind\` / \`commits_between\` to call \`ExGitObjectstore\` directly. Add a \`mix anvil.graphs.rebuild\` task so operators can seed graphs in dev / staging / prod (no push-hook wiring yet — tracked separately). ## Deployment notes - First query after deploy with no graph: walker path, same perf as today. - After one \`mix anvil.graphs.rebuild\` per repo: graph path on every subsequent query (until a push introduces commits not in the graph, at which point that specific query falls back until the next rebuild). - No incremental update in this PR — graph staleness after push is handled by the fallback, not by invalidation.
SHA: 2b54c14bba04f24906f8e2d3318424d8e050cbb8
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-04-18 18:31
Parents: 37bb565
8 files changed +841 -42
Type
lib/ex_git_objectstore.ex +138 −6
@@ -54,7 +54,7 @@
moduledoc for details.
"""
alias ExGitObjectstore.{Merge, Object, ObjectResolver, Ref, Repo, Walk}
alias ExGitObjectstore.{Graph, Merge, Object, ObjectResolver, Ref, Repo, Telemetry, Walk}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
@type sha :: String.t()
@@ -681,13 +681,145 @@
True if `ancestor` is an ancestor of `descendant` (inclusive — a commit is
its own ancestor).
Uses the persisted commit-graph index when available (see
`ExGitObjectstore.Graph`). Falls back to a cat_object-based walker when
the graph isn't built or doesn't yet cover one of the SHAs.
Implemented by checking `merge_base(ancestor, descendant) == ancestor`.
Emits `[:ex_git_objectstore, :graph, :query]` telemetry with
`operation: :ancestor?` and `path: :graph | :fallback`.
"""
@spec ancestor?(Repo.t(), sha(), sha()) :: {:ok, boolean()} | {:error, term()}
def ancestor?(%Repo{} = repo, ancestor_sha, descendant_sha) do
case Walk.merge_base(repo, ancestor_sha, descendant_sha) do
{:ok, ^ancestor_sha} -> {:ok, true}
{:ok, _} -> {:ok, false}
{:error, _} = err -> err
routed_query(
repo,
:ancestor?,
fn graph ->
graph_result(
graph,
[ancestor_sha, descendant_sha],
&Graph.ancestor?(&1, ancestor_sha, descendant_sha)
)
end,
fn -> Graph.Fallback.ancestor?(repo, ancestor_sha, descendant_sha) end
)
end
@doc """
Count commits reachable from `head_sha` but not from `base_sha`
(`:ahead`) and vice versa (`:behind`). Equivalent to the output of
`git rev-list --count --left-right base...head`.
Uses the persisted commit-graph index when available. Falls back to a
cat_object-based walker when the graph isn't built or doesn't yet
cover one of the SHAs.
Emits `[:ex_git_objectstore, :graph, :query]` telemetry with
`operation: :ahead_behind` and `path: :graph | :fallback`.
"""
@spec ahead_behind(Repo.t(), sha(), sha()) ::
{:ok, %{ahead: non_neg_integer(), behind: non_neg_integer()}} | {:error, term()}
def ahead_behind(%Repo{} = repo, base_sha, head_sha) do
routed_query(
repo,
:ahead_behind,
fn graph ->
graph_result(graph, [base_sha, head_sha], &Graph.ahead_behind(&1, base_sha, head_sha))
end,
fn -> Graph.Fallback.ahead_behind(repo, base_sha, head_sha) end
)
end
@doc """
Commits reachable from `head_sha` but not from `base_sha`, newest-first.
Empty when `head_sha` is an ancestor of (or equal to) `base_sha`.
Uses the persisted commit-graph index when available. Falls back to a
cat_object-based walker otherwise.
Emits `[:ex_git_objectstore, :graph, :query]` telemetry with
`operation: :commits_between` and `path: :graph | :fallback`.
"""
@spec commits_between(Repo.t(), sha(), sha()) :: {:ok, [sha()]} | {:error, term()}
def commits_between(%Repo{} = repo, base_sha, head_sha) do
routed_query(
repo,
:commits_between,
fn graph ->
graph_result(graph, [base_sha, head_sha], &Graph.commits_between(&1, base_sha, head_sha))
end,
fn -> Graph.Fallback.commits_between(repo, base_sha, head_sha) end
)
end
@doc """
Rebuild the commit-graph index for `repo` from all refs, persist it to
storage, and seed the in-process cache. Safe to call from any process;
callers should serialize concurrent rebuilds externally.
"""
@spec rebuild_graph(Repo.t()) :: :ok | {:error, term()}
def rebuild_graph(%Repo{} = repo) do
with {:ok, graph} <- Graph.build(repo),
:ok <- Graph.save(repo, graph) do
Graph.Cache.put(repo, graph)
end
end
# -- Graph → fallback routing --
#
# `query_fun.(graph)` returns `{:ok, result}` (answer from the graph),
# `:fallback` (graph is loaded but doesn't cover the query), or
# `{:error, reason}` (graph said no, propagate as-is).
#
# The caller's `fallback_fun` is invoked only when the graph can't
# answer — missing from storage, cache miss and load error, or a
# `:fallback` signal from `query_fun`.
defp routed_query(%Repo{} = repo, operation, query_fun, fallback_fun) do
metadata = %{operation: operation, repo_id: repo.id}
Telemetry.span([:ex_git_objectstore, :graph, :query], metadata, fn ->
{result, path} = run_routed(repo, query_fun, fallback_fun)
{result, Map.put(metadata, :path, path)}
end)
end
defp run_routed(repo, query_fun, fallback_fun) do
case load_or_fetch_graph(repo) do
{:ok, graph} ->
case query_fun.(graph) do
{:ok, _} = ok -> {ok, :graph}
{:error, _} = err -> {err, :graph}
:fallback -> {fallback_fun.(), :fallback}
end
{:error, _} ->
{fallback_fun.(), :fallback}
end
end
# Runs `graph_fun` only if every SHA in `required_shas` is in the graph;
# otherwise signals `:fallback` so the caller routes to its walker.
defp graph_result(graph, required_shas, graph_fun) do
if Enum.all?(required_shas, &Graph.member?(graph, &1)) do
graph_fun.(graph)
else
:fallback
end
end
defp load_or_fetch_graph(repo) do
case Graph.Cache.fetch(repo) do
{:ok, graph} ->
{:ok, graph}
:miss ->
case Graph.load(repo) do
{:ok, graph} ->
:ok = Graph.Cache.put(repo, graph)
{:ok, graph}
{:error, _} = err ->
err
end
end
end
lib/ex_git_objectstore/graph/cache.ex +65 −0
@@ -1,0 +1,65 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Graph.Cache do
@moduledoc """
In-process cache for loaded commit graphs, keyed by `{Repo.storage_module,
prefix}`. Backed by `:persistent_term` for zero-copy reads.
A graph is typically ~50 B per commit; a 100k-commit repo is 5 MB, which is
fine to keep hot. Writes trigger a global GC scan, so `put/2` should be called
sparingly — once per rebuild, not once per query.
Thread-safe: reads are lock-free; `put/2` and `delete/1` serialize through the
VM's persistent-term machinery. Callers that want build-then-cache semantics
should coordinate externally (`ExGitObjectstore.rebuild_graph/1` does this).
"""
alias ExGitObjectstore.{Graph, Repo}
@namespace {__MODULE__, :v1}
@doc """
Fetch the cached graph for `repo`, or `:miss` if none is cached.
"""
@spec fetch(Repo.t()) :: {:ok, Graph.t()} | :miss
def fetch(%Repo{} = repo) do
case :persistent_term.get(key(repo), :__miss__) do
:__miss__ -> :miss
%Graph{} = g -> {:ok, g}
end
end
@doc """
Cache `graph` for `repo`. Replaces any existing entry.
"""
@spec put(Repo.t(), Graph.t()) :: :ok
def put(%Repo{} = repo, %Graph{} = graph) do
:persistent_term.put(key(repo), graph)
:ok
end
@doc """
Evict the cached graph for `repo`, if any.
"""
@spec delete(Repo.t()) :: :ok
def delete(%Repo{} = repo) do
_ = :persistent_term.erase(key(repo))
:ok
end
defp key(%Repo{storage: {mod, _cfg}} = repo) do
{@namespace, mod, Repo.prefix(repo)}
end
end
lib/ex_git_objectstore/graph/fallback.ex +154 −0
@@ -1,0 +1,154 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Graph.Fallback do
@moduledoc """
Reference walker implementations of `ahead_behind`, `commits_between`,
and `ancestor?` used when a commit-graph isn't available (not yet built,
or doesn't cover one of the query SHAs).
These walk the DAG by reading each commit via `ObjectResolver.read/2` —
the same pattern used in the repo today, pre-graph. Each query is
linear in the number of reachable commits and, on remote storage,
pays one round-trip per commit. Prefer the graph path when available.
Walks are bounded by `:max_walk` (default 10_000) as a safety valve
against pathological / runaway histories. Queries exceeding this
return `{:error, :walk_limit_exceeded}`.
"""
alias ExGitObjectstore.Object.Commit
alias ExGitObjectstore.{ObjectResolver, Repo}
@default_max_walk 10_000
@type opts :: [max_walk: pos_integer()]
@spec ahead_behind(Repo.t(), String.t(), String.t(), opts()) ::
{:ok, %{ahead: non_neg_integer(), behind: non_neg_integer()}} | {:error, term()}
def ahead_behind(%Repo{} = repo, base_sha, head_sha, opts \\ []) do
if base_sha == head_sha do
{:ok, %{ahead: 0, behind: 0}}
else
max_walk = Keyword.get(opts, :max_walk, @default_max_walk)
with {:ok, base_anc} <- collect_ancestors(repo, base_sha, max_walk),
{:ok, head_anc} <- collect_ancestors(repo, head_sha, max_walk) do
ahead = MapSet.size(MapSet.difference(head_anc, base_anc))
behind = MapSet.size(MapSet.difference(base_anc, head_anc))
{:ok, %{ahead: ahead, behind: behind}}
end
end
end
@spec commits_between(Repo.t(), String.t(), String.t(), opts()) ::
{:ok, [String.t()]} | {:error, term()}
def commits_between(%Repo{} = repo, base_sha, head_sha, opts \\ []) do
if base_sha == head_sha do
{:ok, []}
else
max_walk = Keyword.get(opts, :max_walk, @default_max_walk)
with {:ok, base_anc} <- collect_ancestors(repo, base_sha, max_walk),
{:ok, head_anc} <- collect_ancestors(repo, head_sha, max_walk) do
diff_shas = MapSet.to_list(MapSet.difference(head_anc, base_anc))
sort_by_committer_time_desc(repo, diff_shas)
end
end
end
@spec ancestor?(Repo.t(), String.t(), String.t(), opts()) ::
{:ok, boolean()} | {:error, term()}
def ancestor?(%Repo{} = repo, ancestor_sha, descendant_sha, opts \\ []) do
if ancestor_sha == descendant_sha do
{:ok, true}
else
max_walk = Keyword.get(opts, :max_walk, @default_max_walk)
with {:ok, desc_anc} <- collect_ancestors(repo, descendant_sha, max_walk) do
{:ok, MapSet.member?(desc_anc, ancestor_sha)}
end
end
end
# -- Internals --
#
# `visited` is a plain Map used as a set (value `true`). Using `Map`
# rather than `MapSet` avoids dialyzer opacity warnings that surface
# when the set is threaded through multiple private function clauses.
# Reachability BFS bounded by `max_walk`. Returns `{:error,
# :walk_limit_exceeded}` if the walk grows past the fence.
@spec collect_ancestors(Repo.t(), String.t(), non_neg_integer()) ::
{:ok, MapSet.t(String.t())} | {:error, term()}
defp collect_ancestors(repo, sha, max_walk) do
case do_collect(repo, [sha], %{}, max_walk) do
{:ok, visited_map} -> {:ok, MapSet.new(Map.keys(visited_map))}
{:error, _} = err -> err
end
end
@spec do_collect(Repo.t(), [String.t()], %{String.t() => true}, non_neg_integer()) ::
{:ok, %{String.t() => true}} | {:error, term()}
defp do_collect(_repo, [], visited, _remaining), do: {:ok, visited}
defp do_collect(_repo, _queue, _visited, 0), do: {:error, :walk_limit_exceeded}
defp do_collect(repo, [sha | rest], visited, remaining) do
if Map.has_key?(visited, sha) do
do_collect(repo, rest, visited, remaining)
else
visited = Map.put(visited, sha, true)
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{parents: parents}} ->
do_collect(repo, parents ++ rest, visited, remaining - 1)
{:error, :not_found} ->
{:error, {:missing_commit, sha}}
{:error, _} = err ->
err
end
end
end
defp sort_by_committer_time_desc(repo, shas) do
result =
Enum.reduce_while(shas, {:ok, []}, fn sha, {:ok, acc} ->
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{committer: c}} -> {:cont, {:ok, [{sha, parse_timestamp(c)} | acc]}}
{:error, _} = err -> {:halt, err}
end
end)
case result do
{:ok, enriched} ->
sorted = enriched |> Enum.sort_by(fn {_sha, ts} -> -ts end) |> Enum.map(&elem(&1, 0))
{:ok, sorted}
{:error, _} = err ->
err
end
end
# `committer` is a required field on %Commit{} and always a String.t,
# so the single binary clause is total.
defp parse_timestamp(str) when is_binary(str) do
case Regex.run(~r/(\d+)\s+[+-]\d{4}$/, str) do
[_, ts] -> String.to_integer(ts)
_ -> 0
end
end
end
lib/ex_git_objectstore/telemetry.ex +12 −0
@@ -47,6 +47,18 @@
- Measurements: `:system_time` (start), `:duration`, `:object_count`, `:pack_size` (stop)
- Metadata: `:repo_id`
### Graph Queries
* `[:ex_git_objectstore, :graph, :query, :start | :stop | :exception]`
- Measurements: `:system_time` (start), `:duration` (stop)
- Metadata: `:operation` (`:ancestor?` | `:ahead_behind` | `:commits_between`),
`:repo_id`, `:path` (`:graph` | `:fallback`, stop only)
Use this event to monitor graph hit-rate. A sustained `path: :fallback`
stream after a known-good rebuild usually means the in-process cache
was evicted (node restart) or the graph doesn't cover SHAs introduced
by a push since the last `rebuild_graph/1`.
### Protocol Operations
* `[:ex_git_objectstore, :protocol, :receive_pack, :start | :stop | :exception]`
test/ex_git_objectstore/graph/cache_test.exs +71 −0
@@ -1,0 +1,71 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Graph.CacheTest do
# persistent_term is global process state, so we serialize.
use ExUnit.Case, async: false
alias ExGitObjectstore.Graph
alias ExGitObjectstore.Graph.Cache
alias ExGitObjectstore.Test.RepoHelper
setup do
repo = RepoHelper.memory_repo("cache-#{:erlang.unique_integer([:positive])}")
on_exit(fn -> Cache.delete(repo) end)
%{repo: repo}
end
test ":miss when nothing cached", %{repo: repo} do
assert :miss = Cache.fetch(repo)
end
test "put then fetch returns the same graph", %{repo: repo} do
g = %Graph{version: 1, shas: ["a"], by_sha: %{"a" => :placeholder}}
:ok = Cache.put(repo, g)
assert {:ok, ^g} = Cache.fetch(repo)
end
test "put overwrites", %{repo: repo} do
g1 = %Graph{version: 1, shas: ["a"], by_sha: %{}}
g2 = %Graph{version: 1, shas: ["b"], by_sha: %{}}
:ok = Cache.put(repo, g1)
:ok = Cache.put(repo, g2)
assert {:ok, ^g2} = Cache.fetch(repo)
end
test "delete evicts", %{repo: repo} do
:ok = Cache.put(repo, %Graph{})
:ok = Cache.delete(repo)
assert :miss = Cache.fetch(repo)
end
test "delete on unmapped key is :ok", %{repo: repo} do
assert :ok = Cache.delete(repo)
end
test "entries are keyed per repo", %{repo: repo_a} do
repo_b = RepoHelper.memory_repo("cache-#{:erlang.unique_integer([:positive])}")
on_exit(fn -> Cache.delete(repo_b) end)
ga = %Graph{version: 1, shas: ["a"], by_sha: %{}}
gb = %Graph{version: 1, shas: ["b"], by_sha: %{}}
:ok = Cache.put(repo_a, ga)
:ok = Cache.put(repo_b, gb)
assert {:ok, ^ga} = Cache.fetch(repo_a)
assert {:ok, ^gb} = Cache.fetch(repo_b)
end
end
test/ex_git_objectstore/graph/fallback_test.exs +137 −0
@@ -1,0 +1,137 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Graph.FallbackTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Graph.Fallback
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Commit, Tree}
alias ExGitObjectstore.Test.RepoHelper
defp init_repo do
repo = RepoHelper.memory_repo("fb-#{:erlang.unique_integer([:positive])}")
ExGitObjectstore.init(repo)
repo
end
defp tree!(repo) do
{:ok, sha} = Object.write(repo, Tree.new([]))
sha
end
defp commit!(repo, tree, parents, ts) do
ident = "A <a@a.com> #{ts} +0000"
{:ok, sha} =
Object.write(repo, %Commit{
tree: tree,
parents: parents,
author: ident,
committer: ident,
message: "m\n"
})
sha
end
describe "ahead_behind/4" do
test "same commit → 0/0" do
repo = init_repo()
t = tree!(repo)
a = commit!(repo, t, [], 1)
assert {:ok, %{ahead: 0, behind: 0}} = Fallback.ahead_behind(repo, a, a)
end
test "linear chain: head ahead by N" do
repo = init_repo()
t = tree!(repo)
a = commit!(repo, t, [], 1)
b = commit!(repo, t, [a], 2)
c = commit!(repo, t, [b], 3)
assert {:ok, %{ahead: 2, behind: 0}} = Fallback.ahead_behind(repo, a, c)
assert {:ok, %{ahead: 0, behind: 2}} = Fallback.ahead_behind(repo, c, a)
end
test "diverged: 1 ahead / 1 behind" do
repo = init_repo()
t = tree!(repo)
root = commit!(repo, t, [], 1)
base = commit!(repo, t, [root], 2)
head = commit!(repo, t, [root], 3)
assert {:ok, %{ahead: 1, behind: 1}} = Fallback.ahead_behind(repo, base, head)
end
test "walk_limit_exceeded when max_walk too low" do
repo = init_repo()
t = tree!(repo)
a = commit!(repo, t, [], 1)
b = commit!(repo, t, [a], 2)
c = commit!(repo, t, [b], 3)
assert {:error, :walk_limit_exceeded} = Fallback.ahead_behind(repo, a, c, max_walk: 1)
end
test "missing commit returns {:missing_commit, sha}" do
repo = init_repo()
t = tree!(repo)
real = commit!(repo, t, [], 1)
missing = String.duplicate("0", 40)
assert {:error, {:missing_commit, ^missing}} = Fallback.ahead_behind(repo, real, missing)
end
end
describe "commits_between/4" do
test "empty when same" do
repo = init_repo()
t = tree!(repo)
a = commit!(repo, t, [], 1)
assert {:ok, []} = Fallback.commits_between(repo, a, a)
end
test "linear chain: newest-first" do
repo = init_repo()
t = tree!(repo)
base = commit!(repo, t, [], 1)
mid = commit!(repo, t, [base], 2)
head = commit!(repo, t, [mid], 3)
assert {:ok, [^head, ^mid]} = Fallback.commits_between(repo, base, head)
end
end
describe "ancestor?/4" do
test "self is ancestor" do
repo = init_repo()
t = tree!(repo)
a = commit!(repo, t, [], 1)
assert {:ok, true} = Fallback.ancestor?(repo, a, a)
end
test "parent is ancestor of child" do
repo = init_repo()
t = tree!(repo)
a = commit!(repo, t, [], 1)
b = commit!(repo, t, [a], 2)
assert {:ok, true} = Fallback.ancestor?(repo, a, b)
assert {:ok, false} = Fallback.ancestor?(repo, b, a)
end
test "unrelated commits are not ancestors" do
repo = init_repo()
t = tree!(repo)
a = commit!(repo, t, [], 1)
b = commit!(repo, t, [], 2)
assert {:ok, false} = Fallback.ancestor?(repo, a, b)
end
end
end
test/ex_git_objectstore/graph/queries_test.exs +44 −36
@@ -303,17 +303,20 @@
end
end
# --- equivalence with the Walk module on random DAGs ---
# --- equivalence on random DAGs ---
#
# Walk provides merge_base/3 which we can use as a reference. For each
# random DAG we build, compare Graph.ancestor? against a brute-force
# reachability walk via cat_object, and compare ahead_behind / merge-base
# shape against the Walk module where feasible.
# Triple cross-check: Graph (fast, generation-bounded, in-memory),
# Fallback (reference walker used when no graph is loaded), and a
# brute-force reachability walker defined in this test file. Any
# divergence between the three flags either a graph algorithm bug,
# a fallback algorithm bug, or a bug in both.
describe "equivalence on random DAGs" do
@iterations 10
alias ExGitObjectstore.Graph.Fallback
test "ancestor? matches a brute-force reachability walker" do
test "ancestor?: Graph ≡ Fallback ≡ brute-force" do
ex_unit_seed = ExUnit.configuration()[:seed]
for i <- 1..@iterations do
@@ -323,23 +326,24 @@
t = empty_tree_sha(repo)
{shas, _tips} = random_dag(repo, t, 15)
# Register all shas as branches so they're reachable.
for {sha, idx} <- Enum.with_index(shas),
do: :ok = ExGitObjectstore.create_branch(repo, "b-#{idx}", sha)
g = graph_of(repo)
for a <- shas, b <- shas do
brute = brute_force_ancestor?(repo, a, b)
{:ok, graph_answer} = Graph.ancestor?(g, a, b)
{:ok, got} = Graph.ancestor?(g, a, b)
expected = brute_force_ancestor?(repo, a, b)
{:ok, fallback_answer} = Fallback.ancestor?(repo, a, b)
assert graph_answer == brute and fallback_answer == brute,
"iter=#{i}: ancestor?(#{String.slice(a, 0, 7)}, #{String.slice(b, 0, 7)}) — " <>
assert got == expected,
"iter=#{i}: Graph.ancestor?(#{String.slice(a, 0, 7)}, #{String.slice(b, 0, 7)}) = #{got}, expected #{expected}"
"graph=#{graph_answer} fallback=#{fallback_answer} brute=#{brute}"
end
end
end
test "ahead_behind: Graph ≡ Fallback ≡ brute-force set difference" do
test "ahead_behind: counts match brute-force set difference" do
ex_unit_seed = ExUnit.configuration()[:seed]
for i <- 1..@iterations do
@@ -353,28 +357,28 @@
do: :ok = ExGitObjectstore.create_branch(repo, "b-#{idx}", sha)
g = graph_of(repo)
pairs =
for a <- Enum.take_random(tips, min(3, length(tips))),
b <- Enum.take_random(tips, min(3, length(tips))),
a != b,
do: {a, b}
pairs = sample_pairs(tips, 3)
for {base, head} <- pairs do
{:ok, %{ahead: ahead, behind: behind}} = Graph.ahead_behind(g, base, head)
base_anc = brute_force_ancestors(repo, base)
head_anc = brute_force_ancestors(repo, head)
expected_ahead = MapSet.size(MapSet.difference(head_anc, base_anc))
expected_behind = MapSet.size(MapSet.difference(base_anc, head_anc))
assert {ahead, behind} == {expected_ahead, expected_behind},
"iter=#{i}: ahead_behind(#{String.slice(base, 0, 7)}, #{String.slice(head, 0, 7)}) = #{inspect({ahead, behind})}, expected #{inspect({expected_ahead, expected_behind})}"
brute = %{
ahead: MapSet.size(MapSet.difference(head_anc, base_anc)),
behind: MapSet.size(MapSet.difference(base_anc, head_anc))
}
{:ok, graph_answer} = Graph.ahead_behind(g, base, head)
{:ok, fallback_answer} = Fallback.ahead_behind(repo, base, head)
assert graph_answer == brute and fallback_answer == brute,
"iter=#{i}: ahead_behind(#{String.slice(base, 0, 7)}, #{String.slice(head, 0, 7)}) — " <>
"graph=#{inspect(graph_answer)} fallback=#{inspect(fallback_answer)} brute=#{inspect(brute)}"
end
end
end
test "commits_between: Graph ≡ Fallback ≡ brute-force set difference" do
test "commits_between: set matches brute-force (ancestors(head) \\ ancestors(base))" do
ex_unit_seed = ExUnit.configuration()[:seed]
for i <- 1..@iterations do
@@ -388,27 +392,31 @@
do: :ok = ExGitObjectstore.create_branch(repo, "b-#{idx}", sha)
g = graph_of(repo)
pairs =
for a <- Enum.take_random(tips, min(3, length(tips))),
b <- Enum.take_random(tips, min(3, length(tips))),
a != b,
do: {a, b}
pairs = sample_pairs(tips, 3)
for {base, head} <- pairs do
brute =
{:ok, got} = Graph.commits_between(g, base, head)
expected =
MapSet.difference(
brute_force_ancestors(repo, head),
brute_force_ancestors(repo, base)
)
assert MapSet.new(got) == expected,
"iter=#{i}: commits_between(#{String.slice(base, 0, 7)}, #{String.slice(head, 0, 7)}) returned #{inspect(got)}, expected set #{inspect(MapSet.to_list(expected))}"
{:ok, graph_list} = Graph.commits_between(g, base, head)
{:ok, fallback_list} = Fallback.commits_between(repo, base, head)
assert MapSet.new(graph_list) == brute and MapSet.new(fallback_list) == brute,
"iter=#{i}: commits_between(#{String.slice(base, 0, 7)}, #{String.slice(head, 0, 7)}) — " <>
"graph=#{length(graph_list)} entries, fallback=#{length(fallback_list)} entries, brute=#{MapSet.size(brute)}"
end
end
end
end
defp sample_pairs(tips, n_per_side) do
for a <- Enum.take_random(tips, min(n_per_side, length(tips))),
b <- Enum.take_random(tips, min(n_per_side, length(tips))),
a != b,
do: {a, b}
end
# --- random DAG + brute-force helpers ---
test/ex_git_objectstore/graph_integration_test.exs +220 −0
@@ -1,0 +1,220 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.GraphIntegrationTest do
@moduledoc """
End-to-end tests for the top-level graph-aware query API:
`ExGitObjectstore.{ahead_behind, commits_between, ancestor?,
rebuild_graph}`.
Verifies all three routing paths:
* graph path — query answered from the cached / persisted graph
* partial graph — at least one SHA not in the graph, caller falls back
* no graph — nothing built, caller falls back
"""
# persistent_term writes; keep serial.
use ExUnit.Case, async: false
alias ExGitObjectstore.Graph.Cache
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Commit, Tree}
alias ExGitObjectstore.Test.RepoHelper
setup do
repo = RepoHelper.memory_repo("int-#{:erlang.unique_integer([:positive])}")
ExGitObjectstore.init(repo)
on_exit(fn -> Cache.delete(repo) end)
%{repo: repo}
end
defp tree!(repo) do
{:ok, sha} = Object.write(repo, Tree.new([]))
sha
end
defp commit!(repo, tree, parents, ts) do
ident = "A <a@a.com> #{ts} +0000"
{:ok, sha} =
Object.write(repo, %Commit{
tree: tree,
parents: parents,
author: ident,
committer: ident,
message: "m\n"
})
sha
end
describe "no graph built — fallback path" do
test "ahead_behind uses fallback walker", %{repo: repo} do
t = tree!(repo)
a = commit!(repo, t, [], 1)
b = commit!(repo, t, [a], 2)
c = commit!(repo, t, [b], 3)
:ok = ExGitObjectstore.create_branch(repo, "main", c)
assert {:ok, %{ahead: 2, behind: 0}} = ExGitObjectstore.ahead_behind(repo, a, c)
end
test "commits_between uses fallback walker", %{repo: repo} do
t = tree!(repo)
a = commit!(repo, t, [], 1)
b = commit!(repo, t, [a], 2)
:ok = ExGitObjectstore.create_branch(repo, "main", b)
assert {:ok, [^b]} = ExGitObjectstore.commits_between(repo, a, b)
end
test "ancestor? uses fallback walker", %{repo: repo} do
t = tree!(repo)
a = commit!(repo, t, [], 1)
b = commit!(repo, t, [a], 2)
:ok = ExGitObjectstore.create_branch(repo, "main", b)
assert {:ok, true} = ExGitObjectstore.ancestor?(repo, a, b)
assert {:ok, false} = ExGitObjectstore.ancestor?(repo, b, a)
end
end
describe "rebuild_graph + graph path" do
test "rebuild populates both storage and cache", %{repo: repo} do
t = tree!(repo)
a = commit!(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", a)
:ok = ExGitObjectstore.rebuild_graph(repo)
assert {:ok, _graph} = Cache.fetch(repo)
end
test "after rebuild, queries hit the graph path and return the same answers", %{repo: repo} do
t = tree!(repo)
a = commit!(repo, t, [], 1)
b = commit!(repo, t, [a], 2)
c = commit!(repo, t, [b], 3)
:ok = ExGitObjectstore.create_branch(repo, "main", c)
:ok = ExGitObjectstore.rebuild_graph(repo)
assert {:ok, %{ahead: 2, behind: 0}} = ExGitObjectstore.ahead_behind(repo, a, c)
assert {:ok, [^c, ^b]} = ExGitObjectstore.commits_between(repo, a, c)
assert {:ok, true} = ExGitObjectstore.ancestor?(repo, a, c)
assert {:ok, false} = ExGitObjectstore.ancestor?(repo, c, a)
end
end
describe "graph present but stale (new commit after build)" do
test "ahead_behind falls back when head is not yet in the graph", %{repo: repo} do
t = tree!(repo)
base = commit!(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", base)
:ok = ExGitObjectstore.rebuild_graph(repo)
# Introduce a new commit AFTER the graph was built — the graph
# does not include `new_head`, so the top-level API must fall
# back to the walker to answer correctly.
new_head = commit!(repo, t, [base], 2)
:ok = ExGitObjectstore.update_branch(repo, "main", new_head, base)
assert {:ok, %{ahead: 1, behind: 0}} = ExGitObjectstore.ahead_behind(repo, base, new_head)
assert {:ok, [^new_head]} = ExGitObjectstore.commits_between(repo, base, new_head)
assert {:ok, true} = ExGitObjectstore.ancestor?(repo, base, new_head)
end
end
describe "telemetry" do
setup do
handler = "graph-query-test-#{:erlang.unique_integer([:positive])}"
test_pid = self()
:telemetry.attach(
handler,
[:ex_git_objectstore, :graph, :query, :stop],
fn _event, measurements, metadata, _cfg ->
send(test_pid, {:graph_query_stop, measurements, metadata})
end,
nil
)
on_exit(fn -> :telemetry.detach(handler) end)
:ok
end
test "graph path emits path: :graph", %{repo: repo} do
t = tree!(repo)
a = commit!(repo, t, [], 1)
b = commit!(repo, t, [a], 2)
:ok = ExGitObjectstore.create_branch(repo, "main", b)
:ok = ExGitObjectstore.rebuild_graph(repo)
assert {:ok, %{ahead: 1, behind: 0}} = ExGitObjectstore.ahead_behind(repo, a, b)
assert_receive {:graph_query_stop, %{duration: dur}, meta}, 1000
assert meta.path == :graph
assert meta.operation == :ahead_behind
assert meta.repo_id == repo.id
assert is_integer(dur) and dur >= 0
end
test "fallback path emits path: :fallback when no graph", %{repo: repo} do
t = tree!(repo)
a = commit!(repo, t, [], 1)
b = commit!(repo, t, [a], 2)
:ok = ExGitObjectstore.create_branch(repo, "main", b)
# No rebuild_graph — graph is missing.
assert {:ok, %{ahead: 1, behind: 0}} = ExGitObjectstore.ahead_behind(repo, a, b)
assert_receive {:graph_query_stop, _, %{path: :fallback, operation: :ahead_behind}}, 1000
end
test "stale graph (sha not present) falls back with path: :fallback", %{repo: repo} do
t = tree!(repo)
base = commit!(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", base)
:ok = ExGitObjectstore.rebuild_graph(repo)
new_head = commit!(repo, t, [base], 2)
:ok = ExGitObjectstore.update_branch(repo, "main", new_head, base)
assert {:ok, _} = ExGitObjectstore.ahead_behind(repo, base, new_head)
assert_receive {:graph_query_stop, _, %{path: :fallback}}, 1000
end
end
describe "rebuild_graph error path" do
test "returns {:error, _} if build fails" do
# Construct a repo whose tips reference commits that don't exist —
# the builder attempts to peel them, which returns {:error, :not_found}.
repo = RepoHelper.memory_repo("rb-err-#{:erlang.unique_integer([:positive])}")
on_exit(fn -> Cache.delete(repo) end)
:ok =
ExGitObjectstore.Repo.storage_call(repo, :put_ref, [
"refs/heads/main",
String.duplicate("0", 40),
nil
])
# build/1 silently drops tips that fail to peel, so a ref pointing at
# nothing just yields an empty graph — that's expected. The negative
# case here is that rebuild_graph/1 succeeds without crashing.
assert :ok = ExGitObjectstore.rebuild_graph(repo)
assert {:ok, graph} = Cache.fetch(repo)
assert ExGitObjectstore.Graph.size(graph) == 0
end
end
end