Repository navigation
perf(storage/tigris): push any repository in bounded memory - #25
Merged
Merged
Conversation
A push decoded its pack through a scratch go-git filesystem.Storage. That storage parses with no storage attached, so go-git's parser stays out of low-memory mode and keeps every inflated base and resolved delta until the parse ends. A push of golang/go (465 MiB pack) passed 21 GiB of heap and did not complete. Replace it with a small index-pack in indexpack.go. One pass stages, checksums, and indexes the pack as it arrives, with no bodies kept. A second pass resolves each delta tree depth first, so a body lives only while a delta below it needs it. Bodies over 8 MiB, or past a 64 MiB total, go to temp files. writePack hands the stream over through a new optional ReadPack method, which also removes a go-git Scanner that buffered every object body in full. golang/go now peaks at about 521 MiB of heap and takes 23 s. Xe/x goes from 516 MiB and 14.7 s to about 165 MiB and 1.3 s. Delta reuse is unchanged. This also fixes stored delta chains of exactly 50 links, which git makes by default and which reads refused. Reads now accept a chain of maxDeltaDepth links, and the writer stores an object whole rather than extend a stored chain past that, since clients can send 4095. Assisted-by: Claude Opus 5.5 via Claude Code Signed-off-by: Xe Iaso <xe@tigrisdata.com>
Record the measurements behind the new pack writer, the delta chain depth fault it found, and the next phase: a compact pack index to replace the per-object entries map. Assisted-by: Claude Opus 5.5 via Claude Code Signed-off-by: Xe Iaso <xe@tigrisdata.com>
The pack index kept a map[plumbing.Hash]packEntry, about 210 bytes for each object. Every push held one entry for each object it wrote, and every clone or fetch built one for each object in the repository. Keep the entries in immutable tables instead: the hashes in one sorted []byte and a 40-byte record for each object, about 60 bytes with SHA-1. A record names its pack by number and its delta base by record number. Each registered container adds a table, and more than 16 tables merge into one, so a lookup is at most 16 binary searches. deregister takes only a pack id, so packJob no longer holds its cueRecords until the upload ends. The iterator walks 8-byte record references instead of a copy of every entry, and yields a hash only from the record the lookup returns, which removes its set of seen hashes. A snapshot shares the tables with the index rather than copying them, because the index never edits them in place. For golang/go, a cold index build drops from 225 MiB to 113 MiB of live heap (the index itself from about 180 MiB to 40 MiB). A push peaks at 364 MiB instead of 521 MiB. Assisted-by: Claude Opus 5.5 via Claude Code Signed-off-by: Xe Iaso <xe@tigrisdata.com>
A CPU profile of a golang/go push spent about 80% of its time in system calls. packSegment wrote each object to its file with no buffer, which cost 14.7 s of a 26 s push. Write through a 1 MiB buffer and count the bytes, so copyCompressed no longer seeks to measure a payload. The unused segmentStore reads from an open segment, so it flushes first. Each inflate also read its section through a 32 KiB buffer, so even a tiny object read 32 KiB. Read a section of 64 KiB or less whole, with one ReadAt of its own length. Wall time did not move measurably on a laptop: one pread per inflate now dominates. The plan records mmap of the staged pack as the next step. Assisted-by: Claude Opus 5.5 via Claude Code Signed-off-by: Xe Iaso <xe@tigrisdata.com>
Describe the table-based pack index in the storer architecture, and add the phase 2 results and the remaining push-time work to the plan. Assisted-by: Claude Opus 5.5 via Claude Code Signed-off-by: Xe Iaso <xe@tigrisdata.com>
Assisted-by: Claude Opus 5.5 via Claude Code Signed-off-by: Xe Iaso <xe@tigrisdata.com>
The existing pack tests reach the new pack reader only through Write, with packs from the git CLI, so they never send a REF-delta, a delta whose base is already in the repository, a chain deeper than a read walks, or a malformed pack. Build packs by hand and push each one through both ReadPack and Write. The round-trip cases read every object back through a new Storer and check the stored delta count and the longest stored chain. The reject cases check the error and that no PutObject call was made. One more test pins that ReadPack leaves the bytes after the trailer unread, which writePack depends on for git:// and SSH. Removing the chain-depth cap or the delta source-size check makes these tests fail. Assisted-by: Claude Opus 5.5 via Claude Code Signed-off-by: Xe Iaso <xe@tigrisdata.com>
Assisted-by: Claude Opus 5.5 via Claude Code Signed-off-by: Xe Iaso <xe@tigrisdata.com>
Xe
pushed a commit
that referenced
this pull request
Sep 25, 2026
## [1.13.1](v1.13.0...v1.13.1) (2026-09-25) ### Performance Improvements * **storage/tigris:** push any repository in bounded memory ([#25](#25)) ([2bd51ec](2bd51ec)) Signed-off-by: Tigris Data <social@tigrisdata.com>
|
🎉 This PR is included in version 1.13.1 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
filesystem.Storageparses a pack with no storage attached, so the parser keeps every inflated base and every resolved delta until the parse ends. A new small index-pack inindexpack.goreplaces it.main: a stored delta chain of exactly 50 links did not read back. Git makes such chains by default.Details
The full plan, with every measurement, is in
docs/plans/push-memory.md.The new pack reader (
indexpack.go). The reader makes two passes:scanPackreads the pack as it arrives. It stages the bytes, verifies the trailer checksum, and keeps about 60 bytes for each object. It keeps no body.io.ReaderAt.writePacknow hands the stream to the storer through an optionalReadPackmethod. It no longer runs a go-gitScanner, which buffered the full body of each object.ReadPackstops at the trailer, so git:// and SSH still work.Delta chain depth. Reads now accept a chain of exactly
maxDeltaDepth(50) links. A client can send up to 4095 links, so the writer stores an object whole when its stored chain is already 50 links long.The pack index (
packtable.go). Tables never change after they are built. Each registered container adds a table, and more than 16 tables merge into one. The iterator no longer copies every entry or keeps a set of the hashes it has seen.deregistertakes a pack id, sopackJobno longer holds itscueRecords.Push-path syscalls. Pack segments now write through a 1 MiB buffer, and a small section is read with one
ReadAtof its own length. The wall time did not change measurably, because onepreadfor each inflate is now the main cost. The plan records a memory map of the staged pack as the next step.mainFor Xe/x (50 MB pack), a push goes from 516 MiB and 14.7 s to about 165 MiB and 1.3 s. Delta reuse did not change: both writers keep 12,016 deltas and store 39 MiB.
Not in this PR:
segmentStoreis an earlier, unused attempt at the same fix, and only its own tests call it. It needed one flush to keep working, and it can be deleted in a later change.TestSmartHTTPHealsDanglingHEADfails under-raceonmaintoo, in go-git'smemory.Storage. This branch does not change it.Test plan
go build ./...npm run format(prettier on the changed docs, gofmt on Go)go test ./...passes.go test -racepasses forinternal/storage/tigris.ReadPackandWrite: every object type, OFS-delta chains, REF-deltas before and after their base, a REF-delta on a base already in the repository, a chain three times deeper than a read walks, a chain of exactly 50 links, and every body on disk. Reject cases: bad signature, bad checksum, truncated pack, bad OFS distance, short entry, wrong delta base size, and missing REF base. Removing the chain cap or the base-size check makes the tests fail.Storerand verified each hash. It also passed for Xe/x, a 197-link chain, and a 266 MB blob with a delta, in default mode and with every body on disk.cmd/membench