ref:6463a30d88717e424d6d830dcf42b518f93361e4

perf(s3): ranged pack reads + adaptive ObjectResolver (no whole-pack fetch for point reads) (#44)

## Why Investigating anvil's Filesystem→S3 git cutover (anvil #84/#325), a single-object read on a packed repo downloaded the **entire pack** and parsed the **whole index**. Cold read on S3 (134k-object repo, 36 MiB pack): **~1300 ms** — making the common web workload (view a file/commit/PR) unusable on S3. ## What 1. **Index.lookup_in_raw/2** — O(log n) point lookup from raw .idx bytes (fanout + binary search), no full parse. Cross-checked vs parse+lookup for every SHA of a real index. 2. **get_pack_range/5** optional storage callback — S3 HTTP Range GET, Filesystem pread, Memory slice. 3. **Reader.read_object_ranged/3** — read one object via windowed ranged fetches, resolving OFS/REF delta bases with more ranged fetches; truncation detected via the header's declared uncompressed size. Cross-checked vs whole-pack read for every object incl. delta chains + a >window object. 4. **Adaptive ObjectResolver** — point reads go ranged (O(1) round-trips, no whole-pack); a pack escalates to whole-pack after 64 reads/process so clone/walk still pay one fetch. 5. Fixed clear_pack_cache/0 (was a no-op for 3-tuple-keyed caches). ## Results (S3-packed via MinIO, 134k-object repo) | workload | before | after | |---|--:|--:| | single-object cold read | 1304 ms | **38 ms** (matches S3-loose) | | tree HEAD cold | 107 ms | 18 ms | | full log walk (20,797) | 10.8 s | 4.6 s | | clone (11k objects) | 14.0 s | 4.2 s | ## Tests Full suite **979 passed**; the Memory backend now advertises get_pack_range, so the whole suite (graph/diff/walk/upload-pack) exercises the ranged path. New: index/reader cross-checks + window growth + S3 Range-GET against MinIO. ## Follow-up On real S3 the per-pack .idx download becomes the cold-read long pole; a cross-request index cache would close it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
SHA: 6463a30d88717e424d6d830dcf42b518f93361e4
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-01 19:43
Parents: cd9c2e3
16 files changed +1656 -68
Type
bench/RESULTS.md +38 −0
@@ -1,0 +1,38 @@
# Read-path benchmark — source of truth for the perf effort
Generated by `mix exgo.bench` (see moduledoc) via `bench/run_all.sh`.
Repo: a 134,921-object packed mirror. Median of 3 runs (clone: 1 run, 60s
per-op cap). Numbers in **ms**. `>N` = exceeded the cap (lower bound).
| operation | native git | FS (local) | FS on CephFS | S3 / MinIO | S3 / Ceph RGW |
| --- | --- | --- | --- | --- | --- |
| resolve HEAD | 4.7 | 0.1 | 7.4 | 1.5 | 12.1 |
| read 1 commit | 4.6 | 0.6 | 9.4 | 4.4 | 6.5 |
| read 1 blob | 5.1 | 0.7 | 5.4 | 5.6 | 7.9 |
| open file by path | 5.6 | 0.9 | 13.9 | 8.0 | 9.9 |
| list root tree | 4.7 | 0.4 | 7.8 | 5.5 | 7.1 |
| tree + blob sizes | 5.3 | 5.9 | 106.0 | 56.3 | 64.6 |
| commit log page (50) | 6.3 | 2.6 | 34.3 | 21.2 | 25.4 |
| full history walk | 7.7 | 2720.3 | 17294.2 | 2841.0 | 2825.4 |
| diff two commits | 17.4 | 327.4 | 1547.5 | 489.3 | 549.4 |
| ahead/behind | 5.4 | 18.7 | 51.3 | 20.0 | 19.3 |
| merge-base | 4.7 | 4.4 | 609.4 | 35.6 | 44.6 |
| clone (read all reachable) | 178.2 | >6.0e4 | >6.0e4 | >6.0e4 | >6.0e4 |
## Reading this table
- **native git** is the reference ("what's achievable"). It carries a ~5-15 ms
floor = git CLI process-spawn per op (sub-ms work on cheap ops), so the
walk/clone gaps are *understated*.
- **Ceph** (RGW + CephFS) ran as an emulated-amd64 single-node demo — directional,
and its idle CPU adds noise to all columns in a shared run.
- All ExGitObjectstore columns use the ranged reader (ex_git_objectstore PR #44).
## Gaps to close (vs native git) — the perf backlog
- **clone >150x** (timed out >60s vs ~0.2-0.4s): no pack reuse; reachable walk
decodes every object.
- **full history walk ~270x** (and worse on CephFS/FUSE): per-commit object read
with delta re-resolution; no commit-graph for topology.
- **diff ~35x**, **tree+blob-sizes 3-11x**, **merge-base** amplified on slow backends.
- Common cause: per-object zlib inflate + delta-base re-resolution with **no
cross-read object/delta-base cache**, and **no commit-graph**. Point reads are
already at parity — the gaps are all in multi-object walk/decode paths.
bench/ROOT_CAUSE.md +146 −0
@@ -1,0 +1,146 @@
# Read-path perf audit — root cause of the walk/diff/clone gaps
Audit of the big gaps in `bench/RESULTS.md` (full history walk ~350×, diff ~19×,
clone >150× vs native git). Every claim below is backed by a measurement, not a
theory. Repro scripts referenced live in `anvil/priv/repro/`.
## Status
There are **two independent root causes**, both confirmed by experiment:
1. **`find_compressed_length` re-decompression** (per-object-read overhead) —
**FIXED** in this branch (commit `30063c9`). The random-access read paths now
use `decompress_only/1` (no length recovery). Measured FS-local vs native git:
full walk **2720 → 577 ms (4.7×)**, merge-base **4.4 → 1.7 ms** (now *faster*
than git), diff **327 → 209 ms**. 207 pack/resolver/protocol tests pass.
2. **No delta-base cache** (deep-delta chain re-decode) — **CONFIRMED, not yet
fixed.** This is the *clone* root cause (and contributes to diff). Reading a
depth-40 blob does 41 decompressions (chain depth + 1) — correct for one read,
but with no cross-read cache a clone re-decodes shared base chains for all
134k objects ≈ O(objects × avg-depth ~17) ≈ 2.3M decompressions → times out.
Note: cause #2 (a delta-base cache) was my *initial* theory; it's **wrong for the
commit-walk** (HEAD-side commits are pack bases, so #1 was the cause there) but
**right for clone**. Both only became clear by testing each.
## TL;DR (original finding for cause #1)
**Primary root cause:** `Pack.Reader.find_compressed_length/2` recovers each
object's compressed byte-length by **binary-searching with ~25 redundant
re-decompressions per object read** (`probe_compressed_length` →
`try_decompress_prefix`). The read path then **throws that length away**
(`resolve_object_by_type` matches `{:ok, data, _}`). So every object read does
~26 decompressions instead of 1. Invisible on a single point read (~0.26 ms),
it dominates any multi-object operation.
**Proven by experiment:** skipping `find_compressed_length` makes the full walk
**14× faster** (300.8 → 21.8 µs/commit) and diff **17.6× faster** (2125 → 121 ms),
with no change to ahead/behind (which reads few objects).
**Secondary (topology ops):** no commit-graph. git walks topology with zero
object reads (20 ms); reading every commit object is 110 ms; ours, after the
primary fix, would be ~450 ms. A commit-graph is the next lever for
log/ahead-behind/merge-base.
## Evidence
### 1. The git advantage splits into commit-graph + object-read speed
(`git -c core.commitGraph=...`)
| | time (20,797 commits) |
|---|--:|
| git rev-list, commit-graph ON | 20 ms |
| git rev-list, commit-graph OFF (reads commit objects) | 110–130 ms |
| ours (`ExGitObjectstore.log`) | ~2720 ms |
So two gaps: per-object-read (~25× vs git's 110 ms) and commit-graph (~6× more).
### 2. The walk does 1 decompress/commit — but each takes 276 µs
(`priv/repro/time_decomp` — accumulate time inside `decompress_data`)
Warm 5000-commit walk: 1485 ms, **1.0 decompress/commit** (5035 calls), but
**93.6 % of total time is inside decompress** at **276 µs/decompress** — vs git's
~1–5 µs to inflate a ~1 KB commit. The count is ideal; the **per-decompress cost
is ~140× too high**.
### 3. A single read is efficient; the 26× is the length-probe
(`priv/repro/count_one` — clean counter at the zlib call sites, fresh process)
| object | decompressions (single read) |
|---|--:|
| HEAD (pack base, depth 0) | 1 |
| depth-1 delta | 2 |
`decompress_data` is called once per object (= chain_depth+1 for deltas), but
`zlib:open` fires ~26× per object (eprof) — the extra 25 are
`try_decompress_prefix` probes inside `find_compressed_length`, each
re-inflating the object to test a candidate length.
### 4. Causation, proven
(env-gated stub: `find_compressed_length` returns a cheap bogus length)
| op | baseline | length-probe skipped | speedup |
|---|--:|--:|--:|
| full walk (5000) | 300.8 µs/commit | 21.8 µs/commit | **14×** |
| diff (79 files) | 2125 ms | 121 ms | **17.6×** |
| ahead/behind | 26.6 ms | 29.5 ms | none (reads few objects) |
### 5. The recomputed length is discarded by readers
`resolve_object_by_type` (base) and `resolve_ofs_delta` both match
`{:ok, data, _}` / `{:ok, delta, _}` — the `compressed_len` from
`decompress_data` is unused in the random-access read path. Only the streaming
pack parser (`parse_stream`, used by receive-pack/clone-generation) needs object
boundaries, and it can get them from zlib's consumed-input count or the pack
index's sorted offsets — no re-decompression.
## Why each row in the table behaves as it does
- **full walk / diff / clone**: dominated by the length-probe (every object read
pays ~26× decompression). Fixing it ≈ removes the gap down to ~4× of git
(honest BEAM-vs-C + walk machinery).
- **ahead/behind, merge-base (FS-local)**: read few small commits → near parity
already; not decompress-bound.
- **ahead/behind, merge-base, everything (CephFS / S3)**: a *second, independent*
axis — per-object read **latency × object count**. The length-probe adds CPU
but no extra I/O (it re-inflates the in-memory pack slice), so on slow backends
the dominant cost is the number of object fetches, which the commit-graph
(topology without object reads) would slash.
## Fix backlog (ranked, per the perf protocol)
1. **[DONE — `30063c9`] Don't compute `find_compressed_length` in the read path**
(it's discarded). `decompress_only/1`. Shared primitive; walk 4.7×, merge-base
now beats git, diff 1.6×. Erlang `:zlib` exposes no consumed-byte count, so
the *sequential* parsers (parse_entry_body/scan_entry_by_type) still probe,
but now via a bounded exponential search instead of over the whole pack.
2. **[DONE — serve side — `fe8a0f5`] Pack reuse for full clone.** When a full
clone's reachable set equals a single pack, stream that pack VERBATIM (deltas
intact) instead of decode-all + `Writer.generate` re-encode. Verified against
real git (byte-identical clone, `git index-pack --strict` accepts it; safe
fallback for every other shape). On the 134k-object mirror the pack **serve
dropped to ~26 ms** (was a timeout) and the clone now completes with a compact
36 MB pack.
**NEW bottleneck exposed:** the reachability safety gate (prove reachable ==
pack) still decodes all commits+trees ≈ **27 s** (deep tree deltas, no cache).
→ **Reachability bitmaps** are the next lever: precompute reachable sets at
pack time so the gate is O(1); the bitmap also *is* the reuse-safety proof. A
delta-base cache (#3) would also speed the gate's tree decoding ~6×.
3. **Delta-base cache** — a *partial* mitigation, not the cold-clone fix. A
bounded LRU of decoded objects avoids re-decoding shared chains. Measured
ceiling (300-commit history closure, cold): 23,997 → ~3,777 decompressions
(~6.4× fewer), but (a) that's the zero-eviction ceiling — tree-DFS walk order
≠ pack order, so a bounded LRU captures less; (b) it still leaves
O(objects) decode + O(objects) encode, so pack reuse (#2) dominates for cold
clone. The cache is most valuable for **repeated point-reads/diffs of
deep-history objects across requests** (ideally a shared, cross-request cache),
where the same bases are hit cold over and over. Per-object chain decode is
correct (depth-40 blob = 41 decompresses); the waste is cross-read, not
within-read.
4. **Commit-graph** for topology-only ops (log / ahead-behind / merge-base) —
avoid reading commit objects entirely (git: 20 ms vs 110 ms reading objects).
Also slashes the per-object-read-latency cost on CephFS/S3.
5. After #1, the walk's remaining cost is the one-time whole-pack `Index.parse` +
`build_sha_cache` (~220 ms) — avoidable via the existing `lookup_in_raw`
binary search (already used by the ranged point-read path).
bench/run_all.sh +59 −0
@@ -1,0 +1,59 @@
#!/usr/bin/env bash
# Regenerate the read-path source-of-truth table (bench/RESULTS.md).
#
# Produces five columns via `mix exgo.bench`: native git, FS (local), FS on
# CephFS, S3/MinIO, S3/Ceph RGW. Each column is one JSON file; the render step
# merges them into a markdown table.
#
# Prereqs:
# - A packed git repo on local disk at $REPO_DIR (also an ExGitObjectstore
# Filesystem layout: $ROOT/repos/$ID).
# - MinIO + Ceph RGW reachable with the repo migrated into a bucket.
# - For the CephFS column: the cephfs-bench container (see anvil
# priv/repro/cephfs-bench/) with CephFS mounted at /cephfs and the repo
# copied to /cephfs/repos/$ID.
#
# Usage: ROOT=/path/to/repo_data ID=<uuid> OUT=/tmp/bench ./bench/run_all.sh
set -euo pipefail
ROOT="${ROOT:?set ROOT=<dir containing repos/<id>>}"
ID="${ID:?set ID=<repo uuid>}"
OUT="${OUT:-/tmp/exgo-bench}"
mkdir -p "$OUT"
RID="$ROOT/repos/$ID"
# Derive ONE fixture from the git repo so every column measures identical inputs.
HEAD=$(git -C "$RID" rev-parse HEAD)
OLD=$(git -C "$RID" rev-parse "HEAD~${DEPTH:-50}")
LS=$(git -C "$RID" ls-tree HEAD)
BLOBLINE=$(echo "$LS" | awk '$2=="blob"{print; exit}')
BLOB=$(echo "$BLOBLINE" | awk '{print $3}')
FILE=$(echo "$BLOBLINE" | cut -f2)
SUBDIR=$(echo "$LS" | awk '$2=="tree"{print; exit}' | cut -f2)
FX="--head $HEAD --old $OLD --file $FILE --blob $BLOB --subdir $SUBDIR --runs 3 --op-timeout 60000"
echo "fixture: HEAD=$HEAD OLD=$OLD FILE=$FILE SUBDIR=$SUBDIR"
mix exgo.bench --target git --git-dir "$RID" $FX --label "native git" --out "$OUT/git.json"
mix exgo.bench --target filesystem --root "$ROOT" --repo "$ID" $FX --label "FS (local)" --out "$OUT/fs.json"
if [ -n "${MINIO:-}" ]; then
mix exgo.bench --target s3 --bucket "${MINIO_BUCKET:-anvil-git-packed}" --repo "$ID" \
--s3-host "${MINIO_HOST:-localhost}" --s3-port "${MINIO_PORT:-9000}" \
--s3-key "${MINIO_KEY:-minioadmin}" --s3-secret "${MINIO_SECRET:-minioadmin}" \
$FX --label "S3 / MinIO" --out "$OUT/minio.json"
fi
if [ -n "${CEPH_RGW:-}" ]; then
mix exgo.bench --target s3 --bucket "${RGW_BUCKET:-anvil-git-packed}" --repo "$ID" \
--s3-host "${RGW_HOST:-localhost}" --s3-port "${RGW_PORT:-8080}" \
--s3-key "${RGW_KEY:-cephadmin}" --s3-secret "${RGW_SECRET:-cephadmin123}" \
$FX --label "S3 / Ceph RGW" --out "$OUT/ceph-rgw.json"
fi
# CephFS column runs inside the cephfs-bench container; emit the same flags:
echo
echo "# CephFS column (run inside the cephfs-bench container):"
echo "mix exgo.bench --target filesystem --root /cephfs --repo $ID $FX --label 'FS on CephFS' --out /tmp/cephfs.json"
echo
echo "# Then render:"
echo "mix exgo.bench --render $OUT/git.json $OUT/fs.json <cephfs.json> $OUT/minio.json $OUT/ceph-rgw.json"
lib/ex_git_objectstore/object_resolver.ex +150 −62
@@ -50,10 +50,25 @@
alias ExGitObjectstore.Pack.{Index, Reader}
@pack_data_key :exgo_pack_data_cache
@pack_index_key :exgo_pack_index_cache
@pack_sha_key :exgo_pack_sha_cache
@pack_list_key :exgo_pack_list_cache
@pack_raw_idx_key :exgo_pack_raw_idx_cache
@pack_reads_key :exgo_pack_reads
@cache_keys [
@pack_data_key,
@pack_list_key,
@pack_raw_idx_key,
@pack_reads_key
]
# After this many ranged single-object reads against one pack in a process,
# switch that pack to the whole-pack path (fetch the full pack once, then serve
# from the per-process cache). Keeps point reads cheap (ranged, O(1)
# round-trips) while bulk readers — clone/walk, which touch most of the pack —
# pay one whole-pack fetch instead of thousands of ranged GETs. Offsets are
# resolved with the same O(log n) raw-index binary search either way.
@escalate_threshold 64
@doc """
Read an object by SHA. Checks packs first (cheap after the per-process
pack-index cache warms up), falls back to loose objects on miss.
@@ -93,10 +108,9 @@
"""
@spec clear_pack_cache() :: :ok
def clear_pack_cache do
for {k, _v} <- Process.get(),
is_tuple(k) and tuple_size(k) >= 1 and elem(k, 0) in @cache_keys do
for key <- [@pack_data_key, @pack_index_key, @pack_sha_key, @pack_list_key] do
for {{^key, _} = full_key, _} <- Process.get() do
Process.delete(full_key)
end
Process.delete(k)
end
:ok
@@ -115,36 +129,146 @@
defp find_in_packs(_repo, _sha, []), do: {:error, :not_found}
defp find_in_packs(repo, sha, [pack_sha | rest]) do
case locate_in_pack(repo, sha, pack_sha) do
with {:ok, index} <- cached_pack_index(repo, pack_sha),
{:ok, offset} <- Index.lookup(index, sha) do
read_object_from_pack(repo, pack_sha, index, offset)
{:ok, _} = ok -> ok
else
:not_found -> find_in_packs(repo, sha, rest)
{:error, :not_found} -> find_in_packs(repo, sha, rest)
{:error, _} = err -> err
end
end
# Point reads take the ranged path (no whole-pack download); a pack escalates
# to the whole-pack path once it has served @escalate_threshold reads in this
defp read_object_from_pack(repo, pack_sha, index, offset) do
case cached_pack_data(repo, pack_sha) do
{:ok, pack_data} ->
sha_cache = cached_sha_cache(repo, pack_sha, index)
# process, or immediately if the backend can't do ranged reads.
defp locate_in_pack(repo, sha, pack_sha) do
if use_whole_pack?(repo, pack_sha) do
whole_pack_read(repo, sha, pack_sha)
else
ranged_pack_read(repo, sha, pack_sha)
end
end
case Reader.read_object(pack_data, offset, sha_cache) do
{:ok, {type, data}} ->
if byte_size(data) > repo.max_object_size do
{:error,
{:object_too_large,
"decompressed size #{byte_size(data)} exceeds limit of #{repo.max_object_size} bytes"}}
else
defp use_whole_pack?(repo, pack_sha) do
not ranged_supported?(repo) or whole_pack_loaded?(repo, pack_sha) or
pack_reads(repo, pack_sha) >= @escalate_threshold
end
defp ranged_supported?(%Repo{storage: {mod, _cfg}}) do
function_exported?(mod, :get_pack_range, 5)
end
# ── Ranged (point-read) path ────────────────────────────────────────────────
defp ranged_pack_read(repo, sha, pack_sha) do
with {:ok, raw_idx} <- cached_raw_index(repo, pack_sha),
{:ok, offset} <- raw_lookup(raw_idx, sha) do
bump_pack_reads(repo, pack_sha)
fetch = pack_fetch_fn(repo, pack_sha)
case Reader.read_object_ranged(fetch, offset, ranged_resolver(raw_idx, fetch)) do
{:ok, {type, data}} -> size_checked_wrap(repo, type, data)
{:error, _} = err -> err
end
end
end
defp raw_lookup(raw_idx, sha) do
case Index.lookup_in_raw(raw_idx, sha) do
{:ok, _} = ok -> ok
:not_found -> :not_found
{:error, _} = err -> err
end
end
defp pack_fetch_fn(repo, pack_sha) do
fn offset, length -> Repo.storage_call(repo, :get_pack_range, [pack_sha, offset, length]) end
wrap_object(type, data)
end
end
{:error, _} = err ->
# REF_DELTA bases in a self-contained (gc'd) pack live in the same pack: look
# the base up in the raw index and read it ranged. Recurses so a base that is
# itself a delta resolves.
defp ranged_resolver(raw_idx, fetch) do
fn base_sha -> resolve_ref_base(raw_idx, fetch, base_sha) end
end
defp resolve_ref_base(raw_idx, fetch, base_sha) do
case Index.lookup_in_raw(raw_idx, base_sha) do
{:ok, off} -> Reader.read_object_ranged(fetch, off, ranged_resolver(raw_idx, fetch))
other -> {:error, {:ref_delta_base_not_found, base_sha, other}}
end
end
# ── Whole-pack (bulk) path ──────────────────────────────────────────────────
# Load the pack once (amortizes per-object HTTP for bulk readers on S3), but
# resolve the offset with the same O(log n) raw-index binary search as the
# ranged path and read the object straight from the in-memory pack.
#
# This used to parse the full index into a SHA→offset map AND rebuild it into a
# second map — ~183 ms of O(n) work over 134k objects, paid in full the moment
# any op (e.g. `diff`, ~150 reads) crossed @escalate_threshold, even though
# `lookup_in_raw` resolves an offset in ~0.001 ms. That single escalation read
# was 90%+ of a `diff`'s wall time.
defp whole_pack_read(repo, sha, pack_sha) do
with {:ok, raw_idx} <- cached_raw_index(repo, pack_sha),
{:ok, offset} <- raw_lookup(raw_idx, sha),
{:ok, pack_data} <- cached_pack_data(repo, pack_sha) do
fetch = in_memory_fetch(pack_data)
case Reader.read_object_ranged(fetch, offset, ranged_resolver(raw_idx, fetch)) do
{:ok, {type, data}} -> size_checked_wrap(repo, type, data)
{:error, _} = err -> err
end
end
end
# A ranged-fetch fn backed by the in-memory pack. Clamps the window to EOF;
# read_object_ranged only over-reads, and everything to EOF always contains the
# full object, so this never false-trips its truncation path.
defp in_memory_fetch(pack_data) do
size = byte_size(pack_data)
fn offset, length -> {:ok, binary_part(pack_data, offset, min(length, size - offset))} end
end
defp size_checked_wrap(repo, type, data) do
if byte_size(data) > repo.max_object_size do
{:error,
{:object_too_large,
"decompressed size #{byte_size(data)} exceeds limit of #{repo.max_object_size} bytes"}}
else
wrap_object(type, data)
end
end
# ── Per-pack read counter + raw-index cache ─────────────────────────────────
defp whole_pack_loaded?(%Repo{id: id}, pack_sha) do
Process.get({@pack_data_key, id, pack_sha}) != nil
end
defp pack_reads(%Repo{id: id}, pack_sha) do
Process.get({@pack_reads_key, id, pack_sha}, 0)
end
defp bump_pack_reads(%Repo{id: id} = repo, pack_sha) do
Process.put({@pack_reads_key, id, pack_sha}, pack_reads(repo, pack_sha) + 1)
end
defp cached_raw_index(%Repo{id: id} = repo, pack_sha) do
key = {@pack_raw_idx_key, id, pack_sha}
case Process.get(key) do
nil ->
case Repo.storage_call(repo, :get_pack_index, [pack_sha]) do
{:ok, _} = ok ->
Process.put(key, ok)
ok
err ->
err
end
cached ->
{:error, _} = err ->
err
cached
end
end
@@ -190,42 +314,6 @@
cached ->
cached
end
end
defp cached_pack_index(%Repo{id: repo_id} = repo, pack_sha) do
key = {@pack_index_key, repo_id, pack_sha}
case Process.get(key) do
nil ->
with {:ok, idx_data} <- Repo.storage_call(repo, :get_pack_index, [pack_sha]),
{:ok, _index} = ok <- Index.parse(idx_data) do
Process.put(key, ok)
ok
end
cached ->
cached
end
end
defp cached_sha_cache(%Repo{id: repo_id}, pack_sha, index) do
key = {@pack_sha_key, repo_id, pack_sha}
case Process.get(key) do
nil ->
sha_cache = build_sha_cache(index)
Process.put(key, sha_cache)
sha_cache
cached ->
cached
end
end
# Convert the parsed index's SHA→offset map into the cache format
# expected by Reader.read_object's find_sha_offset (%{{:sha, hex_sha} => offset}).
defp build_sha_cache(%Index{offsets: offsets}) do
Map.new(offsets, fn {sha, offset} -> {{:sha, sha}, offset} end)
end
defp wrap_object(:blob, data), do: {:ok, Blob.from_content(data)}
lib/ex_git_objectstore/pack/index.ex +79 −0
@@ -121,6 +121,85 @@
Map.has_key?(offsets, sha)
end
@doc """
Look up a single SHA's pack offset directly from the raw `.idx` bytes via
binary search over the fanout-bounded SHA table — **without** parsing the
whole index into maps.
`parse/1` is O(n): it hex-encodes every SHA and builds an n-entry offset map
(≈800 ms for a 134k-object index). For a point read that touches one object,
that is wasted work. This is O(log n) and allocation-light, so it is the right
path for single-object reads (e.g. viewing a file/commit). Use `parse/1` for
bulk reads (clone/walk) that touch most of the pack.
Returns the same offsets as `lookup/2` would.
"""
@spec lookup_in_raw(binary(), String.t()) ::
{:ok, non_neg_integer()} | :not_found | {:error, term()}
def lookup_in_raw(<<@idx_magic, @idx_version, _::binary>> = idx, sha_hex)
when byte_size(idx) >= 1032 do
case decode_sha(sha_hex) do
{:ok, sha_bin} -> do_lookup_in_raw(idx, sha_bin)
:error -> {:error, :invalid_sha}
end
end
def lookup_in_raw(_idx, _sha), do: {:error, :invalid_idx_header}
# Byte offset of the SHA table: 8 (magic+version) + 1024 (fanout).
@sha_table_start 1032
defp do_lookup_in_raw(idx, <<first, _::binary>> = sha_bin) do
count = u32(idx, 1028)
lo = if first == 0, do: 0, else: u32(idx, 8 + (first - 1) * 4)
hi = u32(idx, 8 + first * 4)
case bsearch(idx, sha_bin, lo, hi) do
nil -> :not_found
i -> {:ok, offset_at(idx, count, i)}
end
end
defp bsearch(_idx, _sha, lo, hi) when lo >= hi, do: nil
defp bsearch(idx, sha, lo, hi) do
mid = div(lo + hi, 2)
case sha_at(idx, mid) do
^sha -> mid
s when s < sha -> bsearch(idx, sha, mid + 1, hi)
_ -> bsearch(idx, sha, lo, mid)
end
end
defp sha_at(idx, i), do: :binary.part(idx, @sha_table_start + i * 20, 20)
defp offset_at(idx, count, i) do
# offsets table follows N*20 SHAs and N*4 CRCs.
off_table = @sha_table_start + count * 24
off4 = u32(idx, off_table + i * 4)
if Bitwise.band(off4, 0x80000000) == 0 do
off4
else
large_idx = Bitwise.band(off4, 0x7FFFFFFF)
large_table = off_table + count * 4
u64(idx, large_table + large_idx * 8)
end
end
defp decode_sha(hex) when is_binary(hex) and byte_size(hex) == 40 do
case Base.decode16(hex, case: :mixed) do
{:ok, <<_::binary-size(20)>> = bin} -> {:ok, bin}
_ -> :error
end
end
defp decode_sha(_hex), do: :error
defp u32(bin, pos), do: :binary.decode_unsigned(:binary.part(bin, pos, 4), :big)
defp u64(bin, pos), do: :binary.decode_unsigned(:binary.part(bin, pos, 8), :big)
# -- Parsing helpers --
defp parse_fanout(data) when byte_size(data) >= 1024 do
lib/ex_git_objectstore/pack/reader.ex +167 −5
@@ -455,6 +455,127 @@
do_read_object(pack_data, offset, cache, 0, external_resolver)
end
# -- Ranged reads (no whole-pack fetch) --
@ranged_window 256 * 1024
@doc """
Read a single object from a pack using ranged fetches instead of the whole
pack binary.
`fetch_fn.(offset, length)` returns up to `length` bytes of the pack starting
at `offset` (fewer at EOF). `resolver.(base_sha)` resolves a REF_DELTA base by
SHA to `{:ok, {type, data}}` (used for thin packs / cross-object bases);
OFS_DELTA bases are followed within the pack via additional ranged fetches.
This avoids downloading the entire pack to read one object — the point-read
path for S3-backed storage. Correctness rests on the object header's declared
uncompressed size: a short window yields a size mismatch, which triggers a
larger refetch (or `:truncated_pack` once EOF is reached).
"""
@spec read_object_ranged(
(non_neg_integer(), pos_integer() -> {:ok, binary()} | {:error, term()}),
non_neg_integer(),
(String.t() -> {:ok, {atom(), binary()}} | {:error, term()})
) :: {:ok, {atom(), binary()}} | {:error, term()}
def read_object_ranged(fetch_fn, offset, resolver)
when is_function(fetch_fn, 2) and is_function(resolver, 1) do
ranged_read(fetch_fn, offset, resolver, 0, @ranged_window)
end
defp ranged_read(_fetch, _offset, _resolver, depth, _window) when depth > @max_delta_depth do
{:error, :max_delta_depth_exceeded}
end
defp ranged_read(fetch, offset, resolver, depth, window) do
case fetch.(offset, window) do
{:ok, win} -> ranged_read_window(win, fetch, offset, resolver, depth, window)
{:error, _} = err -> err
end
end
defp ranged_read_window(win, fetch, offset, resolver, depth, window) do
case ranged_decode(win, fetch, offset, resolver, depth) do
{:error, :truncated_window} -> grow_or_fail(win, window, fetch, offset, resolver, depth)
other -> other
end
end
# The object didn't fit in `window`. Grow and retry — unless we already read
# past EOF (short read), in which case the pack is genuinely truncated.
defp grow_or_fail(win, window, fetch, offset, resolver, depth) do
if byte_size(win) < window do
{:error, :truncated_pack}
else
ranged_read(fetch, offset, resolver, depth, window * 4)
end
end
defp ranged_decode(win, fetch, offset, resolver, depth) do
case parse_object_header(win) do
{:ok, type_num, size, header_len, _rest} ->
ranged_by_type(type_num, size, header_len, win, fetch, offset, resolver, depth)
{:error, _} = err ->
err
end
end
defp ranged_by_type(t, size, header_len, win, _fetch, _offset, _resolver, _depth)
when t in @base_types do
<<_::binary-size(^header_len), compressed::binary>> = win
case decompress_exact(compressed, size) do
{:ok, data} -> {:ok, {type_num_to_atom(t), data}}
other -> other
end
end
defp ranged_by_type(@obj_ofs_delta, size, header_len, win, fetch, offset, resolver, depth) do
<<_::binary-size(^header_len), rest::binary>> = win
with {:ok, neg_offset, ofs_len, _} <- parse_ofs_delta_offset(rest),
<<_::binary-size(^ofs_len), compressed::binary>> = rest,
{:ok, delta} <- decompress_exact(compressed, size),
base_offset = offset - neg_offset,
true <- base_offset >= 0 || {:error, :invalid_ofs_delta_offset},
{:ok, {base_type, base_data}} <-
ranged_read(fetch, base_offset, resolver, depth + 1, @ranged_window),
{:ok, result} <- Delta.apply(base_data, delta) do
{:ok, {base_type, result}}
end
end
defp ranged_by_type(@obj_ref_delta, size, header_len, win, _fetch, _offset, resolver, _depth) do
case win do
<<_::binary-size(^header_len), base_sha_bin::binary-size(20), compressed::binary>> ->
with {:ok, delta} <- decompress_exact(compressed, size),
base_sha = Base.encode16(base_sha_bin, case: :lower),
{:ok, {base_type, base_data}} <- resolver.(base_sha),
{:ok, result} <- Delta.apply(base_data, delta) do
{:ok, {base_type, result}}
end
_ ->
{:error, :truncated_window}
end
end
defp ranged_by_type(type_num, _size, _hl, _win, _fetch, _offset, _resolver, _depth) do
{:error, {:unknown_object_type, type_num}}
end
# Decompress and require the result to match the header's declared uncompressed
# size. A short/partial result means the fetch window didn't cover the whole
# compressed object → signal a refetch.
defp decompress_exact(compressed, expected_size) do
case decompress_only(compressed) do
{:ok, data} when byte_size(data) == expected_size -> {:ok, data}
{:ok, _partial} -> {:error, :truncated_window}
{:error, _} -> {:error, :truncated_window}
end
end
# -- Single-entry parsing --
defp parse_entry_at(data) do
@@ -544,8 +665,8 @@
data_start = offset + header_len
<<_::binary-size(^data_start), compressed::binary>> = pack_data
case decompress_only(compressed) do
case decompress_data(compressed) do
{:ok, decompressed, _} -> {:ok, {type, decompressed}}
{:ok, decompressed} -> {:ok, {type, decompressed}}
{:error, _} = err -> err
end
end
@@ -584,7 +705,7 @@
end
defp apply_delta_from_pack(compressed, pack_data, base_offset, cache, depth, ext) do
with {:ok, delta_data} <- decompress_only(compressed),
with {:ok, delta_data, _} <- decompress_data(compressed),
{:ok, {base_type, base_data}} <-
do_read_object(pack_data, base_offset, cache, depth + 1, ext),
{:ok, result} <- Delta.apply(base_data, delta_data) do
@@ -593,7 +714,7 @@
end
defp resolve_ref_delta(compressed, base_sha, pack_data, cache, depth, ext) do
with {:ok, delta_data, _} <- decompress_data(compressed) do
with {:ok, delta_data} <- decompress_only(compressed) do
resolve_ref_delta_base(delta_data, base_sha, pack_data, cache, depth, ext)
end
end
@@ -727,6 +848,32 @@
end
end
# Decompress without recovering the compressed byte length. Used by the
# random-access read/resolve paths (resolve_object_by_type, ofs/ref delta,
# ranged decompress_exact), which read a single object at a known offset and
# never advance to a next object — they discard the length entirely. Avoids
# find_compressed_length, whose probing dominated walk/diff/clone time.
defp decompress_only(compressed) do
z = :zlib.open()
try do
:zlib.inflateInit(z)
case safe_inflate_all(z, compressed, @max_decompressed_size, 0, []) do
{:ok, decompressed_bin} ->
:zlib.inflateEnd(z)
{:ok, decompressed_bin}
{:error, _} = err ->
err
end
rescue
e -> {:error, {:decompress_failed, Exception.message(e)}}
after
:zlib.close(z)
end
end
defp safe_inflate_all(z, data, max_size, total, acc) do
case :zlib.safeInflate(z, data) do
{:continue, chunk} ->
@@ -751,9 +898,24 @@
end
end
# Recover an object's compressed byte length. Erlang's :zlib exposes no
# consumed-input count, so we probe by re-inflating prefixes — but bound the
# work to ~the object's own compressed size via an exponential search for the
# window, then a binary search within it. The previous version searched
# [1, byte_size(rest-of-pack)], i.e. ~25 full-object re-inflations per call.
# Only the sequential pack parsers (parse_entry_body, scan_entry_by_type) need
# this; the random-access read paths use decompress_only/1 and never call it.
defp find_compressed_length(compressed, expected_size) do
total = byte_size(compressed)
probe_compressed_length(compressed, expected_size, 1, total)
find_length_window(compressed, expected_size, min(64, total), 0, total)
end
defp find_length_window(compressed, expected_size, hi, lo, total) do
case try_decompress_prefix(compressed, hi) do
{:ok, ^expected_size} -> probe_compressed_length(compressed, expected_size, lo + 1, hi)
_ when hi >= total -> total
_ -> find_length_window(compressed, expected_size, min(hi * 2, total), hi, total)
end
end
defp probe_compressed_length(compressed, expected_size, low, high) when low < high do
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +121 −1
@@ -53,7 +53,7 @@
alias ExGitObjectstore.{ObjectResolver, Ref, Repo}
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Pack.{Filter, Writer}
alias ExGitObjectstore.Pack.{Filter, Index, Writer}
alias ExGitObjectstore.Protocol.{PktLine, SidebandWriter}
@max_tree_depth 64
@@ -610,6 +610,100 @@
filter_spec,
write_fn
) do
case maybe_reuse_pack(repo, wants, haves, shallow_opts, filter_spec) do
{:reuse, pack_sha, count} ->
stream_reused_pack(repo, pack_sha, count, ack_section, write_fn)
:no ->
stream_generated_pack(
repo,
wants,
haves,
ack_section,
shallow_opts,
filter_spec,
write_fn
)
end
end
# Pack reuse: when the request is a full clone (no haves/shallow/filter) of a
# repo whose single packfile contains *exactly* the reachable set, stream that
# pack's bytes verbatim — it's already a valid, delta-compressed packfile.
# Avoids decoding every object (incl. deep-delta blobs) and re-encoding them
# undeltified via Writer.generate. Falls back to generation whenever the
# conditions don't hold, so correctness never depends on the fast path.
#
# Safety: reuse ONLY when the computed reachable set equals the pack's object
# set. That guarantees completeness (every wanted object is in the pack) and no
# leak (no unreachable object is sent). OFS/REF deltas stay valid because the
# bytes — and thus in-pack offsets and self-contained bases — are preserved.
defp maybe_reuse_pack(_repo, [], _haves, _shallow, _filter), do: :no
defp maybe_reuse_pack(repo, wants, haves, shallow_opts, filter_spec) do
if haves == [] and shallow_opts == nil and filter_spec == nil do
attempt_pack_reuse(repo, wants)
else
:no
end
end
defp attempt_pack_reuse(repo, wants) do
with {:ok, pack_sha} <- single_pack(repo),
{:ok, pack_shas} <- pack_sha_set(repo, pack_sha),
{:ok, reachable} <- reachable_sha_set(repo, wants),
true <- MapSet.equal?(reachable, pack_shas) do
{:reuse, pack_sha, MapSet.size(pack_shas)}
else
_ -> :no
end
end
defp single_pack(repo) do
case Repo.storage_call(repo, :list_packs, []) do
{:ok, [pack_sha]} -> {:ok, pack_sha}
_ -> :error
end
end
defp pack_sha_set(repo, pack_sha) do
with {:ok, idx} <- Repo.storage_call(repo, :get_pack_index, [pack_sha]),
{:ok, index} <- Index.parse(idx) do
{:ok, MapSet.new(index.shas)}
end
end
# Full reachable SHA set (commits, trees, tags, AND blobs) via the same walk
# that serves normal clones. `skip_blobs: true` classifies blobs by tree-entry
# mode and never reads them, but still records their SHAs in `visited` — cheap
# (no blob decode) yet complete.
defp reachable_sha_set(repo, wants) do
visited =
Enum.reduce(wants, MapSet.new(), fn sha, vis ->
{_objs, vis} = collect_reachable(repo, sha, MapSet.new(), vis, skip_blobs: true)
vis
end)
{:ok, visited}
rescue
e -> {:error, Exception.message(e)}
end
defp stream_reused_pack(repo, pack_sha, count, ack_section, write_fn) do
{:ok, pack_data} = Repo.storage_call(repo, :get_pack, [pack_sha])
write_fn.(IO.iodata_to_binary([ack_section, PktLine.encode("packfile")]))
sideband = SidebandWriter.new(1, write_fn)
sideband = SidebandWriter.write(sideband, pack_data)
:ok = SidebandWriter.finish(sideband)
write_fn.(PktLine.flush())
Logger.info("UploadPackV2: reused pack #{byte_size(pack_data)} bytes, #{count} objects")
%{pack_bytes: byte_size(pack_data), objects: count, reused: true}
end
defp stream_generated_pack(repo, wants, haves, ack_section, shallow_opts, filter_spec, write_fn) do
case collect_objects_maybe_shallow(repo, wants, haves, shallow_opts, filter_spec) do
{:ok, %{objects: objects} = walk} ->
# Anvil #215 / REQ-GIT-080: project the walker's
@@ -690,6 +784,32 @@
end
defp build_packfile_response(repo, wants, haves, ack_section, shallow_opts, filter_spec) do
case maybe_reuse_pack(repo, wants, haves, shallow_opts, filter_spec) do
{:reuse, pack_sha, count} ->
build_reused_response(repo, pack_sha, count, ack_section)
:no ->
build_generated_response(repo, wants, haves, ack_section, shallow_opts, filter_spec)
end
end
defp build_reused_response(repo, pack_sha, count, ack_section) do
{:ok, pack_data} = Repo.storage_call(repo, :get_pack, [pack_sha])
sideband_data = IO.iodata_to_binary(PktLine.encode_sideband(1, pack_data))
response =
IO.iodata_to_binary([
ack_section,
PktLine.encode("packfile"),
sideband_data,
PktLine.flush()
])
Logger.info("UploadPackV2: reused pack #{byte_size(pack_data)} bytes, #{count} objects")
{response, %{pack_bytes: byte_size(pack_data), objects: count, reused: true}}
end
defp build_generated_response(repo, wants, haves, ack_section, shallow_opts, filter_spec) 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, generating pack")
lib/ex_git_objectstore/storage.ex +16 −0
@@ -38,6 +38,22 @@
@callback get_pack(config, prefix, pack_sha :: String.t()) :: {:ok, binary()} | {:error, term()}
@callback get_pack_index(config, prefix, pack_sha :: String.t()) ::
{:ok, binary()} | {:error, term()}
@doc """
Read a byte range `[offset, offset+length)` of a packfile. Returns up to
`length` bytes (fewer at end-of-file). Optional capability: backends that
implement it let `ObjectResolver` read a single object without fetching the
whole pack (e.g. an S3 HTTP Range GET instead of downloading the entire pack).
"""
@callback get_pack_range(
config,
prefix,
pack_sha :: String.t(),
offset :: non_neg_integer(),
length :: pos_integer()
) :: {:ok, binary()} | {:error, term()}
@optional_callbacks get_pack_range: 5
@callback put_pack(
config,
prefix,
lib/ex_git_objectstore/storage/filesystem.ex +23 −0
@@ -139,5 +139,28 @@
end
@impl true
def get_pack_range(config, prefix, pack_sha, offset, length) do
path = pack_path(config, prefix, pack_sha, "pack")
case :file.open(path, [:read, :binary, :raw]) do
{:ok, fd} ->
result = :file.pread(fd, offset, length)
:file.close(fd)
case result do
{:ok, data} -> {:ok, data}
:eof -> {:ok, <<>>}
{:error, reason} -> {:error, reason}
end
{:error, :enoent} ->
{:error, :not_found}
{:error, reason} ->
{:error, reason}
end
end
@impl true
def get_pack_index(config, prefix, pack_sha) do
path = pack_path(config, prefix, pack_sha, "idx")
lib/ex_git_objectstore/storage/memory.ex +14 −0
@@ -106,6 +106,20 @@
end
@impl true
def get_pack_range(%{pid: pid}, prefix, pack_sha, offset, length) do
case Agent.get(pid, &get_in(&1, [:packs, pack_key(prefix, pack_sha, "pack")])) do
nil ->
{:error, :not_found}
data when offset >= byte_size(data) ->
{:ok, <<>>}
data ->
{:ok, binary_part(data, offset, min(length, byte_size(data) - offset))}
end
end
@impl true
def get_pack_index(%{pid: pid}, prefix, pack_sha) do
case Agent.get(pid, &get_in(&1, [:packs, pack_key(prefix, pack_sha, "idx")])) do
nil -> {:error, :not_found}
lib/ex_git_objectstore/storage/s3.ex +34 −0
@@ -16,6 +16,25 @@
@moduledoc """
S3-compatible storage backend (AWS S3, MinIO, etc.).
## ⚠ Ceph RGW: require `tcp_nodelay=1` on the beast frontend
RGW's beast frontend does NOT set `TCP_NODELAY` by default. It writes each S3
response as two small segments (headers, then body); with Nagle enabled it
holds the body until the header segment is ACKed, while the client kernel
delayed-ACKs for ~40 ms (Linux `TCP_DELACK_MIN`). The result is a **~40 ms
Nagle/delayed-ACK stall on every small object GET** — verified via packet
trace and matching Ceph PR #27008 / RH BZ 1834303 ("RGW Beast missing
tcp_nodelay"). git's read pattern (many small serial object reads) is
pathologically hit: point reads jump from ~1-3 ms to ~40-170 ms.
When pointing this backend at Ceph RGW, the RGW deployment MUST set:
rgw_frontends = "beast port=7480 tcp_nodelay=1"
With that set, RGW performs on par with MinIO. MinIO (Go) sets `TCP_NODELAY`
by default and is unaffected. See `anvil/bench/local_storage_matrix/` for the
reproducing harness.
## CAS Limitation
S3 does not support atomic compare-and-swap (CAS) operations. The `put_ref/5`
@@ -139,6 +158,21 @@
def get_pack(config, prefix, pack_sha) do
key = pack_key(prefix, pack_sha, "pack")
s3_get(config, key)
end
@impl true
def get_pack_range(config, prefix, pack_sha, offset, length) do
key = pack_key(prefix, pack_sha, "pack")
last = offset + length - 1
op = ExAws.S3.get_object(config.bucket, key, range: "bytes=#{offset}-#{last}")
case ExAws.request(op, ex_aws_config(config)) do
{:ok, %{body: body}} -> {:ok, body}
{:error, {:http_error, 404, _}} -> {:error, :not_found}
# 416 Range Not Satisfiable: offset is at/past EOF — nothing to read.
{:error, {:http_error, 416, _}} -> {:ok, <<>>}
{:error, reason} -> {:error, reason}
end
end
@impl true
lib/mix/tasks/exgo.bench.ex +468 −0
@@ -1,0 +1,468 @@
defmodule Mix.Tasks.Exgo.Bench do
@shortdoc "Storage/read-path benchmark across backends + native git (perf source-of-truth)"
@moduledoc """
Times common git read operations against a backend (or native `git`) and emits
one table column. The intent is a stable source-of-truth for the read-path perf
effort: same operations, same fixture, comparable numbers across
* Filesystem (local disk)
* Filesystem on a CephFS mount
* S3 (MinIO)
* S3 (Ceph RGW)
* native `git` (the reference — what's achievable)
Each operation is timed `--runs` times (median reported). For the
ExGitObjectstore backends the per-process pack cache is cleared before every
run, so each measurement is cold. Native `git` runs a fresh process per run
(warm OS page cache — noted; the gaps we care about dwarf cache effects).
## Produce a column
# Filesystem / CephFS (root contains repos/<id>):
mix exgo.bench --target filesystem --root /data --repo <uuid> \\
--label "FS (local)" --out fs.json
# S3 (MinIO or Ceph RGW):
mix exgo.bench --target s3 --bucket anvil-git-packed --repo <uuid> \\
--s3-host localhost --s3-port 9000 --s3-key minioadmin --s3-secret minioadmin \\
--label "S3 / MinIO" --out minio.json
# Native git (reference column); --git-dir is a real git repo:
mix exgo.bench --target git --git-dir /data/repos/<uuid> --label "native git" --out git.json
Pass an explicit fixture so every column measures identical inputs:
--head <sha> --old <sha> --file <path> --blob <sha> --subdir <path>
(omit to auto-derive from the repo; the orchestrator script derives once and
passes them to all columns).
## Render the table
mix exgo.bench --render fs.json cephfs.json minio.json ceph.json git.json
"""
use Mix.Task
alias ExGitObjectstore.{Diff, ObjectResolver, Repo}
@switches [
target: :string,
root: :string,
repo: :string,
git_dir: :string,
bucket: :string,
s3_host: :string,
s3_port: :integer,
s3_key: :string,
s3_secret: :string,
s3_scheme: :string,
label: :string,
out: :string,
runs: :integer,
op_timeout: :integer,
depth: :integer,
head: :string,
old: :string,
file: :string,
blob: :string,
subdir: :string,
render: :boolean
]
# Canonical operation order (rows). Each: id, label, :elixir runner, :git args.
# Runners get the fixture map; they should return a term or raise. Defined as a
# function (not a module attribute) so the closures can call module privates.
defp ops do
[
%{
id: :resolve_head,
label: "resolve HEAD",
elixir: fn r, _f -> ok!(ExGitObjectstore.resolve(r, "HEAD")) end,
git: fn _f -> ["rev-parse", "HEAD"] end
},
%{
id: :read_commit,
label: "read 1 commit",
elixir: fn r, f -> ok!(ObjectResolver.read(r, f.head)) end,
git: fn f -> ["cat-file", "-p", f.head] end
},
%{
id: :read_blob,
label: "read 1 blob",
elixir: fn r, f -> ok!(ExGitObjectstore.blob(r, f.blob)) end,
git: fn f -> ["cat-file", "-p", f.blob] end
},
%{
id: :open_file,
label: "open file by path",
elixir: fn r, f -> open_file(r, f) end,
git: fn f -> ["show", "#{f.head}:#{f.file}"] end
},
%{
id: :list_root,
label: "list root tree",
elixir: fn r, f -> ok!(ExGitObjectstore.tree(r, f.head, "/")) end,
git: fn f -> ["ls-tree", f.head] end
},
%{
id: :tree_sizes,
label: "tree + blob sizes",
elixir: fn r, f -> tree_with_sizes(r, f) end,
git: fn f -> ["ls-tree", "-l", f.head] end
},
%{
id: :log_50,
label: "commit log page (50)",
elixir: fn r, f -> ok!(ExGitObjectstore.log_page(r, f.head, limit: 50)) end,
git: fn f -> ["log", "-n", "50", "--format=%H%n%an%n%s", f.head] end
},
%{
id: :log_full,
label: "full history walk",
elixir: fn r, f -> ok!(ExGitObjectstore.log(r, f.head, [])) end,
git: fn f -> ["rev-list", "--count", f.head] end
},
%{
id: :diff,
label: "diff two commits",
elixir: fn r, f -> ok!(Diff.diff_commits(r, f.old, f.head)) end,
git: fn f -> ["diff", f.old, f.head] end
},
%{
id: :ahead_behind,
label: "ahead/behind",
elixir: fn r, f -> ok!(ExGitObjectstore.ahead_behind(r, f.old, f.head)) end,
git: fn f -> ["rev-list", "--left-right", "--count", "#{f.old}...#{f.head}"] end
},
%{
id: :merge_base,
label: "merge-base",
elixir: fn r, f -> ok!(ExGitObjectstore.merge_base(r, f.old, f.head)) end,
git: fn f -> ["merge-base", f.old, f.head] end
},
%{
id: :clone,
label: "clone (read all reachable)",
elixir: fn r, f -> clone_walk(r, f.head) end,
git: fn f -> [:pipe_pack, f.head] end
}
]
end
@impl Mix.Task
def run(argv) do
{opts, files, _} = OptionParser.parse(argv, switches: @switches)
if opts[:render] do
render(files)
else
bench_column(opts)
end
end
# ── Column run ──────────────────────────────────────────────────────────────
defp bench_column(opts) do
target = opts[:target] || Mix.raise("--target filesystem|s3|git required")
runs = opts[:runs] || 3
op_timeout = opts[:op_timeout] || 60_000
ctx = build_ctx(target, opts)
Mix.shell().info("== #{opts[:label] || target} (#{target}) ==")
results = Enum.map(ops(), &run_op(&1, target, ctx, runs, op_timeout))
write_json(opts, target, runs, results)
end
defp run_op(op, target, ctx, runs, op_timeout) do
# clone reads the whole repo and is slow on our walk — one run is enough.
op_runs = if op.id == :clone, do: 1, else: runs
{ms, timed_out} =
case measure(target, op, ctx, ctx.fixture, op_runs, op_timeout) do
{:timeout, t} -> {Float.round(t / 1000, 1), true}
ms -> {ms, false}
end
prefix = if timed_out, do: ">", else: ""
Mix.shell().info(" #{String.pad_trailing(op.label, 28)} #{prefix}#{fmt(ms)}")
%{"id" => Atom.to_string(op.id), "label" => op.label, "ms" => ms, "timeout" => timed_out}
end
defp write_json(opts, target, runs, results) do
payload = %{
"label" => opts[:label] || target,
"target" => target,
"repo" => opts[:repo] || opts[:git_dir],
"runs" => runs,
"results" => results
}
case opts[:out] do
nil ->
:ok
path ->
File.write!(path, Jason.encode!(payload, pretty: true))
Mix.shell().info("wrote #{path}")
end
end
defp measure(target, op, ctx, fixture, runs, timeout) do
attempts = for _ <- 1..runs, do: one_attempt(target, op, ctx, fixture, timeout)
if Enum.any?(attempts, &(&1 == :timeout)) do
{:timeout, timeout * 1000}
else
median_ms(attempts)
end
end
defp one_attempt("git", op, ctx, fixture, timeout) do
with_timeout(fn -> run_git(ctx.git_dir, op.git.(fixture)) end, timeout)
end
# Each attempt runs in a fresh Task (a fresh process → cold per-process pack
# cache, naturally), bounded by `timeout` ms so a pathologically slow op (e.g.
# clone on a huge repo via our walk) records a sentinel instead of hanging.
defp one_attempt(_backend, op, ctx, fixture, timeout) do
with_timeout(
fn ->
try do
op.elixir.(ctx.repo, fixture)
rescue
e -> {:bench_error, Exception.message(e)}
end
end,
timeout
)
end
defp with_timeout(fun, timeout) do
task = Task.async(fn -> time_us(fun) end)
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, us} -> us
_ -> :timeout
end
end
# ── Operation helpers ────────────────────────────────────────────────────────
defp ok!({:ok, v}), do: v
# log_page/3 returns {:ok, commits, cursor}.
defp ok!({:ok, v, _cursor}), do: v
defp ok!(other), do: raise("unexpected: #{inspect(other)}")
defp open_file(repo, f) do
{:ok, %{tree: tree_sha}} = ObjectResolver.read(repo, f.head)
{:ok, %{entries: entries}} = ObjectResolver.read(repo, tree_sha)
entry = Enum.find(entries, &(&1.name == f.file)) || hd(entries)
ok!(ExGitObjectstore.blob(repo, entry.sha))
end
defp tree_with_sizes(repo, f) do
{:ok, tree} = ExGitObjectstore.tree(repo, f.head, "/")
blob_shas = for e <- tree.entries, String.starts_with?(e.mode, "100"), do: e.sha
ok!(ExGitObjectstore.blob_sizes(repo, blob_shas))
end
defp clone_walk(repo, head) do
walk(repo, head, MapSet.new()) |> MapSet.size()
end
defp walk(repo, sha, seen) do
if MapSet.member?(seen, sha), do: seen, else: visit(repo, sha, MapSet.put(seen, sha))
end
defp visit(repo, sha, seen) do
case ObjectResolver.read(repo, sha) do
{:ok, %ExGitObjectstore.Object.Commit{tree: t, parents: ps}} ->
Enum.reduce(ps, walk(repo, t, seen), &walk(repo, &1, &2))
{:ok, %ExGitObjectstore.Object.Tree{entries: es}} ->
Enum.reduce(es, seen, fn %{sha: s}, acc -> walk(repo, s, acc) end)
{:ok, %ExGitObjectstore.Object.Tag{object: o}} ->
walk(repo, o, seen)
_ ->
seen
end
end
# native git: clone serving = read+pack all reachable from HEAD.
defp run_git(dir, [:pipe_pack, head]) do
{_, 0} =
System.shell(
"git -C #{dir} rev-list #{head} | git -C #{dir} pack-objects --stdout --revs >/dev/null",
stderr_to_stdout: true
)
end
defp run_git(dir, args) do
{_, 0} = System.cmd("git", ["-C", dir | args], stderr_to_stdout: true)
end
# ── Context + fixture ────────────────────────────────────────────────────────
defp build_ctx("git", opts) do
dir = opts[:git_dir] || Mix.raise("--git-dir required for --target git")
%{git_dir: dir, repo: nil, fixture: fixture(opts, {:git, dir})}
end
defp build_ctx(target, opts) when target in ["filesystem", "s3"] do
if target == "s3", do: {:ok, _} = Application.ensure_all_started(:ex_aws_s3)
repo_id = opts[:repo] || Mix.raise("--repo required")
repo = Repo.new(repo_id, storage: storage(target, opts))
%{git_dir: nil, repo: repo, fixture: fixture(opts, {:repo, repo})}
end
defp storage("filesystem", opts) do
{ExGitObjectstore.Storage.Filesystem,
%{root: Path.expand(opts[:root] || Mix.raise("--root required"))}}
end
defp storage("s3", opts) do
{ExGitObjectstore.Storage.S3,
%{
bucket: opts[:bucket] || Mix.raise("--bucket required"),
ex_aws_config: [
access_key_id: opts[:s3_key] || "minioadmin",
secret_access_key: opts[:s3_secret] || "minioadmin",
region: "us-east-1",
host: opts[:s3_host] || "localhost",
port: opts[:s3_port] || 9000,
scheme: opts[:s3_scheme] || "http://"
]
}}
end
# Use explicit fixture flags when present, else derive from the source. Only
# derives when something is missing (derivation reads the repo; passing all
# five flags — what the orchestrator does — skips it entirely).
defp fixture(opts, source) do
provided = %{
head: opts[:head],
old: opts[:old],
file: opts[:file],
blob: opts[:blob],
subdir: opts[:subdir]
}
if Enum.all?(Map.values(provided), &(&1 != nil)) do
provided
else
base = derive_fixture(source, opts[:depth] || 50)
Map.merge(base, for({k, v} <- provided, v != nil, into: %{}, do: {k, v}))
end
end
defp derive_fixture({:git, dir}, depth) do
head = git_out(dir, ["rev-parse", "HEAD"])
old = git_out(dir, ["rev-parse", "HEAD~#{depth}"])
ls = git_out(dir, ["ls-tree", head]) |> String.split("\n", trim: true)
{blob, file} = first_blob_from_ls(ls)
%{head: head, old: old, file: file, blob: blob, subdir: first_dir_from_ls(ls)}
end
defp derive_fixture({:repo, repo}, depth) do
{:ok, head} = ExGitObjectstore.resolve(repo, "HEAD")
entries = log_entries(ExGitObjectstore.log_page(repo, head, limit: depth + 1))
old = entries |> List.last() |> commit_sha() || head
{:ok, tree} = ExGitObjectstore.tree(repo, head, "/")
blob_entry = Enum.find(tree.entries, &String.starts_with?(&1.mode, "100"))
dir_entry = Enum.find(tree.entries, &String.starts_with?(&1.mode, "40"))
%{
head: head,
old: old || head,
file: (blob_entry && blob_entry.name) || "README.md",
blob: (blob_entry && blob_entry.sha) || head,
subdir: (dir_entry && dir_entry.name) || ""
}
end
defp log_entries({:ok, entries, _cursor}) when is_list(entries), do: entries
defp commit_sha({sha, _commit}) when is_binary(sha), do: sha
defp commit_sha(%{sha: sha}), do: sha
defp commit_sha(%{"sha" => sha}), do: sha
defp commit_sha(_), do: nil
defp first_blob_from_ls(lines) do
line = Enum.find(lines, &String.contains?(&1, " blob ")) || hd(lines)
[meta, name] = String.split(line, "\t", parts: 2)
sha = meta |> String.split() |> Enum.at(2)
{sha, name}
end
defp first_dir_from_ls(lines) do
case Enum.find(lines, &String.contains?(&1, " tree ")) do
nil -> ""
line -> line |> String.split("\t", parts: 2) |> List.last()
end
end
defp git_out(dir, args) do
{out, 0} = System.cmd("git", ["-C", dir | args], stderr_to_stdout: true)
String.trim(out)
end
# ── Timing ───────────────────────────────────────────────────────────────────
defp time_us(fun) do
{us, _} = :timer.tc(fun)
us
end
defp median_ms(times_us) do
sorted = Enum.sort(times_us)
n = length(sorted)
mid = div(n, 2)
us =
if rem(n, 2) == 1,
do: Enum.at(sorted, mid),
else: (Enum.at(sorted, mid - 1) + Enum.at(sorted, mid)) / 2
Float.round(us / 1000, 1)
end
defp fmt(ms), do: "#{ms} ms"
# ── Render ───────────────────────────────────────────────────────────────────
defp render(files) do
cols = Enum.map(files, fn f -> Jason.decode!(File.read!(f)) end)
rows =
Enum.map(ops(), fn op ->
id = Atom.to_string(op.id)
cells = Enum.map(cols, fn c -> cell(c, id) end)
{op.label, cells}
end)
headers = ["operation" | Enum.map(cols, & &1["label"])]
Mix.shell().info(table_line(headers))
Mix.shell().info(table_sep(length(headers)))
Enum.each(rows, fn {label, cells} ->
Mix.shell().info(table_line([label | Enum.map(cells, &cell_str/1)]))
end)
end
defp cell(col, id) do
case Enum.find(col["results"], &(&1["id"] == id)) do
%{"ms" => ms, "timeout" => true} -> {:timeout, ms}
%{"ms" => ms} -> ms
_ -> nil
end
end
defp cell_str({:timeout, ms}), do: ">#{ms}"
defp cell_str(ms) when is_number(ms), do: "#{ms}"
defp cell_str(_), do: "—"
defp table_line(cells), do: "| " <> Enum.join(cells, " | ") <> " |"
defp table_sep(n), do: "|" <> String.duplicate(" --- |", n)
end
test/ex_git_objectstore/pack/index_test.exs +36 −0
@@ -49,6 +49,42 @@
end
end
describe "lookup_in_raw" do
test "returns the same offset as parse/1 + lookup/2 for every SHA" do
{idx_data, shas} = create_packed_repo()
{:ok, index} = Index.parse(idx_data)
for sha <- shas do
{:ok, expected} = Index.lookup(index, sha)
assert {:ok, ^expected} = Index.lookup_in_raw(idx_data, sha)
end
end
test "agrees with lookup/2 across every SHA in the index (incl. fanout edges)" do
{idx_data, _shas} = create_packed_repo()
{:ok, index} = Index.parse(idx_data)
for sha <- index.shas do
assert Index.lookup_in_raw(idx_data, sha) == Index.lookup(index, sha)
end
end
test "non-existent SHA returns :not_found" do
{idx_data, _shas} = create_packed_repo()
assert :not_found = Index.lookup_in_raw(idx_data, String.duplicate("0", 40))
assert :not_found = Index.lookup_in_raw(idx_data, String.duplicate("f", 40))
end
test "rejects malformed input" do
{idx_data, _} = create_packed_repo()
assert {:error, :invalid_idx_header} =
Index.lookup_in_raw("nope", String.duplicate("0", 40))
assert {:error, :invalid_sha} = Index.lookup_in_raw(idx_data, "tooshort")
end
end
describe "spec compliance - checksum and sorting" do
test "generated index has valid checksum" do
# Use Writer to generate a pack+index, then verify index parses
test/ex_git_objectstore/pack/reader_test.exs +56 −0
@@ -130,6 +130,62 @@
end
end
describe "read_object_ranged" do
test "matches whole-pack read for every object (incl. OFS_DELTA chains)" do
{pack_data, idx_data, _expected} = create_packed_repo_with_objects()
{:ok, index} = Index.parse(idx_data)
fetch = ranged_fetch(pack_data)
resolver = ranged_resolver(idx_data, fetch)
for sha <- index.shas do
{:ok, offset} = Index.lookup(index, sha)
whole = Reader.read_object(pack_data, offset)
ranged = Reader.read_object_ranged(fetch, offset, resolver)
assert ranged == whole, "ranged read differs from whole-pack read for #{sha}"
end
end
test "grows the window for an object larger than the initial window" do
# ~1 MiB incompressible blob → compressed > 256 KiB initial window, so the
# first fetch is too small and read_object_ranged must refetch larger.
content = :crypto.strong_rand_bytes(1_000_000)
blob = Blob.from_content(content)
sha = Object.hash(blob)
{pack_data, idx_data, _} = Writer.generate_with_index([{:blob, content, sha}])
{:ok, offset} = Index.lookup_in_raw(idx_data, sha)
fetch = ranged_fetch(pack_data)
assert {:ok, {:blob, ^content}} =
Reader.read_object_ranged(fetch, offset, ranged_resolver(idx_data, fetch))
end
end
# Simulate ranged storage over an in-memory pack binary.
defp ranged_fetch(pack_data) do
size = byte_size(pack_data)
fn offset, length ->
if offset >= size,
do: {:ok, <<>>},
else: {:ok, binary_part(pack_data, offset, min(length, size - offset))}
end
end
# REF_DELTA base resolver: SHA → offset (raw idx) → ranged read.
defp ranged_resolver(idx_data, fetch) do
rec = fn base_sha, self ->
case Index.lookup_in_raw(idx_data, base_sha) do
{:ok, off} -> Reader.read_object_ranged(fetch, off, fn s -> self.(s, self) end)
other -> {:error, {:ref_base_lookup, other}}
end
end
fn base_sha -> rec.(base_sha, rec) end
end
describe "spec compliance - pack checksum verification" do
test "verify_pack_checksum succeeds on valid pack" do
content = "hello world\n"
test/ex_git_objectstore/protocol/upload_pack_reuse_test.exs +238 −0
@@ -1,0 +1,238 @@
# 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.UploadPackReuseTest do
@moduledoc """
Pack-reuse correctness for clone (upload-pack). A full clone of a single-pack
repo whose pack equals the reachable set must stream the pack VERBATIM, and the
result must be a valid packfile that real `git` accepts and that contains every
reachable object. When the pack does NOT equal the reachable set (extra loose
reachable objects), reuse must fall back to generation and still be complete.
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, Ref}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Pack.Reader
alias ExGitObjectstore.Protocol.{PktLine, UploadPackV2}
alias ExGitObjectstore.Test.RepoHelper
@moduletag :git_interop
setup do
tmp = Path.join(System.tmp_dir!(), "exgo_reuse_#{System.unique_integer([:positive])}")
File.mkdir_p!(tmp)
on_exit(fn -> File.rm_rf!(tmp) end)
{:ok, tmp: tmp}
end
# Build a real git repo with several commits (enough churn to produce deltas),
# gc it into a single pack, and return {pack_sha, pack_data, idx_data, head}.
defp git_packed_repo(tmp) do
g = fn args ->
{out, 0} = System.cmd("git", args, cd: tmp)
String.trim(out)
end
g.(["init", "-q", "-b", "main"])
g.(["config", "user.email", "t@t.com"])
g.(["config", "user.name", "T"])
for i <- 1..40 do
File.write!(Path.join(tmp, "file.txt"), String.duplicate("line #{i}\n", i * 50))
File.write!(Path.join(tmp, "other_#{rem(i, 5)}.txt"), "v#{i}\n")
g.(["add", "-A"])
g.(["commit", "-q", "-m", "commit #{i}"])
end
g.(["repack", "-a", "-d", "-q"])
head = g.(["rev-parse", "HEAD"])
pack = Path.wildcard(Path.join(tmp, ".git/objects/pack/*.pack")) |> hd()
idx = String.replace_suffix(pack, ".pack", ".idx")
"pack-" <> sha = Path.basename(pack, ".pack")
{sha, File.read!(pack), File.read!(idx), head}
end
defp load_pack(repo, pack_sha, pack_data, idx_data, head) do
:ok = ExGitObjectstore.Repo.storage_call(repo, :put_pack, [pack_sha, pack_data, idx_data])
:ok = Ref.put(repo, "refs/heads/main", head, nil)
repo
end
defp clone_pack(repo, want) do
{_advert, state} = UploadPackV2.init(repo)
client_data =
PktLine.encode("command=fetch") <>
PktLine.delim() <>
PktLine.encode("want #{want}") <>
PktLine.encode("done") <>
PktLine.flush()
{response, new_state} = UploadPackV2.feed(state, client_data)
assert new_state.phase == :done
extract_pack(response)
end
# Validate a packfile with real git: `git index-pack --strict` runs fsck-level
# checks and rebuilds the index from scratch — the gold standard for "is this
# pack correct and self-contained". Returns the object count git reports.
defp git_validates!(tmp, pack_data) do
dir = Path.join(tmp, "verify_#{System.unique_integer([:positive])}")
File.mkdir_p!(dir)
pack_file = Path.join(dir, "in.pack")
File.write!(pack_file, pack_data)
{_out, status} =
System.cmd("git", ["index-pack", "--strict", pack_file], cd: dir, stderr_to_stdout: true)
assert status == 0, "git index-pack --strict rejected the pack"
idx = String.replace_suffix(pack_file, ".pack", ".idx")
{vp, 0} = System.cmd("git", ["verify-pack", "-v", idx], stderr_to_stdout: true)
vp
|> String.split("\n", trim: true)
|> Enum.count(&Regex.match?(~r/^[0-9a-f]{40} (commit|tree|blob|tag) /, &1))
end
describe "pack reuse (full clone of a packed repo)" do
test "streams the on-disk pack VERBATIM and git accepts it", %{tmp: tmp} do
{pack_sha, pack_data, idx_data, head} = git_packed_repo(tmp)
repo = RepoHelper.memory_repo()
load_pack(repo, pack_sha, pack_data, idx_data, head)
cloned = clone_pack(repo, head)
# Verbatim reuse: the bytes the client receives ARE the on-disk pack.
assert cloned == pack_data, "expected verbatim pack reuse"
# Real git validates the reused pack (fsck-strict) and sees every object.
git_count = git_validates!(tmp, cloned)
{:ok, entries} = Reader.parse(cloned)
assert length(entries) == git_count
end
test "reused clone contains every reachable object", %{tmp: tmp} do
{pack_sha, pack_data, idx_data, head} = git_packed_repo(tmp)
repo = RepoHelper.memory_repo()
load_pack(repo, pack_sha, pack_data, idx_data, head)
cloned = clone_pack(repo, head)
{:ok, entries} = Reader.parse(cloned)
got = MapSet.new(entries, & &1.sha)
# Walk the repo's reachable set independently and require it ⊆ the clone.
reachable = reachable_shas(repo, head)
assert MapSet.subset?(reachable, got),
"clone missing reachable objects: #{inspect(MapSet.difference(reachable, got) |> Enum.take(5))}"
end
end
describe "fallback (reuse conditions not met) stays correct" do
test "extra loose reachable object → falls back, clone still complete", %{tmp: tmp} do
{pack_sha, pack_data, idx_data, head} = git_packed_repo(tmp)
repo = RepoHelper.memory_repo()
load_pack(repo, pack_sha, pack_data, idx_data, head)
# Add a NEW loose commit on top of the packed history. Now the reachable
# set ⊋ the pack, so reuse must NOT fire (it would drop this commit).
{:ok, head_commit} = ExGitObjectstore.cat_object(repo, head)
blob = Blob.from_content("brand new loose content\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "new.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
new_commit = %Commit{
tree: tree_sha,
parents: [head],
author: "T <t@t.com> 1000000000 +0000",
committer: "T <t@t.com> 1000000000 +0000",
message: "loose on top\n"
}
{:ok, new_head} = Object.write(repo, new_commit)
:ok = Ref.put(repo, "refs/heads/main", new_head, head)
cloned = clone_pack(repo, new_head)
# NOT verbatim (it had to generate), and the new loose objects are present.
refute cloned == pack_data
git_validates!(tmp, cloned)
{:ok, entries} = Reader.parse(cloned)
got = MapSet.new(entries, & &1.sha)
assert MapSet.subset?(MapSet.new([new_head, tree_sha, blob_sha]), got)
# And the old packed history still made it in.
assert MapSet.member?(got, head)
_ = {pack_sha, idx_data, head_commit}
end
end
# --- helpers ---
defp reachable_shas(repo, head) do
walk(repo, [head], MapSet.new())
end
defp walk(_repo, [], seen), do: seen
defp walk(repo, [sha | rest], seen) do
if MapSet.member?(seen, sha) do
walk(repo, rest, seen)
else
seen = MapSet.put(seen, sha)
next =
case ExGitObjectstore.cat_object(repo, sha) do
{:ok, %Commit{tree: t, parents: ps}} -> [t | ps]
{:ok, %Tree{entries: es}} -> for %{mode: m, sha: s} <- es, m != "160000", do: s
_ -> []
end
walk(repo, next ++ rest, seen)
end
end
# Minimal sideband-1 pack extractor: pkt-line stream, take band-1 payloads
# after the "packfile" line until flush.
defp extract_pack(response), do: pktlines(response, false, <<>>)
defp pktlines(<<"0000", rest::binary>>, true, acc) do
_ = rest
acc
end
defp pktlines(<<"0000", rest::binary>>, false, acc), do: pktlines(rest, false, acc)
defp pktlines(<<len_hex::binary-size(4), tail::binary>>, in_pack?, acc) do
len = String.to_integer(len_hex, 16) - 4
<<payload::binary-size(len), rest::binary>> = tail
cond do
payload == "packfile\n" ->
pktlines(rest, true, acc)
in_pack? and match?(<<1, _::binary>>, payload) ->
<<1, data::binary>> = payload
pktlines(rest, true, acc <> data)
true ->
pktlines(rest, in_pack?, acc)
end
end
defp pktlines(<<>>, _in_pack?, acc), do: acc
end
test/ex_git_objectstore/storage/s3_test.exs +11 −0
@@ -216,6 +216,17 @@
assert {:ok, ^idx_data} = Repo.storage_call(repo, :get_pack_index, ["abc123"])
end
test "get_pack_range returns the requested byte slice via HTTP Range", %{repo: repo} do
pack_data = "PACK" <> <<0, 0, 0, 2>> <> "0123456789abcdef"
:ok = Repo.storage_call(repo, :put_pack, ["rangetest", pack_data, "idx"])
assert {:ok, slice} = Repo.storage_call(repo, :get_pack_range, ["rangetest", 8, 5])
assert slice == binary_part(pack_data, 8, 5)
# Range past EOF yields empty, not an error.
assert {:ok, ""} = Repo.storage_call(repo, :get_pack_range, ["rangetest", 9999, 10])
end
test "get non-existent pack returns error", %{repo: repo} do
assert {:error, :not_found} = Repo.storage_call(repo, :get_pack, ["nonexistent"])
end