perf: streaming receive-pack — iolist body + throttled SHA + flush API #27

merged colechristensen 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:

  1. pack_buffer switched from binary() to an iolist accumulator. Per-chunk cost is now O(1) iolist cons instead of binary <> copy.
  2. 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).
  3. New flush/1 API for callers with an out-of-band end-of-stream signal. Forces the final verify and surfaces :incomplete_pack for truncated buffers rather than leaving the state machine wedged in :pack forever.

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 <> data on every chunk forced binary copies for a single growing blob.
  • O(N·K) CPU: verify_pack_checksum ran :crypto.hash(:sha, whole_buffer) on every feed call. OTP 28 doesn’t expose crypto: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 feed alone (HTTP one-shot path)
    • flush/1 on truncated input reports :incomplete_pack
  • mix format --check-formatted clean.

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 colechristensen cole.christensen@gmail.com