Skip to content

Commit 7a43b92

Browse files
committed
Fix slow server startup of very big content trees
As in content trees with 10 thousand of directories and more. A benchmark with the bottle neck code in `helpers.ExtractAndGroupRootPaths`: ``` │ cmp20251125.bench │ fix-extractandgrouproot-14211.bench │ │ sec/op │ sec/op vs base │ ExtractAndGroupRootPaths-10 1282818.8µ ± 8% 493.8µ ± 38% -99.96% (p=0.002 n=6) │ cmp20251125.bench │ fix-extractandgrouproot-14211.bench │ │ B/op │ B/op vs base │ ExtractAndGroupRootPaths-10 3343.8Ki ± 0% 146.3Ki ± 0% -95.63% (p=0.002 n=6) │ cmp20251125.bench │ fix-extractandgrouproot-14211.bench │ │ allocs/op │ allocs/op vs base │ ExtractAndGroupRootPaths-10 20.043k ± 0% 2.979k ± 0% -85.14% (p=0.002 n=6) ``` For test project that started this (a 60k directory conent tree), the server startup with no rendering, wen from 1.5 minutes to less than 4 seconds: ``` hugop server --renderSegments none main ✚ ✖ ✱ ◼ Watching for changes in /Users/bep/dev/sites/hugotestsites/60k/content/{section0,section1,section10,section100,section101,section102,section103,section104,section105,section106,...} Watching for changes in /Users/bep/dev/sites/hugotestsites/60k/layouts/_default Watching for config changes in /Users/bep/dev/sites/hugotestsites/60k/config.toml Start building sites … hugo v0.153.0-DEV-7e27c303904ed8b221d6a5a4fc9a764bb7b2935b darwin/arm64 BuildDate=2025-11-25T15:02:58Z │ EN ──────────────────┼──── Pages │ 0 Paginator pages │ 0 Non-page files │ 0 Static files │ 0 Processed images │ 0 Aliases │ 0 Cleaned │ 0 Built in 3884 ms Environment: "development" Serving pages from disk Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender Web Server is available at //localhost:1313/ (bind address 127.0.0.1) Press Ctrl+C to stop ``` Note that the output may be a little different and a little more verbose than before., but the information is correct and this implementation is significantly faster and simpler. Fixes #14211
1 parent 555dfa2 commit 7a43b92

File tree

4 files changed

+58
-124
lines changed

4 files changed

+58
-124
lines changed

‎commands/commandeer.go‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,7 @@ func (r *rootCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args
401401

402402
watchGroups := helpers.ExtractAndGroupRootPaths(watchDirs)
403403

404-
for _, group := range watchGroups {
405-
r.Printf("Watching for changes in %s\n", group)
406-
}
404+
r.Printf("Watching for changes in %s\n", strings.Join(watchGroups, ", "))
407405
watcher, err := b.newWatcher(r.poll, watchDirs...)
408406
if err != nil {
409407
return err

‎commands/server.go‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -492,9 +492,7 @@ func (c *serverCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, arg
492492

493493
watchGroups := helpers.ExtractAndGroupRootPaths(watchDirs)
494494

495-
for _, group := range watchGroups {
496-
c.r.Printf("Watching for changes in %s\n", group)
497-
}
495+
c.r.Printf("Watching for changes in %s\n", strings.Join(watchGroups, ", "))
498496
watcher, err := c.newWatcher(c.r.poll, watchDirs...)
499497
if err != nil {
500498
return err

‎helpers/path.go‎

Lines changed: 45 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import (
2121
"path"
2222
"path/filepath"
2323
"regexp"
24+
"slices"
2425
"sort"
2526
"strings"
2627

28+
"github.com/gohugoio/go-radix"
2729
"github.com/gohugoio/hugo/common/herrors"
28-
"github.com/gohugoio/hugo/common/hstrings"
2930
"github.com/gohugoio/hugo/common/text"
3031
"github.com/gohugoio/hugo/htesting"
3132

@@ -129,113 +130,63 @@ func (n NamedSlice) String() string {
129130
return fmt.Sprintf("%s%s{%s}", n.Name, FilePathSeparator, strings.Join(n.Slice, ","))
130131
}
131132

132-
func ExtractAndGroupRootPaths(paths []string) []NamedSlice {
133-
if len(paths) == 0 {
133+
// ExtractAndGroupRootPaths extracts and groups root paths from the supplied list of paths.
134+
// Note that the in slice will be sorted in place.
135+
func ExtractAndGroupRootPaths(in []string) []string {
136+
if len(in) == 0 {
134137
return nil
135138
}
136-
137-
pathsCopy := make([]string, len(paths))
138-
hadSlashPrefix := strings.HasPrefix(paths[0], FilePathSeparator)
139-
140-
for i, p := range paths {
141-
pathsCopy[i] = strings.Trim(filepath.ToSlash(p), "/")
142-
}
143-
144-
sort.Strings(pathsCopy)
145-
146-
pathsParts := make([][]string, len(pathsCopy))
147-
148-
for i, p := range pathsCopy {
149-
pathsParts[i] = strings.Split(p, "/")
150-
}
151-
152-
var groups [][]string
153-
154-
for i, p1 := range pathsParts {
155-
c1 := -1
156-
157-
for j, p2 := range pathsParts {
158-
if i == j {
159-
continue
139+
const maxGroups = 5
140+
sort.Strings(in)
141+
var groups []string
142+
tree := radix.New[[]string]()
143+
144+
LOOP:
145+
for _, s := range in {
146+
s = filepath.ToSlash(s)
147+
if ss, g, found := tree.LongestPrefix(s); found {
148+
if len(g) > maxGroups {
149+
continue LOOP
160150
}
161-
162-
c2 := -1
163-
164-
for i, v := range p1 {
165-
if i >= len(p2) {
166-
break
167-
}
168-
if v != p2[i] {
169-
break
170-
}
171-
172-
c2 = i
173-
}
174-
175-
if c1 == -1 || (c2 != -1 && c2 < c1) {
176-
c1 = c2
151+
parts := strings.Split(strings.TrimPrefix(strings.TrimPrefix(s, ss), "/"), "/")
152+
if len(parts) > 0 && parts[0] != "" && !slices.Contains(g, parts[0]) {
153+
g = append(g, parts[0])
154+
tree.Insert(ss, g)
177155
}
178-
}
179156

180-
if c1 != -1 {
181-
groups = append(groups, p1[:c1+1])
182157
} else {
183-
groups = append(groups, p1)
158+
tree.Insert(s, []string{})
184159
}
185160
}
186161

187-
groupsStr := make([]string, len(groups))
188-
for i, g := range groups {
189-
groupsStr[i] = strings.Join(g, "/")
190-
}
191-
192-
groupsStr = hstrings.UniqueStringsSorted(groupsStr)
193-
194-
var result []NamedSlice
195-
196-
for _, g := range groupsStr {
197-
name := filepath.FromSlash(g)
198-
if hadSlashPrefix {
199-
name = FilePathSeparator + name
162+
var collect radix.WalkFn[[]string] = func(s string, g []string) (radix.WalkFlag, []string, error) {
163+
if len(g) == 0 {
164+
groups = append(groups, s)
165+
return radix.WalkContinue, nil, nil
200166
}
201-
ns := NamedSlice{Name: name}
202-
for _, p := range pathsCopy {
203-
if !strings.HasPrefix(p, g) {
204-
continue
205-
}
206-
207-
p = strings.TrimPrefix(p, g)
208-
if p != "" {
209-
ns.Slice = append(ns.Slice, p)
210-
}
167+
if len(g) == 1 {
168+
groups = append(groups, path.Join(s, g[0]))
169+
return radix.WalkContinue, nil, nil
211170
}
212-
213-
ns.Slice = hstrings.UniqueStrings(ExtractRootPaths(ns.Slice))
214-
215-
result = append(result, ns)
171+
var sb strings.Builder
172+
sb.WriteString(s)
173+
// This is used to print "Watching for changes in /Users/bep/dev/sites/hugotestsites/60k/content/{section0,section1,section10..."
174+
// Having too many groups here is not helpful.
175+
if len(g) > maxGroups {
176+
// This will modify the slice in the tree, but that is OK since we are done with it.
177+
g = g[:maxGroups]
178+
g = append(g, "...")
179+
}
180+
sb.WriteString("/{")
181+
sb.WriteString(strings.Join(g, ","))
182+
sb.WriteString("}")
183+
groups = append(groups, sb.String())
184+
return radix.WalkContinue, nil, nil
216185
}
217186

218-
return result
219-
}
187+
tree.Walk(collect)
220188

221-
// ExtractRootPaths extracts the root paths from the supplied list of paths.
222-
// The resulting root path will not contain any file separators, but there
223-
// may be duplicates.
224-
// So "/content/section/" becomes "content"
225-
func ExtractRootPaths(paths []string) []string {
226-
r := make([]string, len(paths))
227-
for i, p := range paths {
228-
root := filepath.ToSlash(p)
229-
sections := strings.SplitSeq(root, "/")
230-
for section := range sections {
231-
if section != "" {
232-
root = section
233-
break
234-
}
235-
}
236-
r[i] = root
237-
}
238-
return r
189+
return groups
239190
}
240191

241192
// FindCWD returns the current working directory from where the Hugo

‎helpers/path_test.go‎

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"fmt"
1818
"os"
1919
"path/filepath"
20-
"reflect"
2120
"runtime"
2221
"strconv"
2322
"strings"
@@ -355,36 +354,24 @@ func TestExtractAndGroupRootPaths(t *testing.T) {
355354
filepath.FromSlash("/c/d/e"),
356355
}
357356

358-
inCopy := make([]string, len(in))
359-
copy(inCopy, in)
360-
361357
result := helpers.ExtractAndGroupRootPaths(in)
362358

363359
c := qt.New(t)
364-
c.Assert(fmt.Sprint(result), qt.Equals, filepath.FromSlash("[/a/b/{c,e} /c/d/e]"))
365-
366-
// Make sure the original is preserved
367-
c.Assert(in, qt.DeepEquals, inCopy)
360+
c.Assert(result, qt.DeepEquals, []string{"/a/b/{c,e}", "/c/d/e"})
368361
}
369362

370-
func TestExtractRootPaths(t *testing.T) {
371-
tests := []struct {
372-
input []string
373-
expected []string
374-
}{{
375-
[]string{
376-
filepath.FromSlash("a/b"), filepath.FromSlash("a/b/c/"), "b",
377-
filepath.FromSlash("/c/d"), filepath.FromSlash("d/"), filepath.FromSlash("//e//"),
378-
},
379-
[]string{"a", "a", "b", "c", "d", "e"},
380-
}}
381-
382-
for _, test := range tests {
383-
output := helpers.ExtractRootPaths(test.input)
384-
if !reflect.DeepEqual(output, test.expected) {
385-
t.Errorf("Expected %#v, got %#v\n", test.expected, output)
363+
func BenchmarkExtractAndGroupRootPaths(b *testing.B) {
364+
in := []string{}
365+
for i := 0; i < 10; i++ {
366+
for j := 0; j < 1000; j++ {
367+
in = append(in, fmt.Sprintf("/a/b/c/s%d/p%d", i, j))
386368
}
387369
}
370+
371+
b.ResetTimer()
372+
for i := 0; i < b.N; i++ {
373+
helpers.ExtractAndGroupRootPaths(in)
374+
}
388375
}
389376

390377
func TestFindCWD(t *testing.T) {

0 commit comments

Comments
 (0)