Git backed by object storage because you can't stop me
git object-storage kefka
10

Configure Feed

Select the types of activity you want to include in your feed.

feat(s3fs): cache immutable pack files on local disk to fix clone hangs

Serving a clone via upload-pack's delta-compression re-reads pack
objects thousands of times. Commit 76bb55d made pack reads lazy to
avoid RAM buffering; this causes each access to issue a fresh S3
GetObject, making real-repo clones never finish.

Implement PackCache: immutable pack-dir files (.pack/.idx/.rev)
download once to a local temp file (LRU-bounded by -pack-cache-bytes,
default 2GB) and all reads serve from local disk.

Results on real Tigris bucket:
- objgit (318 objects, 200KB pack): 8500+ GetObjects → 10, 7s
- kefka (197 commits, 19.6MB pack): hang → 14s, full clone

The cache is wired into OpenFile and shared across Chroot children
like the listing cache. Add flags: -pack-cache-bytes, -pack-cache-dir.

See docs/plans/pack-temp-file-cache.md for design.
Assisted-by: Claude Opus 4.8 via claude.ai

Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso (May 30, 2026, 2:51 PM EDT) e1145432 0e99aa0b

+605 -1
+18 -1
cmd/objgitd/main.go
··· 48 48 49 49 s3CacheRecursive = flag.String("s3-cache-recursive-prefixes", "refs/", "comma-separated key prefixes served from one recursive subtree scan instead of a listing per folder; empty disables subtree caching") 50 50 s3CacheMaxSubtree = flag.Int("s3-cache-max-subtree-keys", 50000, "abandon a recursive subtree scan past this many keys and fall back to per-folder listing") 51 + 52 + packCacheBytes = flag.Int64("pack-cache-bytes", 2<<30, "local disk budget for cached pack files (.pack/.idx/.rev), downloaded once and served from a temp file so a clone doesn't re-fetch pack objects per access; 0 disables the cache") 53 + packCacheDir = flag.String("pack-cache-dir", "", "parent directory for the pack-file cache; empty uses the OS temp dir") 51 54 ) 52 55 53 56 func main() { ··· 77 80 // Route s3fs S3 round-trips into Prometheus before any filesystem use. 78 81 s3fs.SetMetricsObserver(metrics.ObserveS3) 79 82 80 - client, err := storage.New(ctx) 83 + rawClient, err := storage.New(ctx) 81 84 if err != nil { 82 85 slog.Error("can't create Tigris storage client", "err", err) 83 86 os.Exit(1) 84 87 } 88 + // Harden the client's HTTP path so stale keep-alive connections to Tigris 89 + // fail fast and retry on a fresh connection instead of hanging the request 90 + // forever (see internal/s3fs/resilient.go). 91 + client := s3fs.Harden(rawClient) 85 92 86 93 var cache *s3fs.ListingCache 87 94 var fsOpts []s3fs.Option ··· 107 114 ListingItems: s.ListingItems, SubtreeItems: s.SubtreeItems, HeadItems: s.HeadItems, 108 115 } 109 116 }) 117 + } 118 + 119 + if *packCacheBytes != 0 { 120 + packCache, err := s3fs.NewPackCache(*packCacheDir, *packCacheBytes) 121 + if err != nil { 122 + slog.Error("can't create pack cache", "err", err) 123 + os.Exit(1) 124 + } 125 + defer packCache.Cleanup() 126 + fsOpts = append(fsOpts, s3fs.WithPackCache(packCache)) 110 127 } 111 128 112 129 fsys, err := s3fs.NewS3FS(client, *bucket, fsOpts...)
+73
docs/plans/pack-temp-file-cache.md
··· 1 + # Local temp-file pack cache 2 + 3 + ## Problem 4 + 5 + Serving a clone runs go-git's `upload-pack`, whose delta-compression phase 6 + (`deltaSelector.ObjectsToPack` → `Encoder.Encode`) re-reads the repository's pack 7 + objects **thousands of times** with random access. Commit `76bb55d` made pack 8 + reads lazy: every object access issues a fresh S3 `GetObject`. Measured on a 9 + 318-object repo (200 KB pack), serving one clone made **8,500+ GetObject calls 10 + and climbing** — the clone never finishes within any reasonable timeout. Real 11 + repos (kefka: 19.6 MB pack, far more objects) are effectively unservable. 12 + 13 + The old eager reader buffered the whole pack in RAM once, so the thousands of 14 + re-reads were in-memory and instant — at the cost of holding whole packs in RAM, 15 + which `76bb55d` set out to avoid. 16 + 17 + A secondary failure rode on top: among those thousands of GetObjects, some reused 18 + stale keep-alive connections to Tigris and, with `context.TODO()` (no timeout), 19 + hung forever. That is fixed separately by `internal/s3fs/resilient.go` (hardened 20 + HTTP client). This plan addresses the round-trip explosion. 21 + 22 + ## Approach 23 + 24 + Materialise each pack-directory file (`.pack`, `.idx`, `.rev`) to a **local temp 25 + file** on first open, and serve all reads from that local file. This gives 26 + go-git cheap repeated random access (local disk) without buffering whole packs in 27 + RAM. The user explicitly chose this over RAM buffering, accepting the local-disk 28 + dependency the project otherwise avoids. 29 + 30 + Pack-dir files are immutable and content-addressed (`pack-<sha>.pack`), so a 31 + downloaded temp file is valid for the file's whole lifetime and safe to cache by 32 + S3 key. 33 + 34 + ## Design 35 + 36 + `internal/s3fs/packcache.go`: 37 + 38 + - `PackCache` — keyed by S3 object key. Each entry downloads its object once 39 + (`sync.Once`) into a temp file under a per-process temp dir, then records the 40 + local path + size. A total-bytes LRU budget evicts least-recently-opened 41 + entries (`os.Remove`; open readers keep working — Linux unlinked-while-open). 42 + - `open(ctx, client, bucket, key, name)` returns a `packCachedFile` wrapping an 43 + **independent** `*os.File` (`os.Open` of the cached path, so each reader has its 44 + own seek cursor). `Close` releases the fd but keeps the cached temp file. 45 + - `Cleanup()` removes the temp dir; `main` defers it. 46 + - Download streams `GetObject` (full object) → temp file via `io.Copy`. NoSuchKey 47 + maps to `fs.ErrNotExist` (matches `newS3ReadFile`). 48 + 49 + `packCachedFile` embeds `*os.File` (Read/ReadAt/Seek/Close/Stat) and adds billy's 50 + `Lock`/`Unlock` (no-ops); `Write`/`WriteAt`/`Truncate` return read-only errors; 51 + `Name` returns the logical path. 52 + 53 + Wiring: 54 + 55 + - `S3FS.packCache *PackCache`, propagated in `Chroot` (shared by pointer like 56 + `cache`). `WithPackCache(*PackCache) Option`. 57 + - `OpenFile` `O_RDONLY`: after the temp/dir short-circuits, if `packCache != nil` 58 + and the key is a pack-dir file, return `packCache.open(...)` instead of 59 + `newS3ReadFile`. 60 + - `main.go`: `-pack-cache-bytes` (default 2 GiB; 0 disables) and 61 + `-pack-cache-dir` (default `os.TempDir()`); construct `PackCache`, wire via 62 + `WithPackCache`, `defer Cleanup()`. 63 + 64 + ## Tests 65 + 66 + - `packcache_test.go` (table-driven, stub `s3Client` serving in-memory bytes): 67 + download-once (N opens → 1 GetObject), independent seek cursors across 68 + concurrent readers, correct bytes via Read and ReadAt, ErrClosed after Close, 69 + NoSuchKey → ErrNotExist, LRU eviction frees disk while keeping an already-open 70 + reader valid. 71 + - End-to-end: clone the 318-object repo and kefka through the server; assert the 72 + GetObject count is small (≈ pack-dir file count, not thousands) and `git fsck` 73 + passes.
+9
internal/s3fs/basic.go
··· 74 74 return &tempReadFile{buf: buf, name: filename}, nil 75 75 } 76 76 77 + // Immutable pack-directory files are re-read with random access 78 + // thousands of times while serving a clone. Download each once to a 79 + // local temp file and serve from disk, instead of one S3 round-trip per 80 + // access (which makes clones of real repos never finish). See 81 + // docs/plans/pack-temp-file-cache.md. 82 + if fs3.packCache != nil && isPackCacheable(key) { 83 + return fs3.packCache.open(context.TODO(), fs3.client, fs3.bucket, key, filename) 84 + } 85 + 77 86 // If the parent folder's listing is cached, resolve the open without a 78 87 // negotiation round-trip: absent → not-exist, a sub-prefix → directory. 79 88 // For a present file the head cache (seeded from the listing) lets
+1
internal/s3fs/chroot.go
··· 25 25 separator: fs3.separator, 26 26 unixMeta: fs3.unixMeta, 27 27 cache: fs3.cache, 28 + packCache: fs3.packCache, 28 29 temps: make(map[string]*tempBuffer), 29 30 } 30 31 return nfs, nil
+14
internal/s3fs/filesystem.go
··· 51 51 // shared by pointer across this filesystem and all of its Chroot children. 52 52 cache *ListingCache 53 53 54 + // packCache, when non-nil, serves reads of immutable pack-directory files 55 + // (.pack/.idx/.rev) from a local temp file downloaded once, instead of one 56 + // S3 round-trip per object access. Shared by pointer across Chroot children. 57 + packCache *PackCache 58 + 54 59 // temps holds TempFile-backed buffers keyed by canonical S3 key, so a 55 60 // subsequent Open of the same path returns a reader over the same bytes 56 61 // the writer is still appending to. See tempfs.go. ··· 78 83 func WithListingCache(c *ListingCache) Option { 79 84 return func(fs3 *S3FS) { 80 85 fs3.cache = c 86 + } 87 + } 88 + 89 + // WithPackCache attaches a local temp-file cache for immutable pack-directory 90 + // files. The same *PackCache is carried into every Chroot child so the whole 91 + // tree shares it. Construct it with NewPackCache and defer its Cleanup. 92 + func WithPackCache(c *PackCache) Option { 93 + return func(fs3 *S3FS) { 94 + fs3.packCache = c 81 95 } 82 96 } 83 97
+212
internal/s3fs/packcache.go
··· 1 + package s3fs 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "io/fs" 8 + "os" 9 + "strings" 10 + "sync" 11 + "time" 12 + 13 + "github.com/aws/aws-sdk-go-v2/service/s3" 14 + ) 15 + 16 + // isPackCacheable reports whether key names an immutable pack-directory file 17 + // that benefits from the local temp-file cache. These files are content- 18 + // addressed (pack-<sha>.{pack,idx,rev}) and re-read with random access many 19 + // times while serving a clone, so a single download served from local disk 20 + // replaces thousands of S3 round-trips. See docs/plans/pack-temp-file-cache.md. 21 + func isPackCacheable(key string) bool { 22 + return strings.HasSuffix(key, ".pack") || 23 + strings.HasSuffix(key, ".idx") || 24 + strings.HasSuffix(key, ".rev") 25 + } 26 + 27 + // PackCache materialises immutable pack-directory objects to local temp files 28 + // and serves their reads from disk. go-git's upload-pack re-reads pack objects 29 + // thousands of times during delta compression; without this each access is a 30 + // fresh S3 GetObject and a clone never completes. The cache is shared by pointer 31 + // across an S3FS and all of its Chroot children. 32 + // 33 + // Entries are keyed by S3 object key. Each downloads its object once and is 34 + // reused across opens; a total-bytes budget evicts the least-recently-opened 35 + // entries. Eviction unlinks the temp file, which on Linux leaves already-open 36 + // readers working until they close, so eviction never corrupts an in-flight 37 + // read. 38 + type PackCache struct { 39 + dir string 40 + maxBytes int64 41 + 42 + mu sync.Mutex 43 + entries map[string]*packEntry 44 + curBytes int64 45 + seq uint64 // monotonic open counter; entry.used orders the LRU 46 + } 47 + 48 + // packEntry is one cached object. once guards the single download; path/size/err 49 + // are set by it. used is the seq of the most recent open, for LRU ordering. 50 + type packEntry struct { 51 + key string 52 + once sync.Once 53 + path string 54 + size int64 55 + err error 56 + used uint64 57 + } 58 + 59 + // NewPackCache creates a pack cache writing temp files under a fresh directory 60 + // inside parent (os.TempDir() when empty). maxBytes bounds the total size of 61 + // cached files; opens past the budget evict the least-recently-opened entries. 62 + // A maxBytes <= 0 disables the budget (no eviction). Call Cleanup to remove the 63 + // temp directory. 64 + func NewPackCache(parent string, maxBytes int64) (*PackCache, error) { 65 + if parent == "" { 66 + parent = os.TempDir() 67 + } 68 + dir, err := os.MkdirTemp(parent, "objgit-packs-") 69 + if err != nil { 70 + return nil, fmt.Errorf("s3fs: create pack cache dir: %w", err) 71 + } 72 + return &PackCache{ 73 + dir: dir, 74 + maxBytes: maxBytes, 75 + entries: make(map[string]*packEntry), 76 + }, nil 77 + } 78 + 79 + // Cleanup removes the cache's temp directory and all files in it. Already-open 80 + // readers keep working (unlinked-while-open); new opens after Cleanup fail. 81 + func (c *PackCache) Cleanup() error { 82 + c.mu.Lock() 83 + dir := c.dir 84 + c.entries = map[string]*packEntry{} 85 + c.curBytes = 0 86 + c.mu.Unlock() 87 + return os.RemoveAll(dir) 88 + } 89 + 90 + // open returns a billy.File for key, downloading the object to a temp file on 91 + // first use and serving from that file thereafter. Each call returns an 92 + // independent *os.File handle so concurrent readers have their own seek cursor. 93 + func (c *PackCache) open(ctx context.Context, client s3Client, bucket, key, name string) (*packCachedFile, error) { 94 + c.mu.Lock() 95 + e := c.entries[key] 96 + if e == nil { 97 + e = &packEntry{key: key} 98 + c.entries[key] = e 99 + } 100 + c.mu.Unlock() 101 + 102 + e.once.Do(func() { 103 + path, size, err := c.download(ctx, client, bucket, key) 104 + if err != nil { 105 + e.err = err 106 + // Drop the failed entry so a later open retries the download. 107 + c.mu.Lock() 108 + if c.entries[key] == e { 109 + delete(c.entries, key) 110 + } 111 + c.mu.Unlock() 112 + return 113 + } 114 + e.path, e.size = path, size 115 + c.mu.Lock() 116 + c.curBytes += size 117 + c.evictLocked(key) 118 + c.mu.Unlock() 119 + }) 120 + if e.err != nil { 121 + return nil, e.err 122 + } 123 + 124 + f, err := os.Open(e.path) 125 + if err != nil { 126 + // The cached file was evicted/cleaned between download and open; retry 127 + // through a fresh entry so the object is fetched again. 128 + c.mu.Lock() 129 + if c.entries[key] == e { 130 + delete(c.entries, key) 131 + } 132 + c.mu.Unlock() 133 + return nil, err 134 + } 135 + 136 + c.mu.Lock() 137 + c.seq++ 138 + e.used = c.seq 139 + c.mu.Unlock() 140 + 141 + return &packCachedFile{File: f, name: name}, nil 142 + } 143 + 144 + // download streams the full object to a temp file and returns its path and size. 145 + func (c *PackCache) download(ctx context.Context, client s3Client, bucket, key string) (string, int64, error) { 146 + start := time.Now() 147 + out, err := client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: &key}) 148 + observeS3("GetObject", start, err) 149 + if err != nil { 150 + if isNotFound(err) { 151 + return "", 0, &os.PathError{Op: "open", Path: key, Err: fs.ErrNotExist} 152 + } 153 + return "", 0, fmt.Errorf("pack cache GetObject %q: %w", key, err) 154 + } 155 + defer out.Body.Close() 156 + 157 + tmp, err := os.CreateTemp(c.dir, "obj-") 158 + if err != nil { 159 + return "", 0, fmt.Errorf("pack cache temp file: %w", err) 160 + } 161 + n, err := io.Copy(tmp, out.Body) 162 + if cerr := tmp.Close(); err == nil { 163 + err = cerr 164 + } 165 + if err != nil { 166 + os.Remove(tmp.Name()) 167 + return "", 0, fmt.Errorf("pack cache download %q: %w", key, err) 168 + } 169 + return tmp.Name(), n, nil 170 + } 171 + 172 + // evictLocked removes least-recently-opened entries until the cache is within 173 + // budget. keep is never evicted (it is the entry the caller just populated). 174 + // Callers hold c.mu. A non-positive maxBytes disables eviction. 175 + func (c *PackCache) evictLocked(keep string) { 176 + if c.maxBytes <= 0 { 177 + return 178 + } 179 + for c.curBytes > c.maxBytes { 180 + var victim *packEntry 181 + for _, e := range c.entries { 182 + if e.key == keep || e.path == "" { 183 + continue 184 + } 185 + if victim == nil || e.used < victim.used { 186 + victim = e 187 + } 188 + } 189 + if victim == nil { 190 + return // nothing evictable 191 + } 192 + os.Remove(victim.path) // open readers survive on Linux (unlinked fd) 193 + c.curBytes -= victim.size 194 + delete(c.entries, victim.key) 195 + } 196 + } 197 + 198 + // packCachedFile is a read-only billy.File backed by a local temp file. It 199 + // embeds *os.File for Read/ReadAt/Seek/Close/Stat and supplies the billy-only 200 + // Lock/Unlock; writes are rejected. 201 + type packCachedFile struct { 202 + *os.File 203 + name string 204 + } 205 + 206 + func (f *packCachedFile) Name() string { return f.name } 207 + 208 + func (f *packCachedFile) Write(p []byte) (int, error) { return 0, ErrCantWriteToReadOnly } 209 + func (f *packCachedFile) WriteAt(p []byte, off int64) (int, error) { return 0, ErrCantWriteToReadOnly } 210 + func (f *packCachedFile) Truncate(size int64) error { return ErrTruncateNotSupported } 211 + func (f *packCachedFile) Lock() error { return ErrLockNotSupported } 212 + func (f *packCachedFile) Unlock() error { return ErrLockNotSupported }
+278
internal/s3fs/packcache_test.go
··· 1 + package s3fs 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "errors" 7 + "io" 8 + "io/fs" 9 + "os" 10 + "sync" 11 + "testing" 12 + 13 + "github.com/aws/aws-sdk-go-v2/aws" 14 + "github.com/aws/aws-sdk-go-v2/service/s3" 15 + ) 16 + 17 + // packStub serves fixed object bytes and counts GetObject calls per key, so a 18 + // test can assert the cache downloads each object exactly once. The embedded nil 19 + // s3Client is never used: PackCache only calls GetObject. 20 + type packStub struct { 21 + s3Client 22 + mu sync.Mutex 23 + objs map[string][]byte 24 + gets map[string]int 25 + } 26 + 27 + func newPackStub(objs map[string][]byte) *packStub { 28 + return &packStub{objs: objs, gets: map[string]int{}} 29 + } 30 + 31 + func (s *packStub) getCount(key string) int { 32 + s.mu.Lock() 33 + defer s.mu.Unlock() 34 + return s.gets[key] 35 + } 36 + 37 + func (s *packStub) GetObject(_ context.Context, in *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { 38 + s.mu.Lock() 39 + defer s.mu.Unlock() 40 + key := aws.ToString(in.Key) 41 + b, ok := s.objs[key] 42 + if !ok { 43 + return nil, notFound() 44 + } 45 + s.gets[key]++ 46 + return &s3.GetObjectOutput{ 47 + Body: io.NopCloser(bytes.NewReader(b)), 48 + ContentLength: aws.Int64(int64(len(b))), 49 + }, nil 50 + } 51 + 52 + // newTestPackCache builds a PackCache under t.TempDir with the given budget and 53 + // registers Cleanup. 54 + func newTestPackCache(t *testing.T, maxBytes int64) *PackCache { 55 + t.Helper() 56 + pc, err := NewPackCache(t.TempDir(), maxBytes) 57 + if err != nil { 58 + t.Fatalf("NewPackCache: %v", err) 59 + } 60 + t.Cleanup(func() { pc.Cleanup() }) 61 + return pc 62 + } 63 + 64 + func mustReadAll(t *testing.T, f *packCachedFile) []byte { 65 + t.Helper() 66 + if _, err := f.Seek(0, io.SeekStart); err != nil { 67 + t.Fatalf("seek: %v", err) 68 + } 69 + b, err := io.ReadAll(f) 70 + if err != nil { 71 + t.Fatalf("read: %v", err) 72 + } 73 + return b 74 + } 75 + 76 + func TestPackCacheReads(t *testing.T) { 77 + ctx := context.Background() 78 + const key = "repo.git/objects/pack/pack-abc.pack" 79 + const name = "objects/pack/pack-abc.pack" 80 + want := bytes.Repeat([]byte("PACKDATA"), 4096) // 32 KiB 81 + 82 + tests := []struct { 83 + name string 84 + // check exercises one read path and returns (got, wantSlice) to compare. 85 + check func(t *testing.T, f *packCachedFile) (got, exp []byte) 86 + }{ 87 + { 88 + name: "sequential Read", 89 + check: func(t *testing.T, f *packCachedFile) ([]byte, []byte) { 90 + return mustReadAll(t, f), want 91 + }, 92 + }, 93 + { 94 + name: "ReadAt mid-object", 95 + check: func(t *testing.T, f *packCachedFile) ([]byte, []byte) { 96 + p := make([]byte, 100) 97 + if _, err := f.ReadAt(p, 8000); err != nil && err != io.EOF { 98 + t.Fatalf("ReadAt: %v", err) 99 + } 100 + return p, want[8000:8100] 101 + }, 102 + }, 103 + } 104 + 105 + for _, tt := range tests { 106 + t.Run(tt.name, func(t *testing.T) { 107 + stub := newPackStub(map[string][]byte{key: want}) 108 + pc := newTestPackCache(t, 0) 109 + 110 + f, err := pc.open(ctx, stub, "bucket", key, name) 111 + if err != nil { 112 + t.Fatalf("open: %v", err) 113 + } 114 + defer f.Close() 115 + 116 + got, exp := tt.check(t, f) 117 + if !bytes.Equal(got, exp) { 118 + t.Errorf("read bytes mismatch: got %d bytes, want %d", len(got), len(exp)) 119 + } 120 + if f.Name() != name { 121 + t.Errorf("Name = %q, want %q", f.Name(), name) 122 + } 123 + }) 124 + } 125 + } 126 + 127 + // TestPackCacheDownloadsOnce confirms repeated opens of the same key hit the 128 + // cache: exactly one GetObject regardless of how many readers open it. This is 129 + // the whole point — replacing thousands of S3 round-trips with one. 130 + func TestPackCacheDownloadsOnce(t *testing.T) { 131 + ctx := context.Background() 132 + const key = "r.git/objects/pack/pack-x.pack" 133 + data := bytes.Repeat([]byte{0xAB}, 1024) 134 + stub := newPackStub(map[string][]byte{key: data}) 135 + pc := newTestPackCache(t, 0) 136 + 137 + const opens = 25 138 + for i := range opens { 139 + f, err := pc.open(ctx, stub, "bucket", key, "name") 140 + if err != nil { 141 + t.Fatalf("open %d: %v", i, err) 142 + } 143 + if got := mustReadAll(t, f); !bytes.Equal(got, data) { 144 + t.Fatalf("open %d: bytes mismatch", i) 145 + } 146 + f.Close() 147 + } 148 + if n := stub.getCount(key); n != 1 { 149 + t.Errorf("GetObject called %d times across %d opens, want 1", n, opens) 150 + } 151 + } 152 + 153 + // TestPackCacheIndependentCursors verifies two open handles over the same cached 154 + // file have independent seek positions (each gets its own *os.File). 155 + func TestPackCacheIndependentCursors(t *testing.T) { 156 + ctx := context.Background() 157 + const key = "r.git/objects/pack/pack-y.idx" 158 + data := []byte("0123456789abcdef") 159 + stub := newPackStub(map[string][]byte{key: data}) 160 + pc := newTestPackCache(t, 0) 161 + 162 + a, err := pc.open(ctx, stub, "bucket", key, "a") 163 + if err != nil { 164 + t.Fatal(err) 165 + } 166 + defer a.Close() 167 + b, err := pc.open(ctx, stub, "bucket", key, "b") 168 + if err != nil { 169 + t.Fatal(err) 170 + } 171 + defer b.Close() 172 + 173 + if _, err := a.Seek(10, io.SeekStart); err != nil { 174 + t.Fatal(err) 175 + } 176 + pa := make([]byte, 3) 177 + if _, err := io.ReadFull(a, pa); err != nil { 178 + t.Fatal(err) 179 + } 180 + if string(pa) != "abc" { 181 + t.Errorf("a read %q, want abc", pa) 182 + } 183 + // b's cursor is untouched, still at 0. 184 + pb := make([]byte, 3) 185 + if _, err := io.ReadFull(b, pb); err != nil { 186 + t.Fatal(err) 187 + } 188 + if string(pb) != "012" { 189 + t.Errorf("b read %q, want 012 (independent cursor)", pb) 190 + } 191 + } 192 + 193 + func TestPackCacheClosedReadFails(t *testing.T) { 194 + ctx := context.Background() 195 + const key = "r.git/objects/pack/pack-z.pack" 196 + stub := newPackStub(map[string][]byte{key: []byte("data")}) 197 + pc := newTestPackCache(t, 0) 198 + 199 + f, err := pc.open(ctx, stub, "bucket", key, "n") 200 + if err != nil { 201 + t.Fatal(err) 202 + } 203 + if err := f.Close(); err != nil { 204 + t.Fatalf("close: %v", err) 205 + } 206 + if _, err := f.Read(make([]byte, 1)); !errors.Is(err, os.ErrClosed) { 207 + t.Errorf("read after close: err = %v, want os.ErrClosed", err) 208 + } 209 + } 210 + 211 + func TestPackCacheMissingObject(t *testing.T) { 212 + stub := newPackStub(map[string][]byte{}) 213 + pc := newTestPackCache(t, 0) 214 + 215 + _, err := pc.open(context.Background(), stub, "bucket", "r.git/objects/pack/absent.pack", "n") 216 + if !errors.Is(err, fs.ErrNotExist) { 217 + t.Errorf("open absent: err = %v, want fs.ErrNotExist", err) 218 + } 219 + } 220 + 221 + // TestPackCacheEviction checks that exceeding the byte budget evicts the 222 + // least-recently-opened entry (re-download on next open) while a reader holding 223 + // the evicted file open still reads correct bytes (unlinked-while-open). 224 + func TestPackCacheEviction(t *testing.T) { 225 + ctx := context.Background() 226 + const keyA = "r.git/objects/pack/A.pack" 227 + const keyB = "r.git/objects/pack/B.pack" 228 + dataA := bytes.Repeat([]byte("A"), 1000) 229 + dataB := bytes.Repeat([]byte("B"), 1000) 230 + stub := newPackStub(map[string][]byte{keyA: dataA, keyB: dataB}) 231 + 232 + // Budget holds only one object; opening B evicts A. 233 + pc := newTestPackCache(t, 1500) 234 + 235 + fa, err := pc.open(ctx, stub, "bucket", keyA, "a") 236 + if err != nil { 237 + t.Fatal(err) 238 + } 239 + defer fa.Close() 240 + 241 + // Open B: total (2000) exceeds 1500, so A is evicted from the cache. 242 + fb, err := pc.open(ctx, stub, "bucket", keyB, "b") 243 + if err != nil { 244 + t.Fatal(err) 245 + } 246 + fb.Close() 247 + 248 + // fa was opened before eviction; its fd still reads A's bytes correctly. 249 + if got := mustReadAll(t, fa); !bytes.Equal(got, dataA) { 250 + t.Errorf("evicted-but-open reader: bytes mismatch") 251 + } 252 + 253 + // Re-opening A must re-download (its cache entry was dropped). 254 + fa2, err := pc.open(ctx, stub, "bucket", keyA, "a2") 255 + if err != nil { 256 + t.Fatal(err) 257 + } 258 + fa2.Close() 259 + if n := stub.getCount(keyA); n != 2 { 260 + t.Errorf("A GetObject count = %d, want 2 (re-downloaded after eviction)", n) 261 + } 262 + } 263 + 264 + func TestIsPackCacheable(t *testing.T) { 265 + tests := map[string]bool{ 266 + "r.git/objects/pack/pack-1.pack": true, 267 + "r.git/objects/pack/pack-1.idx": true, 268 + "r.git/objects/pack/pack-1.rev": true, 269 + "r.git/refs/heads/main": false, 270 + "r.git/HEAD": false, 271 + "r.git/config": false, 272 + } 273 + for key, want := range tests { 274 + if got := isPackCacheable(key); got != want { 275 + t.Errorf("isPackCacheable(%q) = %v, want %v", key, got, want) 276 + } 277 + } 278 + }