fileserver: parallelize directory listing to speed up large-directory browsing - #7933
fileserver: parallelize directory listing to speed up large-directory browsing#7933firefart wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Reviewed by GitHub Copilot (agent).
This PR speeds up file_server browse directory listings for large directories by parallelizing per-entry filesystem metadata collection, while adding a configurable concurrency knob and improving symlink target resolution for custom filesystem implementations.
Changes:
- Parallelize the expensive per-entry
Info/Stat/Readlinkwork using a bounded worker pool, then aggregate results sequentially. - Add
browse { concurrency <n> }Caddyfile support and aBrowse.ConcurrencyJSON field with a conservative default. - Add a new directory-listing unit test and an integration Caddyfile-adapt test for the new config.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| modules/caddyhttp/fileserver/caddyfile.go | Adds Caddyfile parsing for browse { concurrency ... }. |
| modules/caddyhttp/fileserver/browsetplcontext.go | Refactors directoryListing() into filter/stat/aggregate passes and introduces parallel statting plus readLinkFS. |
| modules/caddyhttp/fileserver/browsetplcontext_test.go | Adds a unit test validating aggregated listing results across concurrency settings and failure cases. |
| modules/caddyhttp/fileserver/browse.go | Adds Browse.Concurrency config and default concurrency constant. |
| caddytest/integration/caddyfile_adapt/file_server_browse_concurrency.caddyfiletest | Verifies Caddyfile adaptation outputs the expected JSON for browse.concurrency. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (3)
modules/caddyhttp/fileserver/browsetplcontext.go:218
- Custom filesystem
ReadLinksupport is unreachable in real requests. Filesystems returned byfsrv.fsmap.Getareinternal/filesystems.wrapperFsvalues (map.go:44), and that wrapper embeds anfs.FS-typed field, so optional methods on the underlying implementation are not in the wrapper's method set. This assertion therefore fails and falls back toos.Readlink, recreating the behavior this change is intended to fix. Forward Go 1.25's standardfs.ReadLinkFScapability through the wrapper (and usefs.ReadLink) so registered modules can actually resolve their links.
if rlFS, ok := fileSystem.(readLinkFS); ok {
symLinkTarget, err = rlFS.ReadLink(targetPath)
} else {
symLinkTarget, err = os.Readlink(targetPath)
modules/caddyhttp/fileserver/browse.go:79
- The native JSON API can set
concurrencyto a negative value because the rejection exists only in the Caddyfile adapter. Provisioning then succeeds anddirectoryListingsilently treats the invalid value as the default, although this contract documents only zero as the default sentinel. ValidateBrowse.Concurrency < 0duringFileServer.Provisionso every config adapter enforces the same range.
// Concurrency sets how many directory entries are stat'd (and, for
// symlinks, have their target resolved) at once while building a
// listing. Gathering this per-entry info involves filesystem syscalls,
// so raising this can speed up listings of large directories; lowering
// it reduces the burst of concurrent filesystem calls a single listing
// request can generate. If 0 (default), a built-in default is used.
Concurrency int `json:"concurrency,omitempty"`
modules/caddyhttp/fileserver/browsetplcontext.go:104
- This chunk calculation can launch substantially fewer workers than requested. For example, with 17 visible entries and concurrency 16,
chunkSizeis 2, so only workers 0–8 have non-empty ranges and concurrency is limited to 9. Partitioning withlo := w * len(visible) / concurrencyandhi := (w+1) * len(visible) / concurrencykeeps all configured workers non-empty because concurrency is already capped to the entry count.
chunkSize := (len(visible) + concurrency - 1) / concurrency
var wg sync.WaitGroup
for w := 0; w < concurrency; w++ {
lo := w * chunkSize
hi := min(lo+chunkSize, len(visible))
Summary
file_server browselistings were slow for directories with many entries.directoryListing()calledentry.Info()for every entry sequentially - on Unix that's a realLstatsyscall per entry (Windows already gets this for free fromFindNextFile), plus a secondStat(and optionalReadlink) for every symlink. A directory with thousands of files paid thousands of sequential syscall round-trips.This splits the work into three passes and parallelizes the expensive one:
sync.WaitGroup.Go, Go 1.25+) each stat a contiguous chunk of the remaining entries. Each worker owns disjoint indices into a preallocated results slice, so there's no shared mutable state and no locking. Workers bail out onctx.Err()per item, matching the previous cancellation behavior.NumDirs/NumFiles/TotalFileSize/TotalFileSizeFollowingSymlinksand buildItemsfrom the results. Cheap, no syscalls.Final
Itemsorder doesn't matter here sinceapplySortAndLimitalways resorts before any output format (JSON/text/HTML) uses it.Worker count defaults to 16 (
browse.go'sdefaultDirListingConcurrency), deliberately conservative rather than tied toGOMAXPROCS: this is a syscall/IO-bound workload (Go already scales OS threads for blocked syscalls independent ofGOMAXPROCS), and the bound is per request, not global — nothing caps concurrent syscalls across simultaneous browse requests, so a high default would multiply badly under concurrent load. It's now also configurable per-site:(0/unset uses the built-in default.)
RevealSymlinks now also checks whether the fs.FS implements a new readLinkFS interface (ReadLink(name string) (string, error)) before falling back to os.Readlink, so custom filesystem modules can resolve symlinks their own way instead of assuming an OS path.
Benchmark
go test -bench=BenchmarkDirectoryListing -benchmem -run '^$' ./modules/caddyhttp/fileserver/, real OS-backed directories (existing BenchmarkDirectoryListing harness), before vs. after, ns/op:
Allocations rise modestly (e.g. ~56KB → ~80KB at 100 entries) from goroutine/closure overhead — an accepted tradeoff for the wall-clock win, and there's no regression even at small directory sizes since worker count is capped at min(concurrency, len(visible)).
Assistance Disclosure
I used Claude Code to design and implement this change (directory-listing parallelization, the Concurrency config option and its Caddyfile syntax, and the new test), working from my own investigation that large-directory listings were slow. I reviewed the design, benchmarked it, and made additional changes myself on top (the readLinkFS symlink-resolution interface, and the concurrencySet fix in the Caddyfile parser).