···48484949### Two subtle protocol points
50505151-1. **`streamingStorer` in `git_protocol.go`** wraps the storer for git:// receive-pack to **hide its `PackfileWriter` capability**. `UpdateObjectStorage`'s `PackfileWriter` path uses `io.CopyBuffer` and only returns on `io.EOF`, which deadlocks over a persistent TCP socket (the client is waiting for report-status). Hiding the capability falls through to `Parser.Parse`, which knows the pack's end from the format itself. HTTP doesn't need this — request bodies have a real EOF — so HTTP keeps the faster PackfileWriter path. Trade-off: git:// pushes write loose objects (one S3 PUT per object).
5151+1. **`writePack` in `receivepack.go`** stores the incoming pack whole on every transport. go-git's default `PackfileWriter` path (`WritePackfileToObjectStorage` → `io.CopyBufferPool`) copies until `io.EOF`, which deadlocks over a persistent git:// / SSH socket (the client holds the connection open awaiting report-status). Instead of relying on EOF, `writePack` drives a `packfile.Scanner` over an `io.TeeReader(rd, packWriter)`: the scanner knows the pack's end from its own framing (header object count + trailer checksum) and stops there, while the tee mirrors exactly those bytes into the `PackfileWriter`. The result is one `.pack` + one `.idx` per push — no per-object loose writes (which on S3 cost a `HeadObject` dedup `Lstat` **and** a `PutObject` each). This replaced the old `streamingStorer` hack (which hid `PackfileWriter` to force the loose-object `Parser.Parse` path on git:// / SSH); all three transports now share `writePack`.
525253532. **No-op closers everywhere.** `transport.UploadPack`/`ReceivePack` call `Close` on the reader (and sometimes the writer) between negotiation rounds. The git:// socket can't survive that, and the HTTP `ResponseWriter` doesn't implement `Close`. Wrap with `io.NopCloser` (reader) and `ioutil.WriteNopCloser` from `go-git/v6/utils/ioutil` (writer).
54545555-**SSH shares both gotchas with git://**, not HTTP: an `ssh.Session` is a persistent bidirectional stream, so `handleSSH` uses `streamingStorer{}` for receive-pack and wraps the session in the same no-op closers. (HTTP keeps the faster `PackfileWriter` path because the request body has a real EOF.)
5555+**SSH shares both gotchas with git://**, not HTTP: an `ssh.Session` is a persistent bidirectional stream, so `handleSSH` wraps the session in the same no-op closers and relies on the same Scanner-bounded `writePack` (gotcha #1) instead of the request-body EOF that HTTP enjoys.
5656+5757+> **s3fs `ReadAt` contract.** Because packs are now read back via go-git's `packfile.FSObject` (which probes a packed object's handle with a 1-byte `ReadAt` and reopens the pack on `os.ErrClosed`), `s3fs`'s read file (`internal/s3fs/file.go`) returns `os.ErrClosed` from `Read`/`ReadAt`/`Seek` once closed rather than dereferencing its niled `*bytes.Reader`. A post-receive hook reading the just-pushed commit hits exactly this path.
56585759### The auth seam (`internal/auth`)
5860
+1-17
cmd/objgitd/git_protocol.go
···2828// It is cleared once the (possibly long) transfer begins.
2929const handshakeTimeout = 30 * time.Second
30303131-// streamingStorer wraps a storage.Storer to hide its optional
3232-// storer.PackfileWriter capability. go-git's UpdateObjectStorage drains the
3333-// incoming pack into PackfileWriter via io.CopyBuffer, which only returns on
3434-// io.EOF — fine over HTTP (the request body has a natural EOF) but a deadlock
3535-// over git://, where the client holds the connection open waiting for the
3636-// server's report-status. With PackfileWriter hidden, UpdateObjectStorage
3737-// falls through to Parser.Parse, which knows the end of the pack from the
3838-// pack format itself and never waits for an EOF.
3939-//
4040-// Trade-off: Parser.Parse writes loose objects (one Rename → one S3 PUT each)
4141-// instead of one packfile, so large git:// pushes incur more S3 calls. HTTP
4242-// keeps the fast PackfileWriter path.
4343-type streamingStorer struct {
4444- storage.Storer
4545-}
4646-4731// operationFor maps a git service to the access it needs: receive-pack writes,
4832// everything else (upload-pack, upload-archive) reads.
4933func operationFor(service string) auth.Operation {
···183167 _, _ = pktline.WriteError(conn, fmt.Errorf("cannot open repository %q", req.Pathname))
184168 return fmt.Errorf("opening %q for push: %w", req.Pathname, err)
185169 }
186186- return d.receivePack(ctx, streamingStorer{Storer: st}, st, req.Pathname, r, conn, &transport.ReceivePackRequest{
170170+ return d.receivePack(ctx, st, req.Pathname, r, conn, &transport.ReceivePackRequest{
187171 GitProtocol: gitProtocol,
188172 })
189173
+86
cmd/objgitd/git_protocol_test.go
···1010 "testing"
1111 "time"
12121313+ "github.com/go-git/go-billy/v6"
1314 "github.com/go-git/go-billy/v6/memfs"
1415 "github.com/go-git/go-git/v6/plumbing/transport"
1516 "tangled.org/xeiaso.net/objgit/internal/auth"
···111112 if _, err := fs.Stat("/test.git/config"); err == nil {
112113 t.Fatal("repository must not be created when push is disabled")
113114 }
115115+}
116116+117117+// TestDaemonPushKeepsPack verifies the receive-pack path stores the incoming
118118+// pack whole (objects/pack/pack-*.pack + .idx) rather than exploding it into
119119+// loose objects (objects/<2-hex>/...). git:// used to hide the PackfileWriter
120120+// capability and fall back to loose objects (one S3 PUT + one Lstat per object);
121121+// writePack now delimits the pack with a Scanner and feeds the PackfileWriter on
122122+// every transport.
123123+func TestDaemonPushKeepsPack(t *testing.T) {
124124+ if _, err := exec.LookPath("git"); err != nil {
125125+ t.Skip("git not installed")
126126+ }
127127+128128+ fs := memfs.New()
129129+ d := &daemon{
130130+ fs: fs,
131131+ loader: transport.NewFilesystemLoader(fs, false),
132132+ authz: auth.AllowAnonymous{AllowWrite: true},
133133+ }
134134+135135+ ctx, cancel := context.WithCancel(context.Background())
136136+ defer cancel()
137137+138138+ ln, err := net.Listen("tcp", "127.0.0.1:0")
139139+ if err != nil {
140140+ t.Fatalf("listen: %v", err)
141141+ }
142142+ go func() { _ = d.Serve(ctx, ln) }()
143143+144144+ remote := "git://" + ln.Addr().String() + "/test.git"
145145+146146+ work := t.TempDir()
147147+ runGit(t, work, "init", "-b", "main")
148148+ runGit(t, work, "config", "user.email", "test@example.com")
149149+ runGit(t, work, "config", "user.name", "Test")
150150+ writeFile(t, filepath.Join(work, "README.md"), "hello\n")
151151+ runGit(t, work, "add", ".")
152152+ runGit(t, work, "commit", "-m", "initial") // blob + tree + commit
153153+ runGit(t, work, "push", remote, "main")
154154+155155+ assertPackedRepo(t, fs, "/test.git")
156156+}
157157+158158+// assertPackedRepo fails unless repoPath holds at least one packfile and no loose
159159+// object directories (a 2-hex-char dir under objects/ such as "ab/").
160160+func assertPackedRepo(t *testing.T, fs billy.Filesystem, repoPath string) {
161161+ t.Helper()
162162+163163+ packs, err := fs.ReadDir(repoPath + "/objects/pack")
164164+ if err != nil {
165165+ t.Fatalf("ReadDir objects/pack: %v", err)
166166+ }
167167+ var packCount int
168168+ for _, e := range packs {
169169+ if strings.HasSuffix(e.Name(), ".pack") {
170170+ packCount++
171171+ }
172172+ }
173173+ if packCount == 0 {
174174+ t.Errorf("expected a packfile under %s/objects/pack, found none", repoPath)
175175+ }
176176+177177+ entries, err := fs.ReadDir(repoPath + "/objects")
178178+ if err != nil {
179179+ t.Fatalf("ReadDir objects: %v", err)
180180+ }
181181+ for _, e := range entries {
182182+ if e.IsDir() && isLooseObjectDir(e.Name()) {
183183+ t.Errorf("found loose-object dir objects/%s; pack should have been kept whole", e.Name())
184184+ }
185185+ }
186186+}
187187+188188+// isLooseObjectDir reports whether name is a git loose-object fan-out directory
189189+// (two lowercase hex characters), distinguishing it from "pack" and "info".
190190+func isLooseObjectDir(name string) bool {
191191+ if len(name) != 2 {
192192+ return false
193193+ }
194194+ for _, c := range name {
195195+ if !strings.ContainsRune("0123456789abcdef", c) {
196196+ return false
197197+ }
198198+ }
199199+ return true
114200}
115201116202func runGit(t *testing.T, dir string, args ...string) string {
+10-9
cmd/objgitd/hooks.go
···8181// the repository's receive-pack hook for each updated branch once the push
8282// succeeds — synchronously, streaming hook output to the client over the
8383// sideband progress channel (rendered as "remote: " lines) before the response
8484-// stream is closed. rpStorer is what the service writes through (the git:// and
8585-// SSH paths hide the PackfileWriter capability via streamingStorer); readStorer
8686-// is the underlying storer used for ref snapshots and hook checkouts.
8787-func (d *daemon) receivePack(ctx context.Context, rpStorer, readStorer storage.Storer, repoPath string, r io.ReadCloser, w io.WriteCloser, req *transport.ReceivePackRequest) error {
8484+// stream is closed. st is both what the service writes through and the storer
8585+// used for ref snapshots and hook checkouts — all three transports now share the
8686+// same Scanner-bounded PackfileWriter path (see writePack), so no transport needs
8787+// a capability-hiding wrapper.
8888+func (d *daemon) receivePack(ctx context.Context, st storage.Storer, repoPath string, r io.ReadCloser, w io.WriteCloser, req *transport.ReceivePackRequest) error {
8889 if !d.allowHooks {
8989- return receivePackStreaming(ctx, rpStorer, r, w, req, nil)
9090+ return receivePackStreaming(ctx, st, r, w, req, nil)
9091 }
91929292- before, err := snapshotRefs(readStorer)
9393+ before, err := snapshotRefs(st)
9394 if err != nil {
9495 slog.Warn("hook: ref snapshot before push failed", "path", repoPath, "err", err)
9596 }
···99100 // progress is the sideband band-2 writer, or nil when the client did not
100101 // negotiate sideband (hooks then fall back to logging only).
101102 onUpdated := func(progress io.Writer) {
102102- after, err := snapshotRefs(readStorer)
103103+ after, err := snapshotRefs(st)
103104 if err != nil {
104105 slog.Error("hook: ref snapshot after push failed", "path", repoPath, "err", err)
105106 return
···108109 if len(updates) == 0 {
109110 return
110111 }
111111- d.runHooks(repoPath, "receive-pack", readStorer, updates, progress)
112112+ d.runHooks(repoPath, "receive-pack", st, updates, progress)
112113 }
113114114114- return receivePackStreaming(ctx, rpStorer, r, w, req, onUpdated)
115115+ return receivePackStreaming(ctx, st, r, w, req, onUpdated)
115116}
116117117118// runHooks executes the receive-pack hook once per non-deleted branch update,
···4545 s3CacheTTL = flag.Duration("s3-cache-ttl", 60*time.Second, "how long a cached S3 directory listing answers Stat/Open before a re-list; 0 disables the listing cache")
4646 s3CacheRefresh = flag.Duration("s3-cache-refresh", 30*time.Second, "interval at which the listing-cache warmer re-fills hot prefixes; 0 disables the warmer")
4747 s3CacheIdle = flag.Duration("s3-cache-idle", 10*time.Minute, "drop a prefix from the listing-cache warmer after this long without access")
4848- s3CacheSize = flag.Int64("s3-cache-size", 64<<20, "groupcache LRU budget in bytes for the listing cache")
49485050- groupcacheSelf = flag.String("groupcache-self", "", "this node's groupcache base URL (e.g. http://10.0.0.1:8000); empty runs the cache single-process")
5151- groupcachePeers = flag.String("groupcache-peers", "", "comma-separated groupcache peer base URLs (including self) for cross-process sharing")
5252- groupcacheBind = flag.String("groupcache-bind", "", "TCP address to serve the groupcache peer endpoint; empty disables peer sharing")
4949+ 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")
5050+ 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")
5351)
54525553func main() {
···8886 var cache *s3fs.ListingCache
8987 var fsOpts []s3fs.Option
9088 if *s3CacheTTL > 0 {
9191- var peers []string
9292- if *groupcachePeers != "" {
9393- peers = strings.Split(*groupcachePeers, ",")
8989+ // Non-nil (even empty) so an empty flag explicitly disables subtree
9090+ // caching rather than falling back to the {"refs/"} default.
9191+ recursive := []string{}
9292+ if *s3CacheRecursive != "" {
9393+ recursive = strings.Split(*s3CacheRecursive, ",")
9494 }
9595 cache = s3fs.NewListingCache(s3fs.CacheConfig{
9696- TTL: *s3CacheTTL,
9797- RefreshInterval: *s3CacheRefresh,
9898- IdleTTL: *s3CacheIdle,
9999- SizeBytes: *s3CacheSize,
100100- Self: *groupcacheSelf,
101101- Peers: peers,
9696+ TTL: *s3CacheTTL,
9797+ RefreshInterval: *s3CacheRefresh,
9898+ IdleTTL: *s3CacheIdle,
9999+ RecursivePrefixes: recursive,
100100+ MaxSubtreeKeys: *s3CacheMaxSubtree,
102101 }, client, *bucket, "/")
103102 fsOpts = append(fsOpts, s3fs.WithListingCache(cache))
104103 metrics.RegisterListingCache(func() metrics.ListingCacheStats {
105104 s := cache.Stats()
106105 return metrics.ListingCacheStats{
107107- Gets: s.Gets, CacheHits: s.CacheHits, Loads: s.Loads,
108108- LocalLoads: s.LocalLoads, PeerLoads: s.PeerLoads, LocalLoadErrs: s.LocalLoadErrs,
109109- MainBytes: s.MainBytes, MainItems: s.MainItems, MainEvictions: s.MainEvictions,
110110- HotBytes: s.HotBytes, HotItems: s.HotItems,
106106+ Hits: s.Hits, Misses: s.Misses,
107107+ ListingItems: s.ListingItems, SubtreeItems: s.SubtreeItems, HeadItems: s.HeadItems,
111108 }
112109 })
113110 }
···136133 "allow_hooks", *allowHooks,
137134 "s3_cache_ttl", *s3CacheTTL,
138135 "s3_cache_refresh", *s3CacheRefresh,
139139- "groupcache_self", *groupcacheSelf,
136136+ "s3_cache_recursive_prefixes", *s3CacheRecursive,
140137 )
141138142139 g, gCtx := errgroup.WithContext(ctx)
···146143 cache.RunWarmer(gCtx)
147144 return nil
148145 })
149149-150150- if *groupcacheBind != "" {
151151- handler := cache.PoolHandler()
152152- if handler == nil {
153153- slog.Error("-groupcache-bind set without -groupcache-self; peer sharing is disabled")
154154- os.Exit(1)
155155- }
156156- ln, err := net.Listen("tcp", *groupcacheBind)
157157- if err != nil {
158158- slog.Error("can't listen", "groupcache_bind", *groupcacheBind, "err", err)
159159- os.Exit(1)
160160- }
161161- srv := &http.Server{Handler: handler}
162162- g.Go(func() error {
163163- if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
164164- return err
165165- }
166166- return nil
167167- })
168168- g.Go(func() error {
169169- <-gCtx.Done()
170170- shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
171171- defer cancel()
172172- return srv.Shutdown(shutdownCtx)
173173- })
174174- }
175146 }
176147177148 if *metricsBind != "" {
+41-1
cmd/objgitd/receivepack.go
···66 "fmt"
77 "io"
8899+ "github.com/go-git/go-git/v6/config"
910 "github.com/go-git/go-git/v6/plumbing"
1111+ formatcfg "github.com/go-git/go-git/v6/plumbing/format/config"
1012 "github.com/go-git/go-git/v6/plumbing/format/packfile"
1113 "github.com/go-git/go-git/v6/plumbing/format/pktline"
1214 "github.com/go-git/go-git/v6/plumbing/protocol"
···115117 // Receive the packfile
116118 var unpackErr error
117119 if needPackfile {
118118- unpackErr = packfile.UpdateObjectStorage(st, rd)
120120+ unpackErr = writePack(st, rd)
119121 }
120122121123 // Done with the request, now close the reader
···180182 return firstErr
181183 }
182184 return closeWriter(w)
185185+}
186186+187187+// writePack stores the incoming packfile as a single packfile object via the
188188+// storer's PackfileWriter, delimiting the pack with a Scanner rather than waiting
189189+// for the reader to reach io.EOF. go-git's default PackfileWriter path
190190+// (WritePackfileToObjectStorage → io.CopyBufferPool until EOF) deadlocks on a
191191+// persistent git:// / SSH socket, where the client holds the connection open
192192+// awaiting report-status. The Scanner knows the pack's end from its own framing
193193+// (header object count + trailer checksum) and stops there, while a TeeReader
194194+// mirrors exactly those bytes into the PackfileWriter — so the whole pack lands as
195195+// one object on every transport. Falls back to UpdateObjectStorage (loose objects)
196196+// if the storer cannot write packs.
197197+func writePack(st storage.Storer, rd io.Reader) error {
198198+ pw, ok := st.(storer.PackfileWriter)
199199+ if !ok {
200200+ return packfile.UpdateObjectStorage(st, rd)
201201+ }
202202+203203+ var sopts []packfile.ScannerOption
204204+ if c, ok := st.(config.ConfigStorer); ok {
205205+ if cfg, err := c.Config(); err == nil && cfg.Extensions.ObjectFormat == formatcfg.SHA256 {
206206+ sopts = append(sopts, packfile.WithSHA256())
207207+ }
208208+ }
209209+210210+ w, err := pw.PackfileWriter()
211211+ if err != nil {
212212+ return err
213213+ }
214214+215215+ sc := packfile.NewScanner(io.TeeReader(rd, w), sopts...)
216216+ for sc.Scan() {
217217+ }
218218+ if err := sc.Error(); err != nil {
219219+ _ = w.Close()
220220+ return err
221221+ }
222222+ return w.Close()
183223}
184224185225// sidebandProgress writes to the sideband progress channel (band 2), which the
+3-3
cmd/objgitd/ssh.go
···219219 _ = s.Exit(1)
220220 return fmt.Errorf("opening %q for push: %w", repoPath, err)
221221 }
222222- // streamingStorer hides PackfileWriter (the io.CopyBuffer-until-EOF path
223223- // deadlocks on a live socket); d.receivePack runs push hooks afterward.
224224- if err := d.receivePack(ctx, streamingStorer{Storer: st}, st, repoPath, r, w, &transport.ReceivePackRequest{}); err != nil {
222222+ // d.receivePack stores the pack whole (Scanner-bounded PackfileWriter, see
223223+ // writePack) and runs push hooks afterward.
224224+ if err := d.receivePack(ctx, st, repoPath, r, w, &transport.ReceivePackRequest{}); err != nil {
225225 slog.Error("ssh receive-pack failed", "path", repoPath, "err", err)
226226 return err
227227 }
+5
cmd/objgitd/ssh_test.go
···243243 if !pushLanded && statErr == nil {
244244 t.Fatal("repository must not exist when push did not land")
245245 }
246246+ if pushLanded {
247247+ // SSH shares the Scanner-bounded PackfileWriter path: the push
248248+ // must land as a packfile, not loose objects.
249249+ assertPackedRepo(t, fs, "/test.git")
250250+ }
246251247252 dst := t.TempDir()
248253 out, err := gitWithEnv(dst, env, "clone", remote, "cloned")
+211
docs/plans/git-keep-pushed-packs-whole.md
···11+# Keep pushed packs whole on git:// and SSH
22+33+## Context
44+55+Pushing big repositories to `objgitd` is slow. The root cause is not the go-git
66+dotgit backend itself — it is that two of the three transports explode the
77+received packfile into **loose objects**, one S3 object per git object.
88+99+The git:// and SSH receive-pack paths wrap the storer in `streamingStorer{}`
1010+(`cmd/objgitd/git_protocol.go:43`) to **hide** the storer's `PackfileWriter`
1111+capability. That is a deliberate deadlock workaround: go-git's
1212+`WritePackfileToObjectStorage` copies the pack into the PackfileWriter with
1313+`io.CopyBufferPool`, which only returns on `io.EOF` — fine over HTTP (the request
1414+body has a real EOF) but a hang over a persistent git:// / SSH socket, where the
1515+client holds the connection open waiting for report-status. With PackfileWriter
1616+hidden, `packfile.UpdateObjectStorage` falls through to `Parser.Parse`, which
1717+knows the pack's end from its own framing and writes **loose objects**.
1818+1919+Each loose object then costs **two** S3 round-trips in `internal/s3fs`:
2020+2121+1. go-git's `ObjectWriter.save()` (`dotgit/writers.go:375`) does
2222+ `fs.Lstat(file)` before writing — a content-addressable dedup check — which
2323+ becomes an S3 **HeadObject** (`s3fs/basic.go:200`), plus a ListObjectsV2
2424+ directory probe on a miss.
2525+2. The `Rename` of the temp object then becomes the **PutObject**.
2626+2727+For a 100k-object push that is ~200k S3 round-trips. HTTP avoids all of this
2828+because it keeps the PackfileWriter path and stores the pack as a single object.
2929+3030+**Goal:** make git:// and SSH keep the pack whole too — like HTTP, like real
3131+git's "keep large pack" behavior — so a push is ~2 S3 PutObjects (pack + idx)
3232+plus a handful of ref writes, instead of N. This also removes the
3333+stat-before-every-write entirely, because there are no per-object writes.
3434+3535+## Prerequisite (Step 0): fix the closed-pack-handle panic in s3fs
3636+3737+This must land first — it is a latent crash that Approach B would otherwise newly
3838+expose on git:// and SSH (and which already crashes HTTP+hooks).
3939+4040+go-git's `FSObject.Reader()` (`packfile/fsobject.go:62`) reads packed objects via
4141+`ReadAt` and explicitly recovers from a closed pack descriptor: it probes with a
4242+1-byte `pack.ReadAt(...)` and, if the error matches `os.ErrClosed`, reopens the
4343+pack (`o.fs.Open(o.packPath)`). The billy contract is therefore that
4444+`File.ReadAt` on a closed file returns an `os.ErrClosed`-matching error.
4545+4646+s3fs violates this: `s3ReadFile.Close()` sets `f.reader = nil`
4747+(`internal/s3fs/file.go:150`), but `Read`/`ReadAt`/`Seek`
4848+(`file.go:128/133/138`) dereference `f.reader` unconditionally, so a post-close
4949+call **panics on a nil `*bytes.Reader`** instead of returning `os.ErrClosed`.
5050+After a push the object LRU caches the new commit as an `FSObject` over a pack
5151+handle that index-building later closes; the post-receive hook's `GetCommit` then
5252+calls `Reader()` → probe `ReadAt` on the closed handle → panic
5353+(`hooks.go:136` in the reported trace).
5454+5555+**Fix:** guard the three read methods of `s3ReadFile` to return `os.ErrClosed`
5656+when the file is closed, e.g.:
5757+5858+```go
5959+func (f *s3ReadFile) ReadAt(p []byte, off int64) (int, error) {
6060+ if f.closed || f.reader == nil {
6161+ return 0, os.ErrClosed
6262+ }
6363+ return f.reader.ReadAt(p, off)
6464+}
6565+```
6666+6767+Apply the same guard to `Read` and `Seek`. (`os` is already imported in
6868+`file.go`.) Return `os.ErrClosed` specifically — not the existing `ErrFileClosed`
6969+— unless `ErrFileClosed` is made to wrap it, because go-git keys its reopen on
7070+`errors.Is(err, os.ErrClosed)`.
7171+7272+**Caveat / follow-up (out of scope):** s3fs `Open` reads the _whole_ object into
7373+memory (`file.go:95`), so go-git's reopen-on-closed downloads the entire pack
7474+again per cache-resident `FSObject` read. Acceptable for hooks (a handful of
7575+reads), but a future optimization is to serve `ReadAt` via S3 `GetObject` with a
7676+`Range` header (true random access) and/or keep pack descriptors open, instead of
7777+buffering the full pack. Note it; don't build it here.
7878+7979+## Approach: drive PackfileWriter with a Scanner-bounded TeeReader
8080+8181+The deadlock only exists because `io.CopyBufferPool` waits for the reader's EOF.
8282+We don't need EOF: the packfile is self-delimiting (header carries the object
8383+count; a trailer checksum ends it). go-git's `packfile.Scanner`
8484+(`plumbing/format/packfile/scanner.go`) walks exactly those bytes and stops at
8585+the trailer — this is the same machinery `Parser.Parse` already uses successfully
8686+over the socket today, so it provably terminates without blocking (the client
8787+sends nothing after the pack, so the underlying socket read never waits past the
8888+trailer).
8989+9090+So we get the PackfileWriter's writer, tee the socket reader through it, and let
9191+the Scanner consume exactly the pack — `w.Close()` then builds the index and
9292+uploads one `.pack` + one `.idx`. This depends on framing, not EOF, so it works
9393+identically on HTTP, git://, and SSH. The `streamingStorer` workaround is no
9494+longer needed and is deleted.
9595+9696+### Changes
9797+9898+**1. `cmd/objgitd/receivepack.go` — add `writePack`, replace the unpack call.**
9999+100100+Replace the body at line 118 (`unpackErr = packfile.UpdateObjectStorage(st, rd)`)
101101+with a call to a new helper:
102102+103103+```go
104104+// writePack stores the incoming packfile as a single packfile object via the
105105+// storer's PackfileWriter, delimiting the pack with a Scanner rather than
106106+// waiting for the reader to reach io.EOF. go-git's default PackfileWriter path
107107+// (io.CopyBufferPool until EOF) deadlocks on a persistent git:// / SSH socket
108108+// where the client holds the connection open awaiting report-status; the
109109+// Scanner knows the pack's end from its own framing and stops there, while a
110110+// TeeReader mirrors exactly those bytes into the PackfileWriter. Falls back to
111111+// UpdateObjectStorage (loose objects) if the storer cannot write packs.
112112+func writePack(st storage.Storer, rd io.Reader) error {
113113+ pw, ok := st.(storer.PackfileWriter)
114114+ if !ok {
115115+ return packfile.UpdateObjectStorage(st, rd)
116116+ }
117117+118118+ var sopts []packfile.ScannerOption
119119+ if c, ok := st.(config.ConfigStorer); ok {
120120+ if cfg, err := c.Config(); err == nil &&
121121+ cfg.Extensions.ObjectFormat == formatcfg.SHA256 {
122122+ sopts = append(sopts, packfile.WithSHA256())
123123+ }
124124+ }
125125+126126+ w, err := pw.PackfileWriter()
127127+ if err != nil {
128128+ return err
129129+ }
130130+ sc := packfile.NewScanner(io.TeeReader(rd, w), sopts...)
131131+ for sc.Scan() {
132132+ }
133133+ if err := sc.Error(); err != nil {
134134+ _ = w.Close()
135135+ return err
136136+ }
137137+ return w.Close()
138138+}
139139+```
140140+141141+- Object-format detection mirrors `packfile.UpdateObjectStorage`
142142+ (`common.go:29`): read it from the `ConfigStorer`, default SHA-1; the only
143143+ scanner knob is `packfile.WithSHA256()` (`scanner_options.go:16`).
144144+- New imports: `config`, `formatcfg "…/plumbing/format/config"`,
145145+ and `storer` is already imported. (`packfile`, `storage`, `io` already present.)
146146+- Call site becomes `unpackErr = writePack(st, rd)`.
147147+148148+**2. `cmd/objgitd/git_protocol.go` — delete `streamingStorer`.**
149149+150150+Remove the type and its doc comment (lines 31–45). The `storage` import may
151151+become unused here — drop it if so.
152152+153153+**3. Collapse `receivePack`'s two storer params to one (`hooks.go` + call sites).**
154154+155155+`streamingStorer` was the _only_ reason `rpStorer` and `readStorer` differed; all
156156+callers will now pass the same `st` for both. Simplify
157157+`(*daemon).receivePack` (`hooks.go:87`) to a single `st storage.Storer`
158158+param (used for both the service write and the ref snapshots/hook checkout),
159159+update its doc comment, and update the three call sites:
160160+161161+- `git_protocol.go:186` — `d.receivePack(ctx, st, req.Pathname, …)`
162162+- `ssh.go:224` — `d.receivePack(ctx, st, repoPath, …)`; delete the now-stale
163163+ `streamingStorer` comment at `ssh.go:222–223`.
164164+- `http.go:145` — `d.receivePack(r.Context(), st, repoPath, …)`
165165+166166+**4. Update docs.** `CLAUDE.md` "Two subtle protocol points" #1 and the SSH
167167+section both describe the `streamingStorer` workaround and the loose-object
168168+trade-off — rewrite to describe the Scanner-bounded PackfileWriter that all three
169169+transports now share. (`docs/plans/` may also get a short note.)
170170+171171+### Edge cases / risk
172172+173173+- **No over-read / no hang:** the Scanner's internal `bufio` only issues a read
174174+ when its buffer drains, and a socket read returns the bytes currently present;
175175+ the client sends nothing after the trailer, so it never blocks past the pack.
176176+ This is the exact behavior the current `Parser.Parse` path relies on.
177177+- **Memory:** the pack is buffered in the s3fs in-memory `tempBuffer` before
178178+ upload (`internal/s3fs/tempfs.go`) — identical to today's HTTP path, so git://
179179+ / SSH now match HTTP's memory profile. Spilling large packs to disk / multipart
180180+ upload is a possible follow-up, out of scope here.
181181+- **Empty/delete-only pushes:** unchanged — `needPackfile`
182182+ (`receivepack.go:108`) already gates `writePack` to pushes that carry a pack.
183183+- **Delta bases in existing objects:** unaffected — reads still go through the
184184+ normal storer; we only changed how the incoming pack is _written_.
185185+186186+## Verification
187187+188188+1. `go build ./...`
189189+2. `go test ./...` — existing round-trip push/clone tests for all three
190190+ transports must stay green (`git_protocol_test.go`, `ssh_test.go`,
191191+ `http_test.go`; gated on `git`/`ssh` in PATH).
192192+3. **New assertion proving packs are kept** (tests back the daemon with
193193+ `memfs.New()`, so the stored layout is directly inspectable): after a git://
194194+ push and an SSH push, assert the repo contains `objects/pack/pack-*.pack`
195195+ (+`.idx`) and that **no** loose-object dirs (`objects/<2-hex>/`) exist. Add as
196196+ a table case alongside the existing receive-pack tests; reuse `seedRepo` /
197197+ `runGit` helpers from `git_protocol_test.go`.
198198+4. **Regression test for the Step 0 panic:** with `allowHooks: true` and a repo
199199+ whose pushed tree contains `.objgit/hooks/receive-pack`, push over **HTTP**
200200+ (today's crash) and over **git:// / SSH** (newly pack-backed after Approach B)
201201+ and assert the push succeeds and the hook runs — exercising
202202+ `GetCommit` → `FSObject.Reader()` → `ReadAt` on a cache-resident, closed pack
203203+ handle without panicking. A focused s3fs unit test also helps: open a file,
204204+ `Close()` it, then assert `ReadAt`/`Read`/`Seek` each return an
205205+ `errors.Is(_, os.ErrClosed)` error rather than panicking.
206206+5. **Manual, against a real bucket:** push a large repo over SSH
207207+ (`./objgitd -bucket $BUCKET -ssh-bind :2222 -allow-push`) and watch
208208+ `objgit_s3_requests_total{op="PutObject"}` / `{op="HeadObject"}` on `/metrics`
209209+ — PutObject should drop from O(objects) to ~2 (pack + idx) and HeadObject
210210+ should no longer scale with object count. Compare wall-clock against a
211211+ pre-change push of the same repo.
+168-131
docs/plans/s3fs-groupcache-listobjectsv2.md
···11-# Plan: groupcache-backed directory-listing cache for `internal/s3fs`
11+# Plan: directory-listing cache for `internal/s3fs`
22+33+> **Status note.** This started as a groupcache-backed, fleet-shareable cache
44+> (hence the filename). It shipped as a **process-local `sync.Map` cache** —
55+> groupcache was ripped out for simplicity. There is no cross-process sharing,
66+> no peer pool, and no window-encoded key; TTL is a plain per-entry expiry. The
77+> sections below are revised to match what shipped; ignore any lingering peer/
88+> window phrasing.
29310## Context
411···1724`Stat`/`Open` touching an un-cached folder lists that **parent folder** in full and
1825populates the cache, warming every sibling at once.
19262020-**Backend (user decision): `github.com/golang/groupcache`,** so the listing cache is
2121-shared across processes via groupcache's consistent-hash peer pool. groupcache is a
2222-fill-once, **immutable** cache: no `Set`, no `Delete`, no TTL. We adapt to that with two
2323-techniques baked into the cache **key**:
2727+**Backend: process-local `sync.Map`s** (one for folder listings, one for recursive
2828+subtrees, one for per-object heads). Each cached entry carries two pieces of bookkeeping:
24292525-- **Window-encoded TTL.** The key carries a time window `floor(now/TTL)`. When the
2626- window advances the key changes → groupcache miss → the getter re-lists from S3. Old
2727- keys age out by LRU. Staleness is bounded by one TTL window.
2828-- **Per-prefix local generation for precise in-process invalidation.** The key also
2929- carries a process-local counter per prefix, bumped on every local write under that
3030- prefix. A local write moves the key, so the next read in this process re-lists and
3131- sees its own write immediately — recovering read-after-write correctness that
3232- groupcache alone cannot give (no delete). The stale entry under the old key is simply
3333- never queried again by us.
3030+- **Per-entry TTL.** An entry stores `expires = now + TTL`; past it the entry is ignored
3131+ and re-listed. This bounds how long a write this process can't see stays hidden. (There
3232+ is no background warmer requirement for correctness; the warmer just keeps hot entries
3333+ fresh and sweeps expired ones, since a `sync.Map` has no LRU.)
3434+- **Per-prefix local generation for precise invalidation.** A process-local counter per
3535+ prefix is bumped on every local write under that prefix (and its ancestors — see
3636+ subtree caching). An entry whose stored generation no longer matches is ignored, so the
3737+ next read re-lists and sees the write immediately (read-after-write correctness).
3838+ Concurrent identical fills are coalesced with `golang.org/x/sync/singleflight`.
34393535-**Accepted limitation (explicit user choice):** cross-process and post-restart staleness
3636-is bounded by the TTL window — another process (or this one after a restart that resets
3737-the local generation) may read a just-written object as "not found" for up to one
3838-window. This is safe for git's _content_ (objects are immutable and content-addressed, so
3939-positive listings never go wrong), and the window bounds the negative-staleness risk.
4040-Operators tune it with `-s3-cache-ttl`; `-s3-cache-ttl 0` disables the cache and restores
4141-today's exact behavior.
4040+**Accepted limitation:** negative staleness is bounded by the TTL — a just-deleted-or-
4141+created object may read stale for up to one TTL. Safe for git's _content_ (objects are
4242+immutable and content-addressed, so positive listings never go wrong); the TTL bounds the
4343+negative-staleness risk. Operators tune it with `-s3-cache-ttl`; `-s3-cache-ttl 0`
4444+disables the cache and restores today's exact behavior.
42454343-**Defaults:** on by default, tunable; single-process unless peers are configured.
4646+**Defaults:** on by default, tunable; always single-process.
44474548## Correctness model
4649···4851 with a different `root`, so the `*ListingCache` is **shared by pointer** across a root
4952 fs and all chroot children, and prefixes are full-canonical (`fs3.key()`/`cleanPath` —
5053 root-joined, leading slash stripped). `chroot.go:21` must copy the cache pointer.
5151-- **groupcache key** = `prefix "\x00" window "\x00" localgen`, where
5252- `window = now.Unix() / int64(ttl.Seconds())` and `localgen = c.gen(prefix)`. The
5353- getter parses `prefix` back off the key (segment before the first `\x00`) and lists it.
5454-- **Population is via the getter only** (groupcache has no `Set`). Both `ReadDir` and the
5555- `Stat`/`Open` resolution route through `group.Get`, so each folder is listed once per
5656- (window, localgen) and the result feeds both. groupcache's singleflight dedupes
5757- concurrent identical `Get`s (one list even when a clone fans out across siblings), and
5858- the owning peer dedupes across the fleet.
5454+- **Cache key** is just the canonical prefix; the entry holds `{gen, expires}` alongside
5555+ its payload. A read hits only when `entry.gen == gen(prefix)` and `now < entry.expires`.
5656+- **Both `ReadDir` and `Stat`/`Open` route through `list()`**, so each folder is listed
5757+ once and the result feeds both; `singleflight` coalesces concurrent identical fills (one
5858+ list even when a clone fans out across siblings).
5959- **Listing payload** carries enough to serve both consumers without a second call:
6060 per child `{name, kind(file|dir), size, mtimeUnixNano}` (`CommonPrefixes`→dir with
6161 zero size/mtime, `Contents`→file; file wins on the pathological file+prefix collision,
6262- matching today's Head-first precedence). Encoded compactly (JSON is fine; listings are
6363- small).
6262+ matching today's Head-first precedence). Stored as a Go value in the `sync.Map` — no
6363+ serialization.
6464- **In-process writes bump the local generation** of the parent prefix at _write
6565 completion_, moving the key so the next read re-lists:
6666 - `s3WriteFile.Close` / `s3MultipartUploadFile.Close` — after the upload succeeds.
6767 - `Rename` (covers `TempFile`→final pack promotion), `Remove`, `MkdirAll`.
6868- **Positive `Stat`/`Open` of a file** is served from a second cache, the **head
6969- cache** (see [Head precache](#head-precache)), rather than a foreground `HeadObject`.
7070- Listings omit the user-metadata the unix-metadata feature needs, so head metadata is
7171- cached per object and **prefetched in the background** when a listing is filled. `Open`
7272- still issues `GetObject` for the body but skips its `HeadObject`. A `NoSuchKey` from a
7373- delete racing the listing maps to `NotExist`.
6969+ cache** (see [Head cache](#head-cache)), rather than a foreground `HeadObject`. The head
7070+ cache is **seeded straight from each listing** — `ListObjectsV2` already returns every
7171+ file's size and mtime — so a positive lookup costs no extra round-trip. Listings omit
7272+ the user-metadata the unix-metadata feature needs, so a caller that requires it (i.e.
7373+ unix-metadata enabled) treats a listing-seeded entry as a miss and fills via a real
7474+ `HeadObject`. `Open` still issues `GetObject` for the body but skips its `HeadObject`. A
7575+ `NoSuchKey` from a delete racing the listing maps to `NotExist`.
74767577## Changes
76787779### go.mod
78807979-- Add `github.com/golang/groupcache` (effectively dependency-free).
8181+- No new direct dependency. `golang.org/x/sync/singleflight` (already vendored for
8282+ `errgroup`) dedupes concurrent fills.
80838184### New file: `internal/s3fs/listingcache.go`
82858383-The cache wrapper, groupcache group, key logic, and background warmer.
8686+The three `sync.Map` caches, the TTL/generation key logic, and the background warmer.
84878588```go
8689type CacheConfig struct {
8790 TTL, RefreshInterval, IdleTTL time.Duration
8888- SizeBytes int64 // groupcache LRU budget
8989- Name string // group name (default "objgit-listings")
9090- DisableHeadPrefetch bool // zero value = background head prefetch on
9191- Self string // this node's groupcache URL ("" = single-process)
9292- Peers []string // peer URLs (incl. self) when sharing
9191+ DisableHeadPrefetch bool // zero value = seed heads from listings on
9292+ RecursivePrefixes []string // nil → {"refs/"}; empty → subtree caching off
9393+ MaxSubtreeKeys int // <=0 → 50000
9394}
94959596type childKind uint8 // kindFile / kindDir
9697type childEntry struct { Name string; Kind childKind; Size, Mtime int64 }
9798type headData struct { Size, Mtime int64; Meta map[string]string }
9999+type headCacheEntry struct { data headData; gen uint64; expires time.Time; hasMeta bool }
100100+type listingEntry struct { entries []childEntry; gen uint64; expires time.Time }
101101+type subtreeEntry struct { data subtreeData; gen uint64; expires time.Time }
9810299103type ListingCache struct {
100100- group *groupcache.Group // listings, keyed by prefix
101101- headGroup *groupcache.Group // HeadObject metadata, keyed by object key
102102- pool *groupcache.HTTPPool // nil in single-process mode
103103- ttl time.Duration
104104- cfg CacheConfig
105105- clock func() time.Time // overridable in tests
106106- prefetchSem chan struct{} // bounds background head precaches
107107- mu sync.Mutex
108108- gens map[string]uint64 // per-prefix local generation
109109- seen map[string]time.Time // prefixes accessed → driven by the warmer
104104+ ttl time.Duration
105105+ cfg CacheConfig
106106+ client s3Client
107107+ bucket string
108108+ separator string
109109+ roots []string // normalised RecursivePrefixes, longest first
110110+ clock func() time.Time // overridable in tests
111111+ listings sync.Map // prefix → listingEntry
112112+ subtrees sync.Map // root → subtreeEntry
113113+ heads sync.Map // object key → headCacheEntry
114114+ sf, headSF singleflight.Group // coalesce concurrent fills
115115+ hits, misses atomic.Int64 // for metrics
116116+ mu sync.Mutex
117117+ gens map[string]uint64 // per-prefix local generation
118118+ seen map[string]time.Time // prefixes accessed → driven by the warmer
110119}
111120```
112121113113-- `NewListingCache(cfg, client, bucket, separator) *ListingCache` — creates the
114114- groupcache `Group` (name `"objgit-listings"`, `cfg.SizeBytes`) with a `GetterFunc`
115115- closure over `client`/`bucket`/`separator` that parses the prefix from the key, runs a
116116- full paginated `listChildren`, and encodes the payload into the sink. When
117117- `cfg.Self != ""`, builds a `groupcache.NewHTTPPoolOpts(cfg.Self, …)` and `pool.Set(cfg.Peers…)`.
118118-- `list(ctx, prefix) ([]childEntry, error)` — record `seen[prefix]=now`; build the
119119- groupcache key; `group.Get(ctx, key, AllocatingByteSliceSink(&buf))`; decode. This is
120120- the one entry point both `ReadDir` and `Stat`/`Open` use.
121121-- `gen(prefix)` / `invalidate(prefix)` — `invalidate` bumps `gens[prefix]` and records
122122- `seen` so the warmer re-warms the new key (no groupcache delete needed).
123123-- `runWarmer(ctx)` / `RunWarmer(ctx)` — `time.NewTicker(RefreshInterval)`; each tick drop
124124- `seen` entries idle past `IdleTTL`, then `group.Get` the current key for each remaining
125125- prefix (pre-fills the new window before clients wait and smooths window-rollover
126126- herds). No-op when `RefreshInterval<=0`. Returns on `ctx.Done()` (errgroup idiom).
127127-- `PoolHandler() http.Handler` / `Stats()` accessors for `main` to serve peers and export
128128- metrics.
122122+- `NewListingCache(cfg, client, bucket, separator)` — applies defaults and normalises the
123123+ recursive roots; no groups, no pool.
124124+- `list(ctx, prefix) ([]childEntry, error)` — the one entry point both `ReadDir` and
125125+ `Stat`/`Open` use. Routes recursive prefixes to `subtree`; otherwise `listFolder`
126126+ (sync.Map lookup gated on gen+expiry, `fillFolder` via singleflight on a miss).
127127+- `gen(prefix)` / `invalidate(prefix)` — `invalidate` bumps the generation of `prefix`
128128+ **and every ancestor** and records `seen` (so the warmer refreshes them).
129129+- `RunWarmer(ctx)` — `time.NewTicker(RefreshInterval)`; each tick drops `seen` entries idle
130130+ past `IdleTTL`, re-fills the rest (routing recursive→subtree, deduped), then sweeps
131131+ expired entries from all three maps (no LRU, so the warmer bounds growth). No-op when
132132+ `RefreshInterval<=0`. Returns on `ctx.Done()`.
133133+- `Stats()` accessor exports hit/miss counters and resident item counts to metrics.
129134- `splitKey(key) (prefix, base)` — split on last `/`; no slash → `("", key)`.
130135131131-### Head precache
136136+### Head cache
137137+138138+The per-object head cache is a **`sync.Map`** of `headCacheEntry`, keyed by
139139+canonical object key — like the listing and subtree caches. An
140140+entry is a hit only while (a) unexpired (`expires`, one TTL past fill), (b) still tagged
141141+with its parent prefix's current local generation — so `invalidate`'s generation bump
142142+drops every cached head under the prefix without a map scan, mirroring listing
143143+invalidation — and (c) carrying user metadata when the caller needs it (`hasMeta`).
132144133133-A **second groupcache group** (`<name>-heads`, keyed by object key) caches each file's
134134-`HeadObject` result — `headData{size, mtimeUnixNano, meta}` — so positive `Stat`/`Open`
135135-never pay a foreground `HeadObject`. The head key reuses the window + the **parent
136136-prefix's** local generation, so a write under that prefix re-heads the object exactly as
137137-it re-lists the folder.
145145+- **Seeded from listings (`seedHeads`), not separately fetched.** When the listing getter
146146+ fills a folder, it stores a `headCacheEntry` for every file **directly from the
147147+ `ListObjectsV2` data** (size + mtime), with `hasMeta=false`. This warms the head cache
148148+ with **zero** extra `HeadObject` calls — the listing already paid for the data.
149149+ `CacheConfig.DisableHeadPrefetch` turns seeding off (zero value = on).
150150+- **`headInfo(ctx, key, needMeta) (*s3.HeadObjectOutput, error)`** serves a foreground
151151+ lookup. A warm hit costs no round-trip. `needMeta` reports whether the caller needs the
152152+ x-amz-meta-* user metadata (true iff unix-metadata is enabled): when true, a
153153+ listing-seeded (`hasMeta=false`) entry is treated as a miss and filled via one real
154154+ `HeadObject` (which stores `hasMeta=true`). `headSF` (a `singleflight.Group`) dedupes
155155+ concurrent fills for the same key.
156156+- The warmer also **sweeps expired head entries** each tick — the `sync.Map` has no LRU,
157157+ so the warmer is what bounds its growth to roughly the live working set.
158158+- `Stat`/`Open` of a present file call `headInfo` with `needMeta = fs3.unixMeta != nil`;
159159+ `newS3ReadFile` gains an optional precomputed `*s3.HeadObjectOutput` so it skips its own
160160+ `HeadObject` and only `GetObject`s the body.
138161139139-- **Background prefetch (`prefetchHeads`).** When the listing getter fills a folder, it
140140- launches a **detached goroutine per file** (`go headGroup.Get(context.Background(), …)`)
141141- to warm the head cache off the request's critical path; groupcache singleflight
142142- coalesces a background precache with any foreground head lookup for the same object. A
143143- semaphore (`maxPrefetchInFlight = 64`) bounds the in-flight precaches so a large folder
144144- can't spawn an unbounded goroutine/S3 storm — overflow files are fetched on demand
145145- (logged at debug). `CacheConfig.DisableHeadPrefetch` turns this off (zero value = on).
146146-- **`headInfo(ctx, key) (*s3.HeadObjectOutput, error)`** serves a foreground lookup from
147147- the head cache (a warm hit costs no round-trip; a miss fills via one `HeadObject`).
148148-- `Stat`/`Open` of a present file call `headInfo`; `newS3ReadFile` gains an optional
149149- precomputed `*s3.HeadObjectOutput` so it skips its own `HeadObject` and only `GetObject`s
150150- the body.
162162+### Subtree caching (`RecursivePrefixes`)
163163+164164+Caching one folder per `ListObjectsV2` is wasteful for bounded namespaces that callers
165165+walk folder-by-folder — `refs/` above all (`refs/`, `refs/heads/`, `refs/tags/`,
166166+`refs/remotes/…`, each its own delimited list). A **single delimiter-less
167167+`ListObjectsV2` over `refs/`** returns the whole subtree; every descendant folder's
168168+listing and every negative lookup beneath it is then synthesised in memory.
169169+170170+- **A second `sync.Map`** (`subtrees`, keyed by root) holds `subtreeData{ Objects
171171+ []subtreeObject; Truncated bool }` — the flat key+size+mtime set under a root — in a
172172+ `subtreeEntry{gen, expires}`, the same TTL/generation scheme as folder listings.
173173+- **`list(prefix)` routes**: if `recursiveRoot(prefix)` matches a configured root, serve
174174+ from `subtree(root)` via `synthesizeListing(objects, prefix)` (remainder-after-prefix
175175+ with a `/` ⇒ child dir, deduped; else child file). Otherwise the existing delimited
176176+ path. A complete subtree is authoritative for negative lookups (it has *all* keys under
177177+ the root); only `!Truncated` subtrees are trusted.
178178+- **Bounded.** `listSubtree` stops once it exceeds `MaxSubtreeKeys` (default 50000) and
179179+ reports `Truncated`; `list` then **falls back to the delimited per-folder listing**, so
180180+ an unbounded namespace can't blow up memory. The truncated marker is itself cached, so
181181+ the fallback costs one near-free subtree-cache hit plus the folder list.
182182+- **Invalidation walks ancestors.** `invalidate(prefix)` now bumps the generation of
183183+ `prefix` **and every parent up to `""`** (`ancestorPrefixes`), so a write to
184184+ `refs/tags/v1` moves the `refs/` subtree key (and the root listing's). Trade-off: a
185185+ broader blast radius — a write also re-lists the coarser folders above it on their next
186186+ read. Acceptable because writes are pushes and a re-list after a push is expected.
187187+- **Head seeding** extends to subtrees: a complete scan seeds every file's head
188188+ (`seedSubtreeHeads`), each tagged with its own parent prefix's generation. A truncated
189189+ scan seeds nothing (leaves heads to the fallback path).
190190+- **Warmer routing** mirrors `list`: seen prefixes under a recursive root warm that root's
191191+ subtree (deduped across siblings) rather than per-folder.
192192+- **Config**: `CacheConfig.RecursivePrefixes` (nil ⇒ `{"refs/"}`; explicit empty ⇒ off)
193193+ and `MaxSubtreeKeys`. `main.go` exposes `-s3-cache-recursive-prefixes` (default `refs/`,
194194+ empty disables) and `-s3-cache-max-subtree-keys` (default 50000).
151195152196### `internal/s3fs/filesystem.go`
153197···164208- Extract `listChildren(ctx, client, bucket, separator, prefix) ([]childEntry, error)` —
165209 the paginated `ListObjectsV2` loop, classifying `CommonPrefixes`→dir, `Contents`→file
166210 with size/mtime, dirs-then-files preserving S3 order. A free function so the getter
167167- (which holds only the raw client) can reuse it. Used by `ReadDir` (cache off) and both
168168- getters.
211211+ (which holds only the raw client) can reuse it. Used by `ReadDir` (cache off) and the
212212+ listing getter.
169213- `ReadDir`: when `cache != nil`, get the entries via `cache.list(ctx, prefix)` and
170214 build `[]fs.DirEntry` from them (rebuilding `newDirInfo`/`newFileInfo` from the payload);
171215 otherwise list directly as today.
···196240197241### `internal/metrics/metrics.go`
198242199199-- A Prometheus collector that reads `ListingCache.Stats()` (groupcache `group.Stats`:
200200- Gets, CacheHits, Loads, LocalLoads, PeerLoads, LoaderErrors; plus `CacheStats` bytes/
201201- items/evictions) and exports them as `objgit_s3_listing_cache_*` gauges/counters. No
202202- `repo` label. (groupcache fills are already counted as `ListObjectsV2` via `observeS3`.)
203203- `main` registers it only when the cache is enabled.
243243+- A Prometheus collector that reads `ListingCache.Stats()` (`{Hits, Misses, ListingItems,
244244+ SubtreeItems, HeadItems}`) and exports `objgit_s3_listing_cache_hits_total`,
245245+ `_misses_total`, and `_items{kind=listing|subtree|head}`. No `repo` label. (Cache fills
246246+ are already counted as `ListObjectsV2`/`HeadObject` via `observeS3`.) `main` registers it
247247+ only when the cache is enabled.
204248205249### `cmd/objgitd/main.go`
206250207207-- New flags (kebab-case + flagenv) in the `var (...)` block at `main.go:32`:
208208- - `-s3-cache-ttl` (`Duration`, default `60s`) — window size; `<=0` disables the cache.
251251+- Flags (kebab-case + flagenv):
252252+ - `-s3-cache-ttl` (`Duration`, default `60s`) — per-entry TTL; `<=0` disables the cache.
209253 - `-s3-cache-refresh` (`Duration`, default `30s`) — warmer interval; `<=0` disables the
210254 warmer (lazy fill still works).
211255 - `-s3-cache-idle` (`Duration`, default `10m`) — drop un-accessed prefixes from the warmer.
212212- - `-s3-cache-size` (`Int64`, default `64<<20`) — groupcache LRU budget.
213213- - `-groupcache-self` (`String`, default `""`) — this node's groupcache base URL; empty =
214214- single-process.
215215- - `-groupcache-peers` (`String`, default `""`) — comma-separated peer URLs.
216216- - `-groupcache-bind` (`String`, default `""`) — listen addr serving `PoolHandler()`.
256256+ - `-s3-cache-recursive-prefixes` (`String`, default `refs/`) — comma-separated subtree
257257+ roots; empty disables subtree caching.
258258+ - `-s3-cache-max-subtree-keys` (`Int`, default `50000`) — subtree scan cap.
217259- When `ttl > 0`: build `cache := s3fs.NewListingCache(cfg, client, *bucket, "/")`, pass
218218- `s3fs.WithListingCache(cache)` into `NewS3FS` (`main.go:78`), register the metrics
219219- collector, and in the errgroup:
220220- - if `-groupcache-bind != ""`, `net.Listen` + serve `cache.PoolHandler()` (plus the
221221- Serve/`Shutdown`-on-`gCtx.Done()` two-goroutine idiom used by the other listeners).
222222- - `g.Go(func() error { cache.RunWarmer(gCtx); return nil })`.
260260+ `s3fs.WithListingCache(cache)` into `NewS3FS`, register the metrics collector, and add
261261+ `g.Go(func() error { cache.RunWarmer(gCtx); return nil })` to the errgroup.
223262- Add the cache settings to the startup `slog.Info` line.
224263225264## Why this is acceptably safe
226265227266Within a single git operation the repeated negative lookups happen within milliseconds,
228228-so even a 60s window eliminates essentially all redundant `HeadObject`+probe pairs, and
229229-the first miss in a folder warms every sibling via one parent listing (deduped by
230230-groupcache singleflight, fleet-wide via the owning peer). Local writes bump the
231231-per-prefix generation, so a push reads its own objects immediately. git object _content_
232232-is immutable, so positive/`ReadDir` results are never wrong about what exists — only the
233233-_recency_ of newly-added entries is window-bounded. The residual risk is a cross-process
234234-(or post-restart) negative read of an object another writer just created, bounded by one
235235-TTL window — the limitation the user explicitly accepted; `-s3-cache-ttl 0` opts out
236236-entirely. Note: writes fragment the shared keyspace for written prefixes (each writer's
237237-`localgen` differs), so cross-process sharing is strongest on the read-only prefixes that
238238-dominate clone/fetch and weakest on actively-pushed prefixes — which is the desirable
239239-bias.
267267+so even a 60s TTL eliminates essentially all redundant `HeadObject`+probe pairs, and the
268268+first miss in a folder warms every sibling via one parent listing (deduped by
269269+singleflight). Local writes bump the per-prefix generation (and its ancestors'), so a
270270+push reads its own objects immediately. git object _content_ is immutable, so
271271+positive/`ReadDir` results are never wrong about what exists — only the _recency_ of
272272+newly-added entries is TTL-bounded. The residual risk is a negative read of an object
273273+another process just created (this cache is process-local), bounded by one TTL; `-s3-cache-ttl 0`
274274+opts out entirely.
240275241276## Verification
2422772432781. `go build ./...`; `go mod tidy`; `go test ./...`.
2442792. **Cache disabled = no behavior change:** the cache is only wired when `-s3-cache-ttl>0`;
245280 existing protocol tests (`go test ./cmd/objgitd/...`, needs `git` on PATH) must pass
246246- with the cache off and on (single-process, no peers).
281281+ with the cache off and on.
2472823. **New unit tests in `internal/s3fs`** (table-driven `tt`, counting-stub `storage.Client`):
248283 - **Populate-on-miss:** first `Stat` of an absent key in a never-listed folder issues
249284 exactly one `ListObjectsV2` (the parent) and **zero** `HeadObject`; a second absent
···251286 - `ReadDir` then `Stat`/`Open` of an absent sibling → zero S3; a dir child → zero S3.
252287 - **Local invalidation:** `Create`+`Close` / `Rename` / `Remove` / `MkdirAll` bump the
253288 generation so a following `Stat`/`ReadDir` re-lists and sees the change (read-after-write).
254254- - **Window TTL:** advancing time past `TTL` changes the key → re-list (inject a clock or
255255- a settable `now` in `ListingCache` for the test).
256256- - **Warmer:** `RunWarmer` re-`Get`s accessed prefixes and evicts idle ones past `IdleTTL`.
289289+ - **TTL expiry:** advancing time past `TTL` expires the entry → re-list (inject a clock
290290+ or a settable `now` in `ListingCache` for the test).
291291+ - **Warmer:** `RunWarmer` re-fills accessed prefixes and evicts idle ones past `IdleTTL`.
257292 - **Chroot sharing:** `ReadDir` on the root then `Stat` of an absent child through a
258293 chroot resolves from the same cached prefix (same canonical key).
259259- - **Head precache:** one listing fill warms every file's head in the background; once
260260- warm, `Stat` of a file does **zero** further `HeadObject`s (counting-stub tests
261261- disable prefetch for determinism; a dedicated test enables it and waits for warmup).
262262-4. **Two-process sharing (manual):** run two `objgitd` with `-groupcache-self`/`-peers`
263263- pointing at each other; clone through one and confirm via `objgit_s3_listing_cache_*`
264264- (PeerLoads > 0 on the non-owner) that listings are served cross-process.
265265-5. **End-to-end:** `./objgitd -bucket $BUCKET -allow-push`; clone a packed repo twice and
294294+ - **Head seeding:** one listing fill seeds every file's head from the `ListObjectsV2`
295295+ data with **zero** `HeadObject`s; `Stat` of a seeded file then does **zero** further
296296+ `HeadObject`s (counting-stub tests disable seeding for determinism; a dedicated test
297297+ enables it and asserts no heads are issued).
298298+ - **Subtree caching:** one read of any `refs/` folder scans the subtree once; other
299299+ `refs/` folders and negative lookups beneath them then do **zero** S3; a write to a
300300+ sibling `refs/` folder re-scans (ancestor invalidation) and is visible; a subtree
301301+ past `MaxSubtreeKeys` falls back to a delimited listing yet still returns correctly.
302302+4. **End-to-end:** `./objgitd -bucket $BUCKET -allow-push`; clone a packed repo twice and
266303 confirm `objgit_s3_requests_total{operation="HeadObject"}` grows far slower than with
267267- `-s3-cache-ttl 0`.
304304+ `-s3-cache-ttl 0`, and watch `objgit_s3_listing_cache_hits_total` climb.
···157157}
158158159159// ListingCacheStats is a flat snapshot of the s3fs directory-listing cache's
160160-// groupcache counters. It mirrors s3fs.CacheStats but is defined here so s3fs
161161-// stays free of any Prometheus import; main bridges the two.
160160+// counters. It mirrors s3fs.CacheStats but is defined here so s3fs stays free of
161161+// any Prometheus import; main bridges the two.
162162type ListingCacheStats struct {
163163- Gets, CacheHits, Loads, LocalLoads, PeerLoads, LocalLoadErrs int64
164164- MainBytes, MainItems, MainEvictions int64
165165- HotBytes, HotItems int64
163163+ Hits, Misses int64
164164+ ListingItems, SubtreeItems, HeadItems int64
166165}
167166168167// RegisterListingCache installs a Prometheus collector that reports the
169169-// directory-listing cache's groupcache counters under objgit_s3_listing_cache_*.
170170-// provider is polled at scrape time. Call once at startup when the cache is
171171-// enabled.
168168+// directory-listing cache's counters under objgit_s3_listing_cache_*. provider
169169+// is polled at scrape time. Call once at startup when the cache is enabled.
172170func RegisterListingCache(provider func() ListingCacheStats) {
173171 prometheus.MustRegister(&listingCacheCollector{provider: provider})
174172}
···178176}
179177180178var (
181181- lcGets = prometheus.NewDesc("objgit_s3_listing_cache_gets_total", "Listing-cache Get requests (incl. from peers).", nil, nil)
182182- lcHits = prometheus.NewDesc("objgit_s3_listing_cache_hits_total", "Listing-cache requests served from cache.", nil, nil)
183183- lcLoads = prometheus.NewDesc("objgit_s3_listing_cache_loads_total", "Listing-cache loads (gets minus hits).", nil, nil)
184184- lcLocal = prometheus.NewDesc("objgit_s3_listing_cache_local_loads_total", "Listing-cache loads served by listing S3 locally.", nil, nil)
185185- lcPeer = prometheus.NewDesc("objgit_s3_listing_cache_peer_loads_total", "Listing-cache loads served by a peer.", nil, nil)
186186- lcLoadErrs = prometheus.NewDesc("objgit_s3_listing_cache_local_load_errors_total", "Listing-cache local load errors.", nil, nil)
187187- lcBytes = prometheus.NewDesc("objgit_s3_listing_cache_bytes", "Listing-cache resident bytes by cache.", []string{"cache"}, nil)
188188- lcItems = prometheus.NewDesc("objgit_s3_listing_cache_items", "Listing-cache resident items by cache.", []string{"cache"}, nil)
189189- lcEvictions = prometheus.NewDesc("objgit_s3_listing_cache_evictions_total", "Listing-cache main-cache evictions.", nil, nil)
179179+ lcHits = prometheus.NewDesc("objgit_s3_listing_cache_hits_total", "Listing/subtree-cache lookups served from cache.", nil, nil)
180180+ lcMisses = prometheus.NewDesc("objgit_s3_listing_cache_misses_total", "Listing/subtree-cache lookups that fell through to S3.", nil, nil)
181181+ lcItems = prometheus.NewDesc("objgit_s3_listing_cache_items", "Resident cache entries by kind.", []string{"kind"}, nil)
190182)
191183192184func (c *listingCacheCollector) Describe(ch chan<- *prometheus.Desc) {
193193- ch <- lcGets
194185 ch <- lcHits
195195- ch <- lcLoads
196196- ch <- lcLocal
197197- ch <- lcPeer
198198- ch <- lcLoadErrs
199199- ch <- lcBytes
186186+ ch <- lcMisses
200187 ch <- lcItems
201201- ch <- lcEvictions
202188}
203189204190func (c *listingCacheCollector) Collect(ch chan<- prometheus.Metric) {
205191 s := c.provider()
206206- ch <- prometheus.MustNewConstMetric(lcGets, prometheus.CounterValue, float64(s.Gets))
207207- ch <- prometheus.MustNewConstMetric(lcHits, prometheus.CounterValue, float64(s.CacheHits))
208208- ch <- prometheus.MustNewConstMetric(lcLoads, prometheus.CounterValue, float64(s.Loads))
209209- ch <- prometheus.MustNewConstMetric(lcLocal, prometheus.CounterValue, float64(s.LocalLoads))
210210- ch <- prometheus.MustNewConstMetric(lcPeer, prometheus.CounterValue, float64(s.PeerLoads))
211211- ch <- prometheus.MustNewConstMetric(lcLoadErrs, prometheus.CounterValue, float64(s.LocalLoadErrs))
212212- ch <- prometheus.MustNewConstMetric(lcEvictions, prometheus.CounterValue, float64(s.MainEvictions))
213213- ch <- prometheus.MustNewConstMetric(lcBytes, prometheus.GaugeValue, float64(s.MainBytes), "main")
214214- ch <- prometheus.MustNewConstMetric(lcBytes, prometheus.GaugeValue, float64(s.HotBytes), "hot")
215215- ch <- prometheus.MustNewConstMetric(lcItems, prometheus.GaugeValue, float64(s.MainItems), "main")
216216- ch <- prometheus.MustNewConstMetric(lcItems, prometheus.GaugeValue, float64(s.HotItems), "hot")
192192+ ch <- prometheus.MustNewConstMetric(lcHits, prometheus.CounterValue, float64(s.Hits))
193193+ ch <- prometheus.MustNewConstMetric(lcMisses, prometheus.CounterValue, float64(s.Misses))
194194+ ch <- prometheus.MustNewConstMetric(lcItems, prometheus.GaugeValue, float64(s.ListingItems), "listing")
195195+ ch <- prometheus.MustNewConstMetric(lcItems, prometheus.GaugeValue, float64(s.SubtreeItems), "subtree")
196196+ ch <- prometheus.MustNewConstMetric(lcItems, prometheus.GaugeValue, float64(s.HeadItems), "head")
217197}
+4-4
internal/s3fs/basic.go
···76767777 // If the parent folder's listing is cached, resolve the open without a
7878 // negotiation round-trip: absent → not-exist, a sub-prefix → directory.
7979- // For a present file the head cache (prewarmed in the background) lets
7979+ // For a present file the head cache (seeded from the listing) lets
8080 // newS3ReadFile skip its HeadObject; only GetObject fetches the body.
8181 if info, found, known := fs3.resolve(context.TODO(), key); known {
8282 switch {
···8585 case info.Kind == kindDir:
8686 return newS3DirFile(key, fs3.bucket, fs3.client), nil
8787 default:
8888- if ho, err := fs3.cache.headInfo(context.TODO(), key); err == nil {
8888+ if ho, err := fs3.cache.headInfo(context.TODO(), key, fs3.unixMeta != nil); err == nil {
8989 return newS3ReadFile(fs3.client, fs3.bucket, key, filename, ho)
9090 } else if isNotFound(err) {
9191 return nil, &os.PathError{Op: "open", Path: filename, Err: fs.ErrNotExist}
···180180181181 // If the parent folder's listing is cached, answer without a foreground S3
182182 // round-trip: absent → not-exist, a sub-prefix → directory, a present file
183183- // → the head cache (prewarmed in the background from the listing).
183183+ // → the head cache (seeded from the listing's size/mtime).
184184 if info, found, known := fs3.resolve(ctx, key); known {
185185 if !found {
186186 return nil, &os.PathError{Op: "stat", Path: filename, Err: fs.ErrNotExist}
···188188 if info.Kind == kindDir {
189189 return newDirInfo(path.Base(key)), nil
190190 }
191191- if ho, err := fs3.cache.headInfo(ctx, key); err == nil {
191191+ if ho, err := fs3.cache.headInfo(ctx, key, fs3.unixMeta != nil); err == nil {
192192 return newFileInfoFromHead(path.Base(key), ho, fs3.unixMeta), nil
193193 } else if isNotFound(err) {
194194 return nil, &os.PathError{Op: "stat", Path: filename, Err: fs.ErrNotExist}
+38
internal/s3fs/dir.go
···7272 return append(dirs, files...), nil
7373}
74747575+// listSubtree lists every object under root with a single delimiter-less
7676+// (recursive) paginated ListObjectsV2, returning each object's full key plus
7777+// size/mtime. It stops once more than maxKeys objects have accumulated and
7878+// reports truncated=true; the caller then abandons the subtree and falls back to
7979+// delimited per-folder listing, so an unbounded namespace can't blow up memory.
8080+func listSubtree(ctx context.Context, client s3Client, bucket, root string, maxKeys int) (objs []subtreeObject, truncated bool, err error) {
8181+ var ct *string
8282+ for {
8383+ start := time.Now()
8484+ res, lerr := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
8585+ Bucket: &bucket,
8686+ Prefix: &root,
8787+ ContinuationToken: ct,
8888+ // No Delimiter: a recursive listing of the whole subtree.
8989+ })
9090+ observeS3("ListObjectsV2", start, lerr)
9191+ if lerr != nil {
9292+ return nil, false, lerr
9393+ }
9494+9595+ for _, o := range res.Contents {
9696+ objs = append(objs, subtreeObject{
9797+ Key: aws.ToString(o.Key),
9898+ Size: aws.ToInt64(o.Size),
9999+ Mtime: aws.ToTime(o.LastModified).UnixNano(),
100100+ })
101101+ }
102102+103103+ if maxKeys > 0 && len(objs) > maxKeys {
104104+ return objs, true, nil
105105+ }
106106+ if !aws.ToBool(res.IsTruncated) {
107107+ return objs, false, nil
108108+ }
109109+ ct = res.NextContinuationToken
110110+ }
111111+}
112112+75113// ReadDir reads the directory named by dirname and returns a list of
76114// directory entries. When a listing cache is attached the listing is served
77115// through it (and reused by later Stat/Open of siblings).
+14-1
internal/s3fs/file.go
···126126127127// Read implements io.Reader for billy.File
128128func (f *s3ReadFile) Read(p []byte) (n int, err error) {
129129+ if f.closed || f.reader == nil {
130130+ return 0, os.ErrClosed
131131+ }
129132 return f.reader.Read(p)
130133}
131134132132-// ReadAt implements io.ReaderAt for billy.File
135135+// ReadAt implements io.ReaderAt for billy.File. It returns os.ErrClosed once the
136136+// file is closed (rather than dereferencing the nil reader and panicking): go-git's
137137+// packfile.FSObject.Reader probes with a 1-byte ReadAt and reopens the pack when it
138138+// sees an os.ErrClosed-matching error, so a cache-resident object over a closed pack
139139+// handle recovers instead of crashing.
133140func (f *s3ReadFile) ReadAt(p []byte, off int64) (n int, err error) {
141141+ if f.closed || f.reader == nil {
142142+ return 0, os.ErrClosed
143143+ }
134144 return f.reader.ReadAt(p, off)
135145}
136146137147// Seek implements io.Seeker for billy.File
138148func (f *s3ReadFile) Seek(offset int64, whence int) (int64, error) {
149149+ if f.closed || f.reader == nil {
150150+ return 0, os.ErrClosed
151151+ }
139152 return f.reader.Seek(offset, whence)
140153}
141154
+34
internal/s3fs/file_test.go
···33import (
44 "bytes"
55 "errors"
66+ "os"
67 "testing"
78)
99+1010+// TestS3ReadFileClosed locks in the billy contract go-git's
1111+// packfile.FSObject.Reader depends on: once a read file is closed, Read/ReadAt/Seek
1212+// must return an os.ErrClosed-matching error rather than dereferencing the nil
1313+// reader and panicking. FSObject probes a packed object's handle with a 1-byte
1414+// ReadAt and reopens the pack when it sees os.ErrClosed; the previous nil-deref
1515+// panic crashed the server when a cache-resident object outlived its pack handle
1616+// (e.g. a post-receive hook reading the just-pushed commit).
1717+func TestS3ReadFileClosed(t *testing.T) {
1818+ newClosed := func() *s3ReadFile {
1919+ f := &s3ReadFile{name: "k", reader: bytes.NewReader([]byte("hello"))}
2020+ if err := f.Close(); err != nil {
2121+ t.Fatalf("Close: %v", err)
2222+ }
2323+ return f
2424+ }
2525+2626+ t.Run("ReadAt", func(t *testing.T) {
2727+ if _, err := newClosed().ReadAt(make([]byte, 1), 0); !errors.Is(err, os.ErrClosed) {
2828+ t.Fatalf("ReadAt after close: err = %v, want os.ErrClosed", err)
2929+ }
3030+ })
3131+ t.Run("Read", func(t *testing.T) {
3232+ if _, err := newClosed().Read(make([]byte, 1)); !errors.Is(err, os.ErrClosed) {
3333+ t.Fatalf("Read after close: err = %v, want os.ErrClosed", err)
3434+ }
3535+ })
3636+ t.Run("Seek", func(t *testing.T) {
3737+ if _, err := newClosed().Seek(0, 0); !errors.Is(err, os.ErrClosed) {
3838+ t.Fatalf("Seek after close: err = %v, want os.ErrClosed", err)
3939+ }
4040+ })
4141+}
842943// TestS3WriteFileWrite locks in two invariants on the write path that a prior
1044// stub silently broke: every Write must append its bytes to the buffer, and
+430-200
internal/s3fs/listingcache.go
···11// listingcache.go caches directory listings so that Stat/Open of a path whose
22// parent folder has been listed can be answered without an S3 round-trip: an
33// absent child returns "not found" for free, a sub-directory is recognised for
44-// free, and only a present file still issues a HeadObject/GetObject for its
55-// authoritative content/metadata.
44+// free, and a present file is served from the head cache — seeded with each
55+// file's size/mtime straight from the same ListObjectsV2 — so it too is free
66+// unless the caller needs the x-amz-meta-* user metadata a listing can't carry.
67//
77-// The store is github.com/golang/groupcache so the listing can be shared across
88-// a fleet via its consistent-hash peer pool. groupcache is fill-once and
99-// immutable (no Set, Delete, or TTL), so two facts are encoded into the cache
1010-// key instead:
88+// Everything is held in process-local sync.Maps with a conventional per-entry
99+// TTL. There is no cross-process sharing: it is a plain in-memory cache. Two
1010+// mechanisms keep it correct:
1111//
1212-// - a time window floor(now/TTL): when it advances the key changes, forcing a
1313-// re-list. This bounds staleness from writers this process cannot see.
1212+// - per-entry expiry (expires = now + TTL): a stale entry is ignored once its
1313+// TTL passes, bounding how long a write this process can't see stays hidden.
1414// - a per-prefix local generation, bumped on every local write under the
1515-// prefix: this moves the key so the next local read re-lists and sees the
1616-// write immediately (read-after-write correctness groupcache alone cannot
1717-// give). The stale entry under the old key is simply never queried again.
1515+// prefix (and its ancestors): an entry whose stored generation no longer
1616+// matches is ignored, so the next read re-lists and sees a local write
1717+// immediately (read-after-write correctness).
1818+//
1919+// Subtree caching (RecursivePrefixes). For namespaces that are bounded and
2020+// listed folder-by-folder by callers — refs/ above all — a delimited listing per
2121+// folder is wasteful: one delimiter-less ListObjectsV2 over refs/ returns the
2222+// whole subtree, from which every descendant folder's listing (and every
2323+// negative lookup beneath it) is synthesised in memory. So a prefix at or under a
2424+// recursive root is served from a single cached subtree scan instead of a listing
2525+// per folder. The trade-offs: a subtree write must invalidate every ancestor
2626+// (invalidate walks up), and a subtree larger than MaxSubtreeKeys is abandoned —
2727+// the cache records that and falls back to delimited per-folder listing.
1828package s3fs
19292030import (
2131 "context"
2222- "encoding/json"
2332 "errors"
2433 "log/slog"
2525- "net/http"
2626- "strconv"
3434+ "sort"
2735 "strings"
2836 "sync"
3737+ "sync/atomic"
2938 "time"
30393140 "github.com/aws/aws-sdk-go-v2/aws"
3241 "github.com/aws/aws-sdk-go-v2/service/s3"
3342 "github.com/aws/smithy-go"
3434- "github.com/golang/groupcache"
4343+ "golang.org/x/sync/singleflight"
3544)
36453737-// maxPrefetchInFlight bounds the background HeadObject precaches in flight at
3838-// once, so listing a large folder can't spawn an unbounded goroutine/S3 storm.
3939-// Overflow files are simply fetched on demand instead.
4040-const maxPrefetchInFlight = 64
4141-4246// childKind distinguishes the two things a listing can report under a prefix.
4347type childKind uint8
4448···4751 kindDir
4852)
49535050-// childEntry is one immediate child of a listed prefix. name is the child's
5151-// base name (no separator). size/mtime are populated for files only; dirs
5252-// (S3 common prefixes) carry zero values.
5454+// childEntry is one immediate child of a listed prefix. Name is the child's base
5555+// name (no separator). Size/Mtime are populated for files only; dirs (S3 common
5656+// prefixes) carry zero values.
5357type childEntry struct {
5454- Name string `json:"n"`
5555- Kind childKind `json:"k"`
5656- Size int64 `json:"s,omitempty"`
5757- Mtime int64 `json:"m,omitempty"` // unix nanoseconds
5858+ Name string
5959+ Kind childKind
6060+ Size int64
6161+ Mtime int64 // unix nanoseconds
5862}
59636060-// headData is the cached result of a HeadObject: everything the file metadata
6161-// helpers need. Meta carries the x-amz-meta-* user metadata for the
6262-// Unix-metadata feature (listings can't, which is why heads are cached too).
6464+// headData is the size/mtime/metadata an s3.HeadObjectOutput is rebuilt from.
6565+// Size and Mtime can be seeded straight from a ListObjectsV2 entry; Meta (the
6666+// x-amz-meta-* user metadata for the Unix-metadata feature) only comes from an
6767+// actual HeadObject, so a listing-seeded entry leaves it nil — see hasMeta.
6368type headData struct {
6464- Size int64 `json:"s"`
6565- Mtime int64 `json:"m"` // unix nanoseconds
6666- Meta map[string]string `json:"u,omitempty"`
6969+ Size int64 // bytes
7070+ Mtime int64 // unix nanoseconds
7171+ Meta map[string]string // x-amz-meta-* user metadata; nil when listing-seeded
6772}
68736969-// DefaultGroupName is the groupcache group every objgitd uses; it must match
7070-// across peers for cross-process sharing to route correctly.
7171-const DefaultGroupName = "objgit-listings"
7474+// headCacheEntry is one object's cached head metadata. A read is a hit only when
7575+// the entry is unexpired and its gen still matches the parent prefix's current
7676+// generation — so a local write under the prefix (which bumps that generation)
7777+// invalidates every cached head beneath it without a map scan. hasMeta is false
7878+// for entries seeded from a listing (size/mtime only); a caller that needs the
7979+// user metadata treats those as a miss and fills via a real HeadObject.
8080+type headCacheEntry struct {
8181+ data headData
8282+ gen uint64
8383+ expires time.Time
8484+ hasMeta bool
8585+}
8686+8787+// listingEntry is one folder's cached children, tagged with the prefix's
8888+// generation at fill time and an expiry. Same hit rule as headCacheEntry.
8989+type listingEntry struct {
9090+ entries []childEntry
9191+ gen uint64
9292+ expires time.Time
9393+}
9494+9595+// subtreeObject is one object in a recursive subtree scan: its full canonical
9696+// key plus the size/mtime ListObjectsV2 returns, enough to synthesise any
9797+// descendant folder's listing and to seed the head cache.
9898+type subtreeObject struct {
9999+ Key string
100100+ Size int64
101101+ Mtime int64 // unix nanoseconds
102102+}
103103+104104+// subtreeData is a recursive listing of a root prefix. When Truncated it hit
105105+// MaxSubtreeKeys and is unsafe for negative lookups, so callers fall back to
106106+// delimited per-folder listing for that root.
107107+type subtreeData struct {
108108+ Objects []subtreeObject
109109+ Truncated bool
110110+}
111111+112112+// subtreeEntry is a cached subtreeData, tagged with the root's generation and an
113113+// expiry.
114114+type subtreeEntry struct {
115115+ data subtreeData
116116+ gen uint64
117117+ expires time.Time
118118+}
7211973120// CacheConfig configures a ListingCache. TTL must be > 0 (callers gate the whole
74121// feature on it). The remaining fields have sane zero-value behaviour.
75122type CacheConfig struct {
7676- TTL time.Duration // key window; bounds cross-process staleness
123123+ TTL time.Duration // per-entry lifetime; bounds negative staleness
77124 RefreshInterval time.Duration // warmer tick; <=0 disables the warmer
78125 IdleTTL time.Duration // drop un-accessed prefixes from the warmer
7979- SizeBytes int64 // groupcache LRU budget (<=0 → 64 MiB)
8080- Name string // groupcache group name (default DefaultGroupName)
811268282- // DisableHeadPrefetch turns off the background HeadObject precache that
8383- // warms the head cache for every file in a listing. The zero value keeps
8484- // prefetching enabled.
127127+ // DisableHeadPrefetch turns off seeding the head cache from listings (the
128128+ // size/mtime of every file in a folder, taken straight from ListObjectsV2).
129129+ // The zero value keeps seeding enabled.
85130 DisableHeadPrefetch bool
861318787- Self string // this node's groupcache base URL ("" → single-process)
8888- Peers []string // peer base URLs, including Self, when sharing
132132+ // RecursivePrefixes are namespaces served from one delimiter-less subtree
133133+ // scan rather than a delimited listing per folder (see the package comment).
134134+ // Each is normalised to end in "/". A nil slice defaults to {"refs/"}; an
135135+ // explicit empty non-nil slice disables subtree caching entirely.
136136+ RecursivePrefixes []string
137137+ // MaxSubtreeKeys caps a subtree scan: past it the subtree is abandoned and
138138+ // that root falls back to delimited per-folder listing (<=0 → 50000).
139139+ MaxSubtreeKeys int
89140}
901419191-// ListingCache wraps a groupcache group with the window/generation key scheme
9292-// and a background warmer. It is safe for concurrent use and is shared by
142142+// ListingCache is a process-local cache of directory listings, recursive
143143+// subtrees, and per-object head metadata, with per-entry TTL and per-prefix
144144+// generation invalidation. It is safe for concurrent use and is shared by
93145// pointer across an S3FS and all of its Chroot children.
94146type ListingCache struct {
9595- group *groupcache.Group
9696- headGroup *groupcache.Group // per-object HeadObject metadata
9797- pool *groupcache.HTTPPool // nil in single-process mode
98147 ttl time.Duration
99148 cfg CacheConfig
149149+ client s3Client
150150+ bucket string
151151+ separator string
152152+ roots []string // normalised RecursivePrefixes, longest first
100153101101- clock func() time.Time // overridable in tests
102102- prefetchSem chan struct{} // bounds background head precaches
154154+ clock func() time.Time // overridable in tests
155155+156156+ // The three caches. Each value is the *Entry type above; reads honour the
157157+ // entry's generation and expiry. sf dedupes concurrent listing/subtree fills,
158158+ // headSF the head fills, so a fan-out of identical lookups lists S3 once.
159159+ listings sync.Map // prefix → listingEntry
160160+ subtrees sync.Map // root → subtreeEntry
161161+ heads sync.Map // object key → headCacheEntry
162162+ sf singleflight.Group
163163+ headSF singleflight.Group
164164+165165+ hits, misses atomic.Int64 // listing + subtree cache outcomes, for metrics
103166104167 mu sync.Mutex
105168 gens map[string]uint64 // per-prefix local generation
106169 seen map[string]time.Time // prefixes accessed → driven by the warmer
107170}
108171109109-// NewListingCache builds a cache backed by a groupcache group whose getter lists
110110-// the requested prefix from S3. client/bucket/separator are captured for the
111111-// getter; they are the root filesystem's, so cached prefixes are full-canonical
112112-// keys that every Chroot view agrees on.
172172+// NewListingCache builds a process-local cache. client/bucket/separator are the
173173+// root filesystem's, so cached prefixes are full-canonical keys that every
174174+// Chroot view agrees on.
113175func NewListingCache(cfg CacheConfig, client s3Client, bucket, separator string) *ListingCache {
114114- if cfg.Name == "" {
115115- cfg.Name = DefaultGroupName
176176+ if cfg.MaxSubtreeKeys <= 0 {
177177+ cfg.MaxSubtreeKeys = 50000
116178 }
117117- if cfg.SizeBytes <= 0 {
118118- cfg.SizeBytes = 64 << 20
179179+ if cfg.RecursivePrefixes == nil {
180180+ cfg.RecursivePrefixes = []string{"refs/"}
119181 }
120120- c := &ListingCache{
121121- ttl: cfg.TTL,
122122- cfg: cfg,
123123- clock: time.Now,
124124- prefetchSem: make(chan struct{}, maxPrefetchInFlight),
125125- gens: map[string]uint64{},
126126- seen: map[string]time.Time{},
182182+ return &ListingCache{
183183+ ttl: cfg.TTL,
184184+ cfg: cfg,
185185+ client: client,
186186+ bucket: bucket,
187187+ separator: separator,
188188+ roots: normalizeRoots(cfg.RecursivePrefixes),
189189+ clock: time.Now,
190190+ gens: map[string]uint64{},
191191+ seen: map[string]time.Time{},
127192 }
193193+}
128194129129- listGetter := groupcache.GetterFunc(func(ctx context.Context, key string, dest groupcache.Sink) error {
130130- prefix := keyPrefix(key)
131131- entries, err := listChildren(ctx, client, bucket, separator, prefix)
132132- if err != nil {
133133- return err
195195+// list returns the immediate children of prefix. prefix is a full-canonical key
196196+// prefix: "" for the bucket root, otherwise ending in the separator. A prefix at
197197+// or under a recursive root is synthesised from one cached subtree scan;
198198+// otherwise it is a delimited per-folder listing.
199199+func (c *ListingCache) list(ctx context.Context, prefix string) ([]childEntry, error) {
200200+ c.touch(prefix)
201201+202202+ if root, ok := c.recursiveRoot(prefix); ok {
203203+ st, err := c.subtree(ctx, root)
204204+ // A complete subtree answers the folder in memory. On error or a
205205+ // truncated (oversized) subtree, fall through to delimited listing.
206206+ if err == nil && !st.Truncated {
207207+ return synthesizeListing(st.Objects, prefix), nil
134208 }
135135- data, err := json.Marshal(entries)
136136- if err != nil {
137137- return err
209209+ }
210210+211211+ return c.listFolder(ctx, prefix)
212212+}
213213+214214+// listFolder serves a single delimited folder listing from the cache, filling it
215215+// from S3 on a miss (deduped via singleflight).
216216+func (c *ListingCache) listFolder(ctx context.Context, prefix string) ([]childEntry, error) {
217217+ gen := c.gen(prefix)
218218+ if v, ok := c.listings.Load(prefix); ok {
219219+ e := v.(listingEntry)
220220+ if e.gen == gen && c.clock().Before(e.expires) {
221221+ c.hits.Add(1)
222222+ return e.entries, nil
138223 }
139139- // Precache each file's HeadObject in the background, off this request's
140140- // critical path; groupcache singleflight coalesces a background precache
141141- // with any foreground head lookup for the same object.
142142- c.prefetchHeads(prefix, entries)
143143- return dest.SetBytes(data)
144144- })
145145- c.group = groupcache.NewGroup(cfg.Name, cfg.SizeBytes, listGetter)
224224+ }
225225+ c.misses.Add(1)
226226+ return c.fillFolder(ctx, prefix, gen)
227227+}
146228147147- headGetter := groupcache.GetterFunc(func(ctx context.Context, key string, dest groupcache.Sink) error {
148148- objKey := keyPrefix(key)
149149- start := time.Now()
150150- ho, err := client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &bucket, Key: &objKey})
151151- observeS3("HeadObject", start, err)
229229+// fillFolder lists prefix from S3, stores the result, and seeds the head cache.
230230+// Concurrent fills for the same prefix are coalesced; the warmer also calls it
231231+// to force a refresh.
232232+func (c *ListingCache) fillFolder(ctx context.Context, prefix string, gen uint64) ([]childEntry, error) {
233233+ v, err, _ := c.sf.Do("L\x00"+prefix, func() (any, error) {
234234+ entries, err := listChildren(ctx, c.client, c.bucket, c.separator, prefix)
152235 if err != nil {
153153- return err
236236+ return nil, err
154237 }
155155- data, err := json.Marshal(headData{
156156- Size: aws.ToInt64(ho.ContentLength),
157157- Mtime: aws.ToTime(ho.LastModified).UnixNano(),
158158- Meta: ho.Metadata,
159159- })
160160- if err != nil {
161161- return err
162162- }
163163- return dest.SetBytes(data)
238238+ c.listings.Store(prefix, listingEntry{entries: entries, gen: gen, expires: c.clock().Add(c.ttl)})
239239+ // ListObjectsV2 already carries every file's size and mtime, so warm the
240240+ // head cache from it without a single extra HeadObject.
241241+ c.seedHeads(prefix, entries)
242242+ return entries, nil
164243 })
165165- c.headGroup = groupcache.NewGroup(cfg.Name+"-heads", cfg.SizeBytes, headGetter)
244244+ if err != nil {
245245+ return nil, err
246246+ }
247247+ return v.([]childEntry), nil
248248+}
166249167167- if cfg.Self != "" {
168168- c.pool = groupcache.NewHTTPPoolOpts(cfg.Self, &groupcache.HTTPPoolOptions{})
169169- peers := cfg.Peers
170170- if len(peers) == 0 {
171171- peers = []string{cfg.Self}
250250+// subtree returns the recursive listing of root from the cache, filling it via
251251+// one delimiter-less ListObjectsV2 scan on a miss (deduped via singleflight).
252252+func (c *ListingCache) subtree(ctx context.Context, root string) (subtreeData, error) {
253253+ gen := c.gen(root)
254254+ if v, ok := c.subtrees.Load(root); ok {
255255+ e := v.(subtreeEntry)
256256+ if e.gen == gen && c.clock().Before(e.expires) {
257257+ c.hits.Add(1)
258258+ return e.data, nil
172259 }
173173- c.pool.Set(peers...)
174260 }
175175-176176- return c
261261+ c.misses.Add(1)
262262+ return c.fillSubtree(ctx, root, gen)
177263}
178264179179-// list returns the immediate children of prefix, served from groupcache (which
180180-// fills via S3 on a miss). prefix is a full-canonical key prefix: "" for the
181181-// bucket root, otherwise ending in the separator.
182182-func (c *ListingCache) list(ctx context.Context, prefix string) ([]childEntry, error) {
183183- c.touch(prefix)
184184-185185- var data []byte
186186- if err := c.group.Get(ctx, c.groupKey(prefix), groupcache.AllocatingByteSliceSink(&data)); err != nil {
187187- return nil, err
188188- }
189189- var entries []childEntry
190190- if err := json.Unmarshal(data, &entries); err != nil {
191191- return nil, err
265265+// fillSubtree scans root recursively, stores the result, and (when complete)
266266+// seeds the head cache for every file under it.
267267+func (c *ListingCache) fillSubtree(ctx context.Context, root string, gen uint64) (subtreeData, error) {
268268+ v, err, _ := c.sf.Do("S\x00"+root, func() (any, error) {
269269+ objs, truncated, err := listSubtree(ctx, c.client, c.bucket, root, c.cfg.MaxSubtreeKeys)
270270+ if err != nil {
271271+ return subtreeData{}, err
272272+ }
273273+ data := subtreeData{Objects: objs, Truncated: truncated}
274274+ c.subtrees.Store(root, subtreeEntry{data: data, gen: gen, expires: c.clock().Add(c.ttl)})
275275+ // A complete subtree seeds the head cache for every file under the root
276276+ // in one shot; a truncated one is untrustworthy, so leave heads to the
277277+ // per-folder fallback path.
278278+ if !truncated {
279279+ c.seedSubtreeHeads(objs)
280280+ }
281281+ return data, nil
282282+ })
283283+ if err != nil {
284284+ return subtreeData{}, err
192285 }
193193- return entries, nil
286286+ return v.(subtreeData), nil
194287}
195288196196-// groupKey encodes the prefix, the current time window, and the prefix's local
197197-// generation. The prefix is first so the getter can recover it.
198198-func (c *ListingCache) groupKey(prefix string) string {
199199- window := c.clock().UnixNano() / int64(c.ttl)
200200- return prefix + "\x00" + strconv.FormatInt(window, 10) + "\x00" + strconv.FormatUint(c.gen(prefix), 10)
289289+// recursiveRoot reports the longest configured recursive root at or above prefix.
290290+func (c *ListingCache) recursiveRoot(prefix string) (string, bool) {
291291+ for _, r := range c.roots { // longest first
292292+ if prefix == r || strings.HasPrefix(prefix, r) {
293293+ return r, true
294294+ }
295295+ }
296296+ return "", false
201297}
202298203203-// headKey is groupKey's analogue for the per-object head cache. It carries the
204204-// object key plus the same window and its parent prefix's generation, so a
205205-// write under that prefix (which bumps the generation) re-heads the object.
206206-func (c *ListingCache) headKey(objKey string) string {
207207- prefix, _ := splitKey(objKey)
208208- window := c.clock().UnixNano() / int64(c.ttl)
209209- return objKey + "\x00" + strconv.FormatInt(window, 10) + "\x00" + strconv.FormatUint(c.gen(prefix), 10)
299299+// synthesizeListing builds the immediate children of prefix from a subtree's
300300+// flat object set: a key whose remainder after prefix contains a separator is a
301301+// child directory (deduped), otherwise a child file. Order follows the objects'
302302+// (lexicographic) S3 order, dirs reported before files like listChildren.
303303+func synthesizeListing(objs []subtreeObject, prefix string) []childEntry {
304304+ seenDir := map[string]bool{}
305305+ var dirs, files []childEntry
306306+ for _, o := range objs {
307307+ if !strings.HasPrefix(o.Key, prefix) {
308308+ continue
309309+ }
310310+ rest := o.Key[len(prefix):]
311311+ if rest == "" {
312312+ continue // the prefix's own placeholder object
313313+ }
314314+ if i := strings.IndexByte(rest, '/'); i >= 0 {
315315+ name := rest[:i]
316316+ if name == "" || seenDir[name] {
317317+ continue
318318+ }
319319+ seenDir[name] = true
320320+ dirs = append(dirs, childEntry{Name: name, Kind: kindDir})
321321+ continue
322322+ }
323323+ files = append(files, childEntry{Name: rest, Kind: kindFile, Size: o.Size, Mtime: o.Mtime})
324324+ }
325325+ return append(dirs, files...)
210326}
211327212212-// keyPrefix recovers the object key / prefix a getter was asked for, stripping
213213-// the "\x00window\x00gen" suffix groupKey/headKey append.
214214-func keyPrefix(key string) string {
215215- if i := strings.IndexByte(key, 0); i >= 0 {
216216- return key[:i]
328328+// normalizeRoots cleans recursive-prefix config: drops blanks, ensures a
329329+// trailing separator, dedupes, and sorts longest-first so recursiveRoot returns
330330+// the most specific match.
331331+func normalizeRoots(prefixes []string) []string {
332332+ seen := map[string]bool{}
333333+ var out []string
334334+ for _, p := range prefixes {
335335+ if p == "" {
336336+ continue
337337+ }
338338+ if !strings.HasSuffix(p, "/") {
339339+ p += "/"
340340+ }
341341+ if seen[p] {
342342+ continue
343343+ }
344344+ seen[p] = true
345345+ out = append(out, p)
217346 }
218218- return key
347347+ sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
348348+ return out
219349}
220350221221-// headInfo returns the object's HeadObject metadata, served from the head cache
222222-// (prewarmed in the background from listings, or filled on demand here).
223223-func (c *ListingCache) headInfo(ctx context.Context, objKey string) (*s3.HeadObjectOutput, error) {
224224- var data []byte
225225- if err := c.headGroup.Get(ctx, c.headKey(objKey), groupcache.AllocatingByteSliceSink(&data)); err != nil {
226226- return nil, err
227227- }
228228- var d headData
229229- if err := json.Unmarshal(data, &d); err != nil {
351351+// headInfo returns the object's head metadata, served from the head cache
352352+// (seeded from listings, or filled on demand here). needMeta reports whether the
353353+// caller requires the x-amz-meta-* user metadata: when true a listing-seeded
354354+// entry (size/mtime only) is insufficient and a real HeadObject is issued.
355355+func (c *ListingCache) headInfo(ctx context.Context, objKey string, needMeta bool) (*s3.HeadObjectOutput, error) {
356356+ d, err := c.headLoad(ctx, objKey, needMeta)
357357+ if err != nil {
230358 return nil, err
231359 }
232360 return &s3.HeadObjectOutput{
···236364 }, nil
237365}
238366239239-// prefetchHeads warms the head cache for every file in a freshly-listed folder,
240240-// each via a detached background goroutine so the work never blocks the request
241241-// that triggered the listing. Beyond maxPrefetchInFlight concurrent precaches it
242242-// drops extras (logged), which are then fetched on demand.
243243-func (c *ListingCache) prefetchHeads(prefix string, entries []childEntry) {
367367+// headLoad returns objKey's head metadata from the head cache. An entry hits only
368368+// while unexpired, still tagged with its parent prefix's current generation (so
369369+// invalidate's bump drops every cached head under the prefix), and — when
370370+// needMeta — carrying user metadata. A miss fills via a single deduped HeadObject.
371371+func (c *ListingCache) headLoad(ctx context.Context, objKey string, needMeta bool) (headData, error) {
372372+ prefix, _ := splitKey(objKey)
373373+ gen := c.gen(prefix)
374374+ if v, ok := c.heads.Load(objKey); ok {
375375+ e := v.(headCacheEntry)
376376+ if e.gen == gen && c.clock().Before(e.expires) && (e.hasMeta || !needMeta) {
377377+ return e.data, nil
378378+ }
379379+ }
380380+381381+ // Miss: dedupe concurrent fills for the same key so only one HeadObject goes
382382+ // to S3.
383383+ v, err, _ := c.headSF.Do(objKey, func() (any, error) {
384384+ start := time.Now()
385385+ ho, err := c.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &c.bucket, Key: &objKey})
386386+ observeS3("HeadObject", start, err)
387387+ if err != nil {
388388+ return headData{}, err
389389+ }
390390+ d := headData{
391391+ Size: aws.ToInt64(ho.ContentLength),
392392+ Mtime: aws.ToTime(ho.LastModified).UnixNano(),
393393+ Meta: ho.Metadata,
394394+ }
395395+ c.heads.Store(objKey, headCacheEntry{data: d, gen: gen, expires: c.clock().Add(c.ttl), hasMeta: true})
396396+ return d, nil
397397+ })
398398+ if err != nil {
399399+ return headData{}, err
400400+ }
401401+ return v.(headData), nil
402402+}
403403+404404+// seedHeads warms the head cache for every file in a freshly-listed folder
405405+// directly from the listing's size/mtime — no HeadObject round-trips. The
406406+// entries are marked metadata-less (hasMeta=false), so a later read that needs
407407+// user metadata still triggers a real HeadObject; a read that doesn't (the
408408+// common case, and objgitd's only case) is served entirely from this seed.
409409+func (c *ListingCache) seedHeads(prefix string, entries []childEntry) {
244410 if c.cfg.DisableHeadPrefetch {
245411 return
246412 }
413413+ gen := c.gen(prefix)
414414+ exp := c.clock().Add(c.ttl)
247415 for _, e := range entries {
248416 if e.Kind != kindFile {
249417 continue
250418 }
251251- objKey := prefix + e.Name
252252- select {
253253- case c.prefetchSem <- struct{}{}:
254254- default:
255255- slog.Debug("head precache skipped: prefetch pipeline full", "key", objKey)
256256- continue
419419+ c.heads.Store(prefix+e.Name, headCacheEntry{
420420+ data: headData{Size: e.Size, Mtime: e.Mtime},
421421+ gen: gen,
422422+ expires: exp,
423423+ })
424424+ }
425425+}
426426+427427+// seedSubtreeHeads seeds the head cache for every file in a complete subtree
428428+// scan, the recursive analogue of seedHeads. Each entry is tagged with its own
429429+// parent prefix's generation so per-folder invalidation still applies.
430430+func (c *ListingCache) seedSubtreeHeads(objs []subtreeObject) {
431431+ if c.cfg.DisableHeadPrefetch {
432432+ return
433433+ }
434434+ exp := c.clock().Add(c.ttl)
435435+ for _, o := range objs {
436436+ prefix, base := splitKey(o.Key)
437437+ if base == "" {
438438+ continue // directory placeholder
257439 }
258258- go func(k string) {
259259- defer func() { <-c.prefetchSem }()
260260- var data []byte
261261- if err := c.headGroup.Get(context.Background(), c.headKey(k), groupcache.AllocatingByteSliceSink(&data)); err != nil {
262262- slog.Debug("head precache failed", "key", k, "err", err)
263263- }
264264- }(objKey)
440440+ c.heads.Store(o.Key, headCacheEntry{
441441+ data: headData{Size: o.Size, Mtime: o.Mtime},
442442+ gen: c.gen(prefix),
443443+ expires: exp,
444444+ })
265445 }
266446}
267447···283463 return c.gens[prefix]
284464}
285465286286-// invalidate bumps a prefix's local generation so the next read in this process
287287-// re-lists. groupcache cannot delete; moving the key is how we invalidate.
466466+// invalidate bumps the local generation of prefix and every ancestor up to the
467467+// bucket root, so the next read re-lists. Entries under the old generation are
468468+// simply ignored (and swept at expiry). The ancestor walk is what lets a subtree
469469+// entry (cached under a coarse root) notice a write to any descendant folder — at
470470+// the cost of a broader blast radius: a write also re-lists the coarser folders
471471+// above it on their next read.
288472func (c *ListingCache) invalidate(prefix string) {
289473 if c == nil {
290474 return
291475 }
476476+ now := c.clock()
292477 c.mu.Lock()
293293- c.gens[prefix]++
294294- c.seen[prefix] = c.clock()
478478+ for _, p := range ancestorPrefixes(prefix) {
479479+ c.gens[p]++
480480+ c.seen[p] = now
481481+ }
295482 c.mu.Unlock()
296483}
297484485485+// ancestorPrefixes returns prefix and every parent prefix up to and including
486486+// the bucket root (""). Each non-root element ends in "/".
487487+func ancestorPrefixes(prefix string) []string {
488488+ out := []string{prefix}
489489+ for prefix != "" {
490490+ p := strings.TrimSuffix(prefix, "/")
491491+ if i := strings.LastIndex(p, "/"); i >= 0 {
492492+ prefix = p[:i+1]
493493+ } else {
494494+ prefix = ""
495495+ }
496496+ out = append(out, prefix)
497497+ }
498498+ return out
499499+}
500500+298501func (c *ListingCache) touch(prefix string) {
299502 c.mu.Lock()
300503 c.seen[prefix] = c.clock()
301504 c.mu.Unlock()
302505}
303506304304-// RunWarmer pre-fills the current-window key for every recently-accessed prefix
305305-// on each tick, smoothing window-rollover misses, and evicts prefixes unused for
306306-// longer than IdleTTL. It returns when ctx is cancelled; it is a no-op when the
307307-// refresh interval is non-positive.
507507+// RunWarmer refreshes every recently-accessed prefix on each tick — re-listing
508508+// it so a hot entry never lapses into a client-visible miss — then sweeps expired
509509+// entries from all three caches (there is no LRU, so the warmer is what bounds
510510+// their growth). It evicts prefixes unused for longer than IdleTTL. It returns
511511+// when ctx is cancelled; it is a no-op when the refresh interval is non-positive.
308512func (c *ListingCache) RunWarmer(ctx context.Context) {
309513 if c == nil || c.cfg.RefreshInterval <= 0 {
310514 <-ctx.Done()
···335539 }
336540 c.mu.Unlock()
337541542542+ // Refresh via the same routing as the read path: prefixes under a recursive
543543+ // root refresh that root's subtree (deduped across siblings), the rest their
544544+ // delimited folder listing.
545545+ roots := map[string]bool{}
338546 for _, p := range prefixes {
339339- var data []byte
340340- if err := c.group.Get(ctx, c.groupKey(p), groupcache.AllocatingByteSliceSink(&data)); err != nil {
547547+ if root, ok := c.recursiveRoot(p); ok {
548548+ roots[root] = true
549549+ continue
550550+ }
551551+ if _, err := c.fillFolder(ctx, p, c.gen(p)); err != nil {
341552 slog.Debug("listing-cache warm failed", "prefix", p, "err", err)
342553 }
343554 }
555555+ for root := range roots {
556556+ if _, err := c.fillSubtree(ctx, root, c.gen(root)); err != nil {
557557+ slog.Debug("subtree-cache warm failed", "root", root, "err", err)
558558+ }
559559+ }
560560+561561+ c.sweepExpired(now)
344562}
345563346346-// PoolHandler returns the groupcache peer HTTP handler, or nil in single-process
347347-// mode. Serve it on the address passed as -groupcache-bind.
348348-func (c *ListingCache) PoolHandler() http.Handler {
349349- if c == nil || c.pool == nil {
350350- return nil
351351- }
352352- return c.pool
564564+// sweepExpired drops expired entries from all three caches. Stale-generation
565565+// entries that have not yet expired are left; they are never read (generation
566566+// mismatch) and age out here once their TTL passes.
567567+func (c *ListingCache) sweepExpired(now time.Time) {
568568+ c.listings.Range(func(k, v any) bool {
569569+ if !now.Before(v.(listingEntry).expires) {
570570+ c.listings.Delete(k)
571571+ }
572572+ return true
573573+ })
574574+ c.subtrees.Range(func(k, v any) bool {
575575+ if !now.Before(v.(subtreeEntry).expires) {
576576+ c.subtrees.Delete(k)
577577+ }
578578+ return true
579579+ })
580580+ c.heads.Range(func(k, v any) bool {
581581+ if !now.Before(v.(headCacheEntry).expires) {
582582+ c.heads.Delete(k)
583583+ }
584584+ return true
585585+ })
353586}
354587355355-// CacheStats is a flat snapshot of groupcache counters for export to metrics.
588588+// CacheStats is a snapshot of the cache's counters for export to metrics.
356589type CacheStats struct {
357357- Gets, CacheHits, Loads, LocalLoads, PeerLoads, LocalLoadErrs int64
358358- MainBytes, MainItems, MainEvictions int64
359359- HotBytes, HotItems int64
590590+ Hits, Misses int64
591591+ ListingItems, SubtreeItems, HeadItems int64
360592}
361593362362-// Stats snapshots the underlying groupcache counters.
594594+// Stats snapshots hit/miss counters and resident item counts.
363595func (c *ListingCache) Stats() CacheStats {
364364- if c == nil || c.group == nil {
596596+ if c == nil {
365597 return CacheStats{}
366598 }
367367- main := c.group.CacheStats(groupcache.MainCache)
368368- hot := c.group.CacheStats(groupcache.HotCache)
369599 return CacheStats{
370370- Gets: c.group.Stats.Gets.Get(),
371371- CacheHits: c.group.Stats.CacheHits.Get(),
372372- Loads: c.group.Stats.Loads.Get(),
373373- LocalLoads: c.group.Stats.LocalLoads.Get(),
374374- PeerLoads: c.group.Stats.PeerLoads.Get(),
375375- LocalLoadErrs: c.group.Stats.LocalLoadErrs.Get(),
376376- MainBytes: main.Bytes,
377377- MainItems: main.Items,
378378- MainEvictions: main.Evictions,
379379- HotBytes: hot.Bytes,
380380- HotItems: hot.Items,
600600+ Hits: c.hits.Load(),
601601+ Misses: c.misses.Load(),
602602+ ListingItems: syncMapLen(&c.listings),
603603+ SubtreeItems: syncMapLen(&c.subtrees),
604604+ HeadItems: syncMapLen(&c.heads),
381605 }
606606+}
607607+608608+func syncMapLen(m *sync.Map) int64 {
609609+ var n int64
610610+ m.Range(func(_, _ any) bool { n++; return true })
611611+ return n
382612}
383613384614// splitKey splits a canonical key into its parent prefix and base name. The
+132-28
internal/s3fs/listingcache_test.go
···44 "bytes"
55 "context"
66 "errors"
77- "fmt"
87 "io"
98 "io/fs"
109 "sort"
···132131 return &s3.CompleteMultipartUploadOutput{}, nil
133132}
134133135135-// gcSeq gives every test cache a unique groupcache group name; NewGroup panics
136136-// on a duplicate name.
137137-var gcSeq atomic.Int64
138138-139139-// newTestCache disables the background head precache so S3-call counts are
140140-// deterministic; TestListingCacheHeadPrefetch exercises it explicitly.
134134+// newTestCache disables listing-driven head seeding so S3-call counts are
135135+// deterministic; TestListingCacheHeadSeed exercises it explicitly.
141136func newTestCache(stub *stubClient, ttl time.Duration) *ListingCache {
142142- n := gcSeq.Add(1)
143143- return NewListingCache(CacheConfig{TTL: ttl, Name: fmt.Sprintf("test-%d", n), DisableHeadPrefetch: true}, stub, "bucket", "/")
137137+ return NewListingCache(CacheConfig{TTL: ttl, DisableHeadPrefetch: true}, stub, "bucket", "/")
144138}
145139146140func newCachedFS(t *testing.T, stub *stubClient, cache *ListingCache) billy.Filesystem {
···320314 }
321315}
322316323323-func TestListingCacheHeadPrefetch(t *testing.T) {
317317+func TestListingCacheHeadSeed(t *testing.T) {
324318 stub := newStub("objects/ab/f1", "objects/ab/f2")
325325- n := gcSeq.Add(1)
326326- cache := NewListingCache(CacheConfig{TTL: time.Hour, Name: fmt.Sprintf("test-%d", n)}, stub, "bucket", "/")
319319+ cache := NewListingCache(CacheConfig{TTL: time.Hour}, stub, "bucket", "/")
327320 fsys := newCachedFS(t, stub, cache)
328321329329- // A single listing fill (here triggered by an absent lookup) prefetches the
330330- // HeadObject for every file in the folder, in the background.
322322+ // A single listing fill (here triggered by an absent lookup) seeds the head
323323+ // cache for every file in the folder straight from the ListObjectsV2 data —
324324+ // no HeadObject round-trips at all.
331325 if _, err := fsys.Stat("objects/ab/nope"); !errors.Is(err, fs.ErrNotExist) {
332326 t.Fatalf("Stat: %v", err)
333327 }
328328+ if got := stub.heads.Load(); got != 0 {
329329+ t.Fatalf("seeding issued HeadObjects: got %d, want 0", got)
330330+ }
334331335335- // Wait for the two background HeadObjects to land.
336336- deadline := time.Now().Add(2 * time.Second)
337337- for stub.heads.Load() < 2 {
338338- if time.Now().After(deadline) {
339339- t.Fatalf("prefetch did not warm both heads: got %d", stub.heads.Load())
332332+ // Stat of a seeded file is served from the head cache: still no HeadObject,
333333+ // and it reports a file with the listed size.
334334+ fi, err := fsys.Stat("objects/ab/f1")
335335+ if err != nil {
336336+ t.Fatalf("Stat f1: %v", err)
337337+ }
338338+ if fi.IsDir() {
339339+ t.Fatalf("Stat f1: reported a directory")
340340+ }
341341+ if got := stub.heads.Load(); got != 0 {
342342+ t.Fatalf("seeded Stat did a HeadObject: got %d, want 0", got)
343343+ }
344344+}
345345+346346+func TestListingCacheSubtreeCollapsesFolders(t *testing.T) {
347347+ stub := newStub("refs/heads/main", "refs/heads/dev", "refs/tags/v1")
348348+ cache := newTestCache(stub, time.Hour) // default RecursivePrefixes = {"refs/"}
349349+ fsys := newCachedFS(t, stub, cache)
350350+351351+ // The first touch of any refs/ folder scans the whole refs/ subtree once.
352352+ entries, err := fsys.ReadDir("refs/heads")
353353+ if err != nil {
354354+ t.Fatalf("ReadDir refs/heads: %v", err)
355355+ }
356356+ if len(entries) != 2 {
357357+ t.Fatalf("refs/heads entries = %d, want 2", len(entries))
358358+ }
359359+ if stub.lists.Load() != 1 {
360360+ t.Fatalf("first refs read lists = %d, want 1", stub.lists.Load())
361361+ }
362362+363363+ // A different refs/ folder is served from that same subtree: no new S3.
364364+ l0 := stub.lists.Load()
365365+ tags, err := fsys.ReadDir("refs/tags")
366366+ if err != nil {
367367+ t.Fatalf("ReadDir refs/tags: %v", err)
368368+ }
369369+ if len(tags) != 1 {
370370+ t.Fatalf("refs/tags entries = %d, want 1", len(tags))
371371+ }
372372+ if stub.lists.Load() != l0 {
373373+ t.Fatalf("second refs folder re-listed: %d->%d", l0, stub.lists.Load())
374374+ }
375375+376376+ // refs/ itself synthesises its child directories from the subtree.
377377+ top, err := fsys.ReadDir("refs")
378378+ if err != nil {
379379+ t.Fatalf("ReadDir refs: %v", err)
380380+ }
381381+ if len(top) != 2 {
382382+ t.Fatalf("refs entries = %d, want 2 (heads, tags)", len(top))
383383+ }
384384+ for _, e := range top {
385385+ if !e.IsDir() {
386386+ t.Fatalf("refs child %q not a directory", e.Name())
340387 }
341341- time.Sleep(time.Millisecond)
342388 }
343343- if got := stub.heads.Load(); got != 2 {
344344- t.Fatalf("prefetch heads = %d, want 2", got)
389389+390390+ // And a negative lookup anywhere under refs/ is free.
391391+ if _, err := fsys.Stat("refs/heads/missing"); !errors.Is(err, fs.ErrNotExist) {
392392+ t.Fatalf("Stat missing: want ErrNotExist, got %v", err)
393393+ }
394394+ if stub.lists.Load() != l0 {
395395+ t.Fatalf("negative lookup did S3: %d->%d", l0, stub.lists.Load())
345396 }
397397+}
346398347347- // Stat of a prewarmed file is served from the head cache: no new HeadObject.
348348- h0 := stub.heads.Load()
349349- if _, err := fsys.Stat("objects/ab/f1"); err != nil {
350350- t.Fatalf("Stat f1: %v", err)
399399+func TestListingCacheSubtreeInvalidate(t *testing.T) {
400400+ stub := newStub("refs/heads/main")
401401+ cache := newTestCache(stub, time.Hour)
402402+ fsys := newCachedFS(t, stub, cache)
403403+404404+ // Warm the whole refs/ subtree by reading one folder.
405405+ if _, err := fsys.ReadDir("refs/heads"); err != nil {
406406+ t.Fatalf("ReadDir refs/heads: %v", err)
351407 }
352352- if stub.heads.Load() != h0 {
353353- t.Fatalf("prewarmed Stat did a HeadObject: %d->%d", h0, stub.heads.Load())
408408+ if stub.lists.Load() != 1 {
409409+ t.Fatalf("warm lists = %d, want 1", stub.lists.Load())
410410+ }
411411+412412+ // Write a ref into a *sibling* folder. Ancestor invalidation must move the
413413+ // refs/ subtree key so the next read re-scans and sees it.
414414+ f, err := fsys.Create("refs/tags/v1")
415415+ if err != nil {
416416+ t.Fatalf("Create: %v", err)
417417+ }
418418+ if _, err := f.Write([]byte("deadbeef")); err != nil {
419419+ t.Fatalf("Write: %v", err)
420420+ }
421421+ if err := f.Close(); err != nil {
422422+ t.Fatalf("Close: %v", err)
423423+ }
424424+425425+ tags, err := fsys.ReadDir("refs/tags")
426426+ if err != nil {
427427+ t.Fatalf("ReadDir refs/tags after write: %v", err)
428428+ }
429429+ if len(tags) != 1 {
430430+ t.Fatalf("refs/tags entries = %d, want 1", len(tags))
431431+ }
432432+ if stub.lists.Load() < 2 {
433433+ t.Fatalf("subtree not re-scanned after sibling write: lists = %d", stub.lists.Load())
434434+ }
435435+}
436436+437437+func TestListingCacheSubtreeTruncationFallback(t *testing.T) {
438438+ stub := newStub("refs/heads/a", "refs/heads/b", "refs/heads/c")
439439+ cache := NewListingCache(CacheConfig{
440440+ TTL: time.Hour,
441441+ DisableHeadPrefetch: true,
442442+ MaxSubtreeKeys: 2, // 3 refs exceed the cap → subtree abandoned
443443+ }, stub, "bucket", "/")
444444+ fsys := newCachedFS(t, stub, cache)
445445+446446+ // The oversized subtree scan is abandoned; the folder is still served
447447+ // correctly, by falling back to a delimited per-folder listing.
448448+ entries, err := fsys.ReadDir("refs/heads")
449449+ if err != nil {
450450+ t.Fatalf("ReadDir refs/heads: %v", err)
451451+ }
452452+ if len(entries) != 3 {
453453+ t.Fatalf("entries = %d, want 3", len(entries))
454454+ }
455455+ // One truncated subtree scan + one delimited fallback listing.
456456+ if stub.lists.Load() != 2 {
457457+ t.Fatalf("fallback lists = %d, want 2", stub.lists.Load())
354458 }
355459}
356460