ref:c38581b3d812930b762297915f9d6070d6133aa2

Merge pull request #25 from graph-ahead-behind-perf into main

SHA: c38581b3d812930b762297915f9d6070d6133aa2
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-04-30 05:01
4 files changed +332 -0
Type
lib/ex_git_objectstore.ex +55 −0
@@ -730,5 +730,60 @@
end
@doc """
Like `ahead_behind/3`, but for many heads against a single base.
Walks `ancestors(base)` once and reuses it across every head, instead
of re-walking it for each call. For workloads where `head_shas` are
many small offsets from a common base (e.g. a PR-list page where
every PR has `base = main`), this turns
`O(N · |ancestors(base)|)` into `O(|ancestors(base)| + Σ head walks)`.
Returns `{:ok, %{head_sha => %{ahead: N, behind: M}}}` with one entry
per head. Heads not in the graph or whose ref couldn't be resolved
fall back to per-head `ahead_behind/3` (which has its own
cat_object walker fallback). If the graph itself isn't available,
every head goes through the per-head fallback.
Emits `[:ex_git_objectstore, :graph, :query]` telemetry with
`operation: :ahead_behind_many`. `path` is `:graph` when the batched
fast path was used, `:fallback` when nothing was in the graph and
every head went through the per-head walker.
"""
@spec ahead_behind_many(Repo.t(), sha(), [sha()]) ::
{:ok, %{sha() => %{ahead: non_neg_integer(), behind: non_neg_integer()}}}
| {:error, term()}
def ahead_behind_many(%Repo{} = repo, base_sha, head_shas) when is_list(head_shas) do
metadata = %{operation: :ahead_behind_many, repo_id: repo.id}
Telemetry.span([:ex_git_objectstore, :graph, :query], metadata, fn ->
case load_or_fetch_graph(repo) do
{:ok, graph} ->
if Graph.member?(graph, base_sha) do
{:ok, fast} = Graph.ahead_behind_many(graph, base_sha, head_shas)
missing = Enum.reject(head_shas, &Map.has_key?(fast, &1))
merged = fill_per_head(repo, base_sha, missing, fast)
{{:ok, merged}, Map.put(metadata, :path, :graph)}
else
merged = fill_per_head(repo, base_sha, head_shas, %{})
{{:ok, merged}, Map.put(metadata, :path, :fallback)}
end
{:error, _} ->
merged = fill_per_head(repo, base_sha, head_shas, %{})
{{:ok, merged}, Map.put(metadata, :path, :fallback)}
end
end)
end
defp fill_per_head(repo, base_sha, head_shas, acc) do
Enum.reduce(head_shas, acc, fn head_sha, acc ->
case ahead_behind(repo, base_sha, head_sha) do
{:ok, counts} -> Map.put(acc, head_sha, counts)
{:error, _} -> acc
end
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`.
lib/ex_git_objectstore/graph.ex +112 −0
@@ -180,6 +180,41 @@
end
@doc """
Like `ahead_behind/3`, but takes one base and many heads. Walks
`ancestors(base)` once and reuses it across every head — turns the
cost from `O(N · |ancestors(base)|)` into
`O(|ancestors(base)| + Σ head-only walks)`.
Returns `{:ok, %{head_sha => %{ahead: N, behind: M}}}` containing one
entry per head that is in the graph. Heads missing from the graph are
omitted; the caller (typically a wrapper that knows about ref-walker
fallbacks) is responsible for filling them in. Returns
`{:error, :missing_commit}` if `base_sha` is not in the graph.
"""
@spec ahead_behind_many(t(), sha(), [sha()]) ::
{:ok, %{sha() => %{ahead: non_neg_integer(), behind: non_neg_integer()}}}
| {:error, :missing_commit}
def ahead_behind_many(%__MODULE__{by_sha: by_sha}, base_sha, head_shas)
when is_list(head_shas) do
with {:ok, _} <- fetch_entry(by_sha, base_sha) do
base_ancestors = collect_ancestors(by_sha, [base_sha], %{})
base_size = map_size(base_ancestors)
result =
Enum.reduce(head_shas, %{}, fn head_sha, acc ->
if Map.has_key?(by_sha, head_sha) do
counts = ahead_behind_against(by_sha, base_ancestors, base_size, head_sha)
Map.put(acc, head_sha, counts)
else
acc
end
end)
{:ok, result}
end
end
@doc """
Return commits reachable from `head_sha` but not from `base_sha`, in
descending corrected-commit-date order (newest first). Empty when
`head_sha` is an ancestor of (or equal to) `base_sha`.
@@ -307,6 +342,83 @@
[item | list]
else
[head | insert_by_gen(rest, item)]
end
end
# -- ahead_behind_many helpers ---------------------------------------
# Collect every ancestor of the seed SHAs (inclusive) into a plain map
# used as a set. Order doesn't matter — membership is the only query.
defp collect_ancestors(_by, [], acc), do: acc
defp collect_ancestors(by, [sha | rest], acc) do
if Map.has_key?(acc, sha) do
collect_ancestors(by, rest, acc)
else
%{parents: parents} = Map.fetch!(by, sha)
collect_ancestors(by, parents ++ rest, Map.put(acc, sha, true))
end
end
defp ahead_behind_against(by_sha, base_ancestors, base_size, head_sha) do
{ahead_count, merge_points} =
walk_head(by_sha, base_ancestors, [head_sha], %{}, 0, [])
intersect_size = collect_within(by_sha, base_ancestors, merge_points, %{}, 0)
%{ahead: ahead_count, behind: base_size - intersect_size}
end
# Walk from `head_sha` toward roots. A commit in `base_ancestors` is a
# merge point — record it and stop pushing parents from this branch
# (everything reachable through it is also in `base_ancestors`, so it
# contributes to the intersection, not to ahead). A commit not in
# `base_ancestors` is a head-only commit — bump the ahead count and
# push its parents.
defp walk_head(_by, _base, [], _seen, ahead_count, merge_points),
do: {ahead_count, merge_points}
defp walk_head(by, base, [sha | rest], seen, ahead_count, merge_points) do
cond do
Map.has_key?(seen, sha) ->
walk_head(by, base, rest, seen, ahead_count, merge_points)
Map.has_key?(base, sha) ->
walk_head(by, base, rest, Map.put(seen, sha, true), ahead_count, [sha | merge_points])
true ->
%{parents: parents} = Map.fetch!(by, sha)
walk_head(
by,
base,
parents ++ rest,
Map.put(seen, sha, true),
ahead_count + 1,
merge_points
)
end
end
# BFS DOWN from `merge_points`, restricted to commits within
# `base_ancestors`. Returns the count of distinct commits visited —
# this is `|ancestors(head) ∩ ancestors(base)|`. Behind is then
# `|ancestors(base)| - intersection`.
defp collect_within(_by, _base, [], _seen, count), do: count
defp collect_within(by, base, [sha | rest], seen, count) do
cond do
Map.has_key?(seen, sha) ->
collect_within(by, base, rest, seen, count)
not Map.has_key?(base, sha) ->
# Defensive: caller seeds with merge points that are by
# construction in `base`, so this branch shouldn't fire.
collect_within(by, base, rest, seen, count)
true ->
%{parents: parents} = Map.fetch!(by, sha)
collect_within(by, base, parents ++ rest, Map.put(seen, sha, true), count + 1)
end
end
end
test/ex_git_objectstore/graph/queries_test.exs +115 −0
@@ -232,6 +232,121 @@
end
end
# --- ahead_behind_many ---
describe "ahead_behind_many/3" do
test "matches per-head ahead_behind for the chiron PR-list shape" do
# `base = main`, heads are scattered across main's history (the
# case where most PRs are already merged), plus one ahead-only
# head. This is the exact workload the batched API exists for.
repo = init_repo()
t = empty_tree_sha(repo)
main =
Enum.reduce(1..30, [nil], fn ts, [parent | _] = acc ->
parents = if parent, do: [parent], else: []
[commit(repo, t, parents, ts) | acc]
end)
|> Enum.reverse()
|> tl()
base = List.last(main)
:ok = ExGitObjectstore.create_branch(repo, "main", base)
# 5 heads at positions 5, 10, 15, 20, 25 of main (already-merged
# PRs in chiron parlance).
merged_heads = for i <- [5, 10, 15, 20, 25], do: Enum.at(main, i)
# 1 head ahead of main: branched off main and added 3 commits.
ahead_head =
Enum.reduce(1..3, base, fn ts, parent ->
commit(repo, t, [parent], 100 + ts)
end)
:ok = ExGitObjectstore.create_branch(repo, "ahead", ahead_head)
heads = merged_heads ++ [ahead_head]
g = graph_of(repo)
{:ok, batched} = Graph.ahead_behind_many(g, base, heads)
# Cross-check every head against the unbatched implementation.
for head <- heads do
{:ok, expected} = Graph.ahead_behind(g, base, head)
assert Map.fetch!(batched, head) == expected
end
end
test "diverged heads are counted correctly" do
repo = init_repo()
t = empty_tree_sha(repo)
root = commit(repo, t, [], 1)
base_a = commit(repo, t, [root], 2)
base_b = commit(repo, t, [base_a], 3)
head1 = commit(repo, t, [root], 4)
head2 = commit(repo, t, [base_a], 5)
:ok = ExGitObjectstore.create_branch(repo, "base", base_b)
:ok = ExGitObjectstore.create_branch(repo, "h1", head1)
:ok = ExGitObjectstore.create_branch(repo, "h2", head2)
g = graph_of(repo)
{:ok, result} = Graph.ahead_behind_many(g, base_b, [head1, head2])
# head1 branches at root → 1 ahead (head1), 2 behind (base_a, base_b).
assert Map.fetch!(result, head1) == %{ahead: 1, behind: 2}
# head2 branches at base_a → 1 ahead (head2), 1 behind (base_b).
assert Map.fetch!(result, head2) == %{ahead: 1, behind: 1}
end
test "head equal to base is 0/0" do
repo = init_repo()
t = empty_tree_sha(repo)
a = commit(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", a)
g = graph_of(repo)
assert {:ok, %{^a => %{ahead: 0, behind: 0}}} = Graph.ahead_behind_many(g, a, [a])
end
test "missing head is omitted from result" do
repo = init_repo()
t = empty_tree_sha(repo)
a = commit(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", a)
g = graph_of(repo)
missing = String.duplicate("0", 40)
{:ok, result} = Graph.ahead_behind_many(g, a, [a, missing])
assert Map.has_key?(result, a)
refute Map.has_key?(result, missing)
end
test "missing base returns :missing_commit" do
repo = init_repo()
t = empty_tree_sha(repo)
a = commit(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", a)
g = graph_of(repo)
missing = String.duplicate("0", 40)
assert {:error, :missing_commit} = Graph.ahead_behind_many(g, missing, [a])
end
test "empty head list returns empty map" do
repo = init_repo()
t = empty_tree_sha(repo)
a = commit(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", a)
g = graph_of(repo)
assert {:ok, result} = Graph.ahead_behind_many(g, a, [])
assert result == %{}
end
end
# --- commits_between ---
describe "commits_between/3" do
test/ex_git_objectstore/graph_integration_test.exs +50 −0
@@ -195,6 +195,56 @@
end
end
describe "ahead_behind_many" do
test "graph path matches per-head ahead_behind", %{repo: repo} do
t = tree!(repo)
base = commit!(repo, t, [], 1)
mid = commit!(repo, t, [base], 2)
ahead_only = commit!(repo, t, [mid], 3)
diverged = commit!(repo, t, [base], 4)
:ok = ExGitObjectstore.create_branch(repo, "main", mid)
:ok = ExGitObjectstore.create_branch(repo, "ahead", ahead_only)
:ok = ExGitObjectstore.create_branch(repo, "div", diverged)
:ok = ExGitObjectstore.rebuild_graph(repo)
heads = [base, ahead_only, diverged]
{:ok, batched} = ExGitObjectstore.ahead_behind_many(repo, mid, heads)
for h <- heads do
{:ok, expected} = ExGitObjectstore.ahead_behind(repo, mid, h)
assert Map.fetch!(batched, h) == expected
end
end
test "missing-head heads fall back to per-head walker", %{repo: repo} do
t = tree!(repo)
base = commit!(repo, t, [], 1)
:ok = ExGitObjectstore.create_branch(repo, "main", base)
:ok = ExGitObjectstore.rebuild_graph(repo)
# Add a head AFTER the graph was built — Graph.ahead_behind_many
# will skip it; the wrapper must fall back per-head.
new_head = commit!(repo, t, [base], 2)
:ok = ExGitObjectstore.update_branch(repo, "main", new_head, base)
{:ok, result} = ExGitObjectstore.ahead_behind_many(repo, base, [base, new_head])
assert Map.fetch!(result, base) == %{ahead: 0, behind: 0}
assert Map.fetch!(result, new_head) == %{ahead: 1, behind: 0}
end
test "no graph at all → every head goes through fallback", %{repo: repo} do
t = tree!(repo)
base = commit!(repo, t, [], 1)
head = commit!(repo, t, [base], 2)
:ok = ExGitObjectstore.create_branch(repo, "main", head)
# No rebuild_graph.
{:ok, result} = ExGitObjectstore.ahead_behind_many(repo, base, [head])
assert Map.fetch!(result, head) == %{ahead: 1, behind: 0}
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 —