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))
steadytao
left a comment
There was a problem hiding this comment.
The per-request worker limit is not a process-wide bound so concurrent browse requests can multiply filesystem operations without limit. Please resolve the filesystem concurrency contract and establish an aggregate bound before enabling parallel traversal by default. The chunk partitioning also underuses the requested workers for values such as 17 entries with concurrency 16, although that is secondary to the correctness and resource-bound issues.
| // browseTemplateContext. fileSystem is invoked concurrently from multiple | ||
| // goroutines (Stat, for symlink targets), so any fs.FS passed here must | ||
| // support concurrent use; the standard OS-backed filesystem does. | ||
| func (fsrv *FileServer) directoryListing(ctx context.Context, fileSystem fs.FS, parentModTime time.Time, entries []fs.DirEntry, canGoUp bool, root, urlPath string, repl *caddy.Replacer) *browseTemplateContext { |
There was a problem hiding this comment.
fs.FS does not require implementations or returned fs.DirEntry values to support concurrent use.
| // 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"` |
There was a problem hiding this comment.
Validation exists only in the Caddyfile adapter. Native JSON can provide a negative value which provisioning accepts and runtime silently treats as the default. Validate this at provisioning so every adapter observes the same contract.
| if rlFS, ok := fileSystem.(readLinkFS); ok { | ||
| symLinkTarget, err = rlFS.ReadLink(targetPath) | ||
| } else { | ||
| symLinkTarget, err = os.Readlink(targetPath) | ||
| } |
There was a problem hiding this comment.
This method is not reachable through Caddy’s filesystem wrapper because it exposes the underlying implementation as fs.FS. Forward fs.ReadLinkFS through the wrapper and use fs.ReadLink
|
Is this actually an issue? How big are the directories you're serving? Wat. I feel like if anything we should just set up pagination or something via query args, just list the first 1000 and paginate after that point or w/e. |
|
Hmm, true, lol |
Multiple thousand files, due to the lack of pagination, the rendering takes forever |
Before doing more implementation work here, I think we need to settle the product and resource-control design. The reported large-directory delay is credible, but a per-request worker count is not an aggregate bound: simultaneous browse requests can multiply filesystem operations without limit. Parallel traversal also still computes every entry before rendering, whereas pagination could bound both work and response size. My inclination is to pursue pagination/limiting first. If parallel metadata collection remains necessary, it should use an aggregate process- or filesystem-level bound (and probably be opt-in) rather than a default per-request pool. Could you describe the desired pagination behavior and whether the benchmarked workload still needs concurrency once only one page is statted? Please hold further changes until we agree on that contract; this PR can stay open as the concrete design discussion. |
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).