ref:main
perf: streaming receive-pack — iolist body + throttled SHA + flush API #27
merged
cole.christensen@gmail.com wants to merge
receive-pack-streaming-hash
into main
No CI
Closes anvil#153.
Summary
Three changes that together fix the ovs push outage observed in prod:
pack_bufferswitched frombinary()to an iolist accumulator. Per-chunk cost is now O(1) iolist cons instead of binary<>copy.- SHA-1 completeness check is throttled to once per 4 MB of body growth (with auto-bypass for bodies smaller than that — keeps small packs / HTTP one-shot working unchanged).
- New
flush/1API for callers with an out-of-band end-of-stream signal. Forces the final verify and surfaces:incomplete_packfor truncated buffers rather than leaving the state machine wedged in:packforever.
Why
Live test push of openvswitch/ovs (106 MB / 134k objects) in prod showed:
- Server CPU pinned at 100% on a single core
- RSS climbed from 200 MB → 1.37 GB at 50 MB transferred
- On-disk repo dir stayed at 8 KB the entire time (nothing written until the whole pack is buffered)
- Transfer rate degraded 900 → 120 KB/s as the buffer grew
- Would have OOM’d before completion against the 3.82 GiB container cap
Two compounding bugs in ReceivePack.feed/2 :pack:
- O(K²) memory:
new_buffer = state.pack_buffer <> dataon every chunk forced binary copies for a single growing blob. - O(N·K) CPU:
verify_pack_checksumran:crypto.hash(:sha, whole_buffer)on every feed call. OTP 28 doesn’t exposecrypto:hash_copy/1, so we can’t keep an incremental SHA cheaply — instead, throttle the check.
Test plan
- Existing receive-pack tests: 11/11.
- Full ex_git_objectstore suite: 928/0.
- New
receive_pack_streaming_test.exs(4 tests):- 5 MB pack in 4 KB chunks completes; every blob lands
- Process heap stays under 3× pack size (regression lock)
- Small pack finalizes via
feedalone (HTTP one-shot path) flush/1on truncated input reports:incomplete_pack
-
mix format --check-formattedclean.
Follow-up (anvil-side, separate PR)
The throttle has a tail-edge: if a pack ends mid-4MB-window and the SSH client EOFs without sending more bytes, the receive sits in :pack waiting for the next boundary that never comes. Anvil’s SSH cli.ex needs to call ProtocolHandler.flush on {:eof, channel_id} — landing in the companion anvil PR alongside the mix.lock bump.
Created May 06, 2026 at 03:33 UTC
| Merged May 06, 2026 at 03:43 UTC
by
cole.christensen@gmail.com