ref:ed878d981a4f22a333de680e275b1a6f7b9dd184

epic #215 Phase 1 + S1: walker property test + lazy object streaming (#37)

Bundles the library-side work for Anvil epic #215 into one PR per repo. Two commits, two REQs, both backed by failure-injection proof. ## What's in here ### T1 — Walker invariant property test (REQ-GIT-076) \`stream_data\` generator produces random commit graphs (1-3 chains of length 1-6, optional merges, optional mode-160000 gitlinks whose SHA targets either the root commit or the current tip). For every generated graph: assert \`MapSet.new(walker_pack_shas) == bfs_reachable(repo, wants)\`. Catches the entire class of walker bugs that hit production in May 2026 (the gitlink-reachability regression — 1043 reachable objects silently pruned from \`fangorn/hephaestus\`'s pack). **Failure-injection proof.** Temporarily reverting the \`mode: \"160000\"\` head in \`collect_single_tree_entry\` makes the property fail on iteration 0: \`\`\` Failed with generated values (after 0 successful runs): Clause: spec <- graph_spec() Generated: %{..., inject_gitlink?: true, gitlink_target: :self, ...} walker missed reachable SHAs: [\"087267660a...\", \"209f7076a8...\", ... 8 SHAs total ...] \`\`\` Restored: 80 random iterations pass in ~100 ms. Adds \`{:stream_data, \"~> 1.1\", only: :test}\` to deps. ### S1 — Stream walker objects through writer (REQ-GIT-080) After \`collect_objects_maybe_shallow\` returns the walker's \`[{type, content, sha}, ...]\` list, project it down to \`[{type, sha}, ...]\` and let the original content list go out of scope (so it's GC'd). Pipe the SHA list through a \`Stream.map(fn {type, sha} -> ... ObjectResolver.read(repo, sha) ... end)\` into a new \`Writer.generate_stream_enum/4\`. Pack-write phase peak heap is bounded by one object at a time. Surface changes: - **New** \`Writer.generate_stream_enum/4\` — accepts \`Enumerable.t/0\` + explicit count. The pack format needs the count in the hashed header, so it cannot be deferred. Same byte output as the existing \`generate_stream/3\`. - \`generate_stream/3\` becomes a thin wrapper that supplies \`length/1\` for list inputs. Same public contract. - \`UploadPackV2.stream_packfile_response\` uses \`drop_content/1\` + \`stream_object_contents/2\` helpers to set up the lazy stream. **Measured impact** (Anvil REQ-GIT-077 budget test, 10 MiB random-blob fetch, avg peak BEAM heap delta over 3 runs): | Library version | Peak heap delta | |---|---| | main (eager) | 14.7 MB | | **this branch** | **3.5 MB** | ~4× reduction on the test fixture. Scaled to prod hephaestus (~210 MB pack), absolute savings should exceed 200 MB. The reduction is in the pack-write phase; the walk phase still allocates content (will be addressed by S4 / pass-through packed object reuse in a follow-up). ## Test plan - [x] \`mix test\` — 945 tests, 0 failures (51 excluded) - [x] \`mix format --check-formatted\` clean - [x] \`mix credo --strict\` clean - [x] Property test demonstrated to fail on the gitlink shape when the fix is reverted - [x] S1 byte output verified equivalent to non-streaming via existing v2 byte-equivalence tests ## Tracks Epic Anvil #215 (REQ-GIT-076, REQ-GIT-080).
SHA: ed878d981a4f22a333de680e275b1a6f7b9dd184
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-05-14 18:10
Parents: 81b6f84
5 files changed +450 -10
Type
lib/ex_git_objectstore/pack/writer.ex +23 −1
@@ -98,7 +98,29 @@
when acc: var
def generate_stream(objects, acc, write_fn)
when is_list(objects) and is_function(write_fn, 2) do
count = length(objects)
generate_stream_enum(objects, length(objects), acc, write_fn)
end
@doc """
Stream a packfile from an arbitrary `Enumerable.t/0` of object
entries with a known `count` (REQ-GIT-080).
Pack format requires the object count in the 12-byte header — which
is hashed into the trailing SHA-1 — so the count cannot be deferred.
Callers that want to stream from the walker pass an enumerable plus
a pre-computed count.
Behaves exactly like `generate_stream/3` (same byte output, same
hashing), but the caller may pass a `Stream.t/0` so the entries are
pulled lazily and never fully materialized in memory. The walker
uses this to avoid holding all ~10k pack entries simultaneously for
a large clone.
"""
@spec generate_stream_enum(Enumerable.t(), non_neg_integer(), acc, (binary(), acc -> acc)) ::
{String.t(), non_neg_integer(), acc}
when acc: var
def generate_stream_enum(objects, count, acc, write_fn)
when is_integer(count) and count >= 0 and is_function(write_fn, 2) do
header = <<@pack_signature, @pack_version::unsigned-big-32, count::unsigned-big-32>>
hasher = :crypto.hash_init(:sha)
acc = write_fn.(header, acc)
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +48 −8
@@ -612,7 +612,14 @@
) do
case collect_objects_maybe_shallow(repo, wants, haves, shallow_opts, filter_spec) do
{:ok, %{objects: objects} = walk} ->
Logger.info("UploadPackV2: collected #{length(objects)} objects, streaming pack")
# Anvil #215 / REQ-GIT-080: project the walker's
# `[{type, content, sha}, …]` list down to just `[{type, sha}, …]`
# before starting to write the pack. The original `objects`
# list (and all its content) can then be GC'd, so the pack-
# write phase peak is bounded by one object at a time rather
# than the full pack size in `content` strings.
{type_shas, count} = drop_content(objects)
Logger.info("UploadPackV2: collected #{count} objects, streaming pack")
shallow_info = build_shallow_info(walk)
packfile_header = PktLine.encode("packfile")
@@ -620,14 +627,19 @@
# Prefix (ack + shallow + 'packfile' pkt-line) is small — emit as one write.
write_fn.(IO.iodata_to_binary([ack_section, shallow_info, packfile_header]))
# Pack body streamed through a sideband-1 chunker. Objects are
# read from storage lazily — one at a time — so neither this
# Pack body streamed through a sideband-1 chunker. The accumulator
# carries both the sideband state and a byte counter so we can
# report pack_bytes for telemetry without holding the full pack.
# function nor the writer ever hold the materialized list.
sideband = SidebandWriter.new(1, write_fn)
{_pack_sha, count, {sideband, pack_bytes}} =
{_pack_sha, ^count, {sideband, pack_bytes}} =
Writer.generate_stream_enum(
Writer.generate_stream(objects, {sideband, 0}, fn bytes, {sb, n} ->
{SidebandWriter.write(sb, bytes), n + byte_size(bytes)}
end)
stream_object_contents(repo, type_shas),
count,
{sideband, 0},
fn bytes, {sb, n} ->
{SidebandWriter.write(sb, bytes), n + byte_size(bytes)}
end
)
:ok = SidebandWriter.finish(sideband)
@@ -647,6 +659,34 @@
write_fn.(PktLine.flush())
%{pack_bytes: 0, objects: 0, error: reason}
end
end
# Project the walker's [{type, content, sha}, ...] list down to
# [{type, sha}, ...] and the count. Wrapping this in a function call
# means the original `objects` list goes out of scope as soon as we
# return — BEAM is free to GC its content immediately.
defp drop_content(objects) do
{type_shas, count} =
Enum.reduce(objects, {[], 0}, fn {type, _content, sha}, {acc, n} ->
{[{type, sha} | acc], n + 1}
end)
{Enum.reverse(type_shas), count}
end
# Lazy stream of {type, content, sha} tuples produced by reading each
# `{type, sha}` from storage on demand. Pairs with
# `Writer.generate_stream_enum/4` so the pack-write phase never holds
# more than one object's content at a time.
defp stream_object_contents(repo, type_shas) do
Stream.map(type_shas, fn {type, sha} ->
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = c} -> {type, Object.encode_content_only(c), sha}
{:ok, %Tree{} = t} -> {type, Tree.encode_content(t), sha}
{:ok, %Blob{content: content}} -> {type, content, sha}
{:ok, %Tag{} = tag} -> {type, Object.encode_content_only(tag), sha}
end
end)
end
defp build_packfile_response(repo, wants, haves, ack_section, shallow_opts, filter_spec) do
mix.exs +2 −1
@@ -101,7 +101,8 @@
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:plug, "~> 1.16", only: :test},
{:bandit, "~> 1.5", only: :test}
{:bandit, "~> 1.5", only: :test},
{:stream_data, "~> 1.1", only: :test}
]
end
end
mix.lock +1 −0
@@ -25,6 +25,7 @@
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
test/ex_git_objectstore/protocol/upload_pack_v2_walker_property_test.exs +376 −0
@@ -1,0 +1,376 @@
# 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.Protocol.UploadPackV2WalkerPropertyTest do
@moduledoc """
Property test for the v2 fetch walker (Anvil #215 / REQ-GIT-076).
Builds a random commit graph (with random subtree depth, randomly
injected gitlink entries, and a fraction of merge commits) and checks
one invariant: the SHA set produced by `UploadPackV2.feed/2` for a
given want-list must equal the SHA set reachable from those wants by
a plain BFS over the object graph.
Catches the entire class of walker bugs we have hit in production,
including the May 2026 gitlink-reachability regression (Anvil #214)
where a `mode: \"160000\"` tree entry whose SHA happened to match a
real commit caused the walker to silently prune the commit's entire
ancestor subgraph.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias ExGitObjectstore.{Object, Pack.Reader, Ref}
alias ExGitObjectstore.Object.{Blob, Commit, Tree, Tag}
alias ExGitObjectstore.Protocol.{PktLine, UploadPackV2}
alias ExGitObjectstore.Test.RepoHelper
@moduletag :property
describe "v2 fetch walker reachability invariant" do
@describetag requirements: ["REQ-GIT-076"]
property "pack response contains exactly the BFS-reachable object SHAs" do
check all(
spec <- graph_spec(),
max_runs: 80
) do
repo = RepoHelper.memory_repo("walker-prop-#{System.unique_integer([:positive])}")
ExGitObjectstore.init(repo)
%{tip_shas: tip_shas} = build_graph(repo, spec)
# Walker output (what gets sent to the client in the pack body).
walker_shas = walker_pack_shas(repo, tip_shas)
# Ground truth: every SHA reachable from `tip_shas` by walking
# commit parents, tree subtrees, blob references, and tag
# targets — using whatever the storage layer actually serves.
bfs_shas = bfs_reachable(repo, tip_shas)
only_in_walker = MapSet.difference(walker_shas, bfs_shas)
only_in_bfs = MapSet.difference(bfs_shas, walker_shas)
assert MapSet.size(only_in_walker) == 0,
"walker emitted SHAs that aren't reachable: #{inspect(MapSet.to_list(only_in_walker))}\nspec: #{inspect(spec)}"
assert MapSet.size(only_in_bfs) == 0,
"walker missed reachable SHAs: #{inspect(MapSet.to_list(only_in_bfs))}\nspec: #{inspect(spec)}"
end
end
# REQ-GIT-081 / S7: partial-clone audit. The walker has special-case
# plumbing (`early_skip_blobs?/1` + `apply_filter/3` + `:skip_blobs`
# walk_opt) for `--filter=blob:none`. This property asserts that
# plumbing holds end-to-end: the wire pack must contain zero blobs
# when the client sends `filter blob:none`, and the reachable set
# minus blobs equals the wire set.
property "filter blob:none excludes every blob from the pack body" do
check all(
spec <- graph_spec(),
max_runs: 40
) do
repo = RepoHelper.memory_repo("filter-prop-#{System.unique_integer([:positive])}")
ExGitObjectstore.init(repo)
%{tip_shas: tip_shas} = build_graph(repo, spec)
walker_shas = walker_pack_shas(repo, tip_shas, filter: "blob:none")
walker_blob_shas = blob_shas(repo, walker_shas)
assert MapSet.size(walker_blob_shas) == 0,
"filter blob:none should exclude every blob but #{MapSet.size(walker_blob_shas)} blobs leaked: #{inspect(MapSet.to_list(walker_blob_shas) |> Enum.take(5))}\nspec: #{inspect(spec)}"
# Non-blob reachable set must equal the wire set — we filtered
# blobs, not anything else.
bfs_non_blob = bfs_reachable(repo, tip_shas) |> MapSet.reject(&blob?(repo, &1))
assert MapSet.equal?(walker_shas, bfs_non_blob),
"walker drift on filter blob:none. only-walker=#{inspect(MapSet.difference(walker_shas, bfs_non_blob) |> MapSet.to_list())} only-bfs=#{inspect(MapSet.difference(bfs_non_blob, walker_shas) |> MapSet.to_list())}\nspec: #{inspect(spec)}"
end
end
end
# ── Graph spec generator ───────────────────────────────────────────
# A graph spec describes the shape of a small commit graph we'll
# build before running the walker on it. We deliberately mix:
# - linear chains (typical)
# - merge commits (multiple parents)
# - gitlink tree entries (the prod regression case)
#
# Keeping the parameter ranges small keeps each property iteration
# fast (< 50ms) while still exercising the structural shapes that
# matter for the walker.
defp graph_spec do
gen all(
chain_count <- integer(1..3),
chain_lengths <- list_of(integer(1..6), length: chain_count),
branches <- integer(1..min(3, chain_count)),
inject_gitlink? <- boolean(),
gitlink_target <- one_of([constant(:self), constant(:root)]),
inject_merge? <- boolean()
) do
%{
chain_count: chain_count,
chain_lengths: chain_lengths,
branches: branches,
inject_gitlink?: inject_gitlink?,
gitlink_target: gitlink_target,
inject_merge?: inject_merge?
}
end
end
# ── Repo construction ──────────────────────────────────────────────
defp build_graph(repo, spec) do
# Build N independent chains of commits, then optionally merge them.
chains =
Enum.map(0..(spec.chain_count - 1), fn idx ->
build_chain(repo, "chain#{idx}", Enum.at(spec.chain_lengths, idx))
end)
tips =
chains
|> Enum.take(spec.branches)
|> Enum.map(&List.last/1)
tips =
if spec.inject_merge? and length(tips) >= 2 do
[merge_tip(repo, tips) | tl(tips)]
else
tips
end
tips =
if spec.inject_gitlink? do
target =
case spec.gitlink_target do
:root -> hd(hd(chains))
:self -> hd(tips)
end
[add_gitlink_commit(repo, hd(tips), target) | tl(tips)]
else
tips
end
Enum.with_index(tips, fn sha, i ->
:ok = Ref.put(repo, "refs/heads/tip#{i}", sha, nil)
end)
%{tip_shas: tips}
end
defp build_chain(repo, label, length) do
Enum.reduce(1..length, [], fn i, acc ->
parents = if acc == [], do: [], else: [List.last(acc)]
sha = commit_with_file(repo, "#{label}/f#{i}", "content #{label} #{i}\n", parents)
acc ++ [sha]
end)
end
defp commit_with_file(repo, path, content, parents) do
{dir, name} =
case String.split(path, "/", parts: 2) do
[d, n] -> {d, n}
[n] -> {nil, n}
end
blob = Blob.from_content(content)
{:ok, blob_sha} = Object.write(repo, blob)
tree_entries =
if dir do
inner = Tree.new([%{mode: "100644", name: name, sha: blob_sha}])
{:ok, inner_sha} = Object.write(repo, inner)
[%{mode: "40000", name: dir, sha: inner_sha}]
else
[%{mode: "100644", name: name, sha: blob_sha}]
end
tree = Tree.new(tree_entries)
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: parents,
author: "T <t@t> 1000000000 +0000",
committer: "T <t@t> 1000000000 +0000",
message: "#{path}\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
commit_sha
end
defp merge_tip(repo, tips) do
blob = Blob.from_content("merge\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "merge.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: tips,
author: "T <t@t> 1000000000 +0000",
committer: "T <t@t> 1000000000 +0000",
message: "merge\n"
}
{:ok, sha} = Object.write(repo, commit)
sha
end
# Build a commit whose tree contains a gitlink entry pointing at
# `link_target_sha`. This is the exact shape that caused 1043
# reachable objects to disappear from hephaestus's pack response
# before the fix in ex_git_objectstore PR #33.
defp add_gitlink_commit(repo, parent_sha, link_target_sha) do
blob = Blob.from_content("gitlink commit\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree =
Tree.new([
%{mode: "100644", name: "f.txt", sha: blob_sha},
%{mode: "160000", name: "submodule", sha: link_target_sha}
])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [parent_sha],
author: "T <t@t> 1000000000 +0000",
committer: "T <t@t> 1000000000 +0000",
message: "gitlink\n"
}
{:ok, sha} = Object.write(repo, commit)
sha
end
# ── Walker invocation ──────────────────────────────────────────────
defp walker_pack_shas(repo, tip_shas, opts \\ []) do
{_advert, state} = UploadPackV2.init(repo)
want_pkts =
tip_shas
|> Enum.map(&PktLine.encode("want " <> &1))
|> Enum.join("")
filter_pkt =
case Keyword.get(opts, :filter) do
nil -> ""
spec -> PktLine.encode("filter " <> spec)
end
fetch_req =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
want_pkts <>
filter_pkt <>
PktLine.encode("done") <>
PktLine.flush()
{response, _new_state} = UploadPackV2.feed(state, fetch_req)
pack = extract_pack(response)
case Reader.parse(pack) do
{:ok, entries} -> Enum.map(entries, & &1.sha) |> MapSet.new()
err -> flunk("Reader.parse failed: #{inspect(err)}\npack: #{byte_size(pack)} bytes")
end
end
defp blob?(repo, sha) do
case ExGitObjectstore.ObjectResolver.read(repo, sha) do
{:ok, %Blob{}} -> true
_ -> false
end
end
defp blob_shas(repo, sha_set) do
sha_set
|> Enum.filter(&blob?(repo, &1))
|> MapSet.new()
end
# ── Ground-truth BFS reachability ──────────────────────────────────
# Pure BFS over the object graph as the storage actually serves it.
# NOTE: we exclude submodule (mode 160000) entry SHAs — those name
# commits in another repo and are not expected to be in this repo's
# pack response, even when the SHA happens to also exist locally.
defp bfs_reachable(repo, starts) do
walk(repo, starts, MapSet.new())
end
defp walk(_repo, [], visited), do: visited
defp walk(repo, [sha | rest], visited) do
if MapSet.member?(visited, sha) do
walk(repo, rest, visited)
else
next =
case ExGitObjectstore.ObjectResolver.read(repo, sha) do
{:ok, %Commit{tree: t, parents: ps}} ->
[t | ps]
{:ok, %Tree{entries: es}} ->
for e <- es, e.mode != "160000", do: e.sha
{:ok, %Blob{}} ->
[]
{:ok, %Tag{object: t}} ->
[t]
_ ->
[]
end
walk(repo, next ++ rest, MapSet.put(visited, sha))
end
end
# ── Helpers ────────────────────────────────────────────────────────
# Strip v2 protocol framing and concatenate sideband-1 payloads to
# recover the raw pack bytes from a fetch response. Returns `<<>>`
# when the response has no packfile section (e.g. the walker
# returned an error and only a flush was sent).
defp extract_pack(response) do
{:ok, packets, _rest} = PktLine.decode(response)
# PktLine.decode strips the trailing LF from data packets, so the
# "packfile" marker arrives without its newline.
case Enum.drop_while(packets, fn
{:data, "packfile"} -> false
_ -> true
end) do
[] ->
<<>>
[_packfile_marker | body] ->
body
|> Enum.flat_map(fn
{:data, <<1, chunk::binary>>} -> [chunk]
_ -> []
end)
|> IO.iodata_to_binary()
end
end
end