v0.27.0 0039612

bench_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package ermitegit_test
4
5import (
6	"context"
7	"fmt"
8	"io"
9	"io/fs"
10	"os"
11	"path/filepath"
12	"strconv"
13	"strings"
14	"testing"
15	"time"
16
17	"github.com/stretchr/testify/require"
18
19	"bnema.dev/ermite/internal/adapters/ermitegit"
20	"bnema.dev/ermite/internal/adapters/gitcli"
21	"bnema.dev/ermite/internal/domain"
22	"bnema.dev/ermite/internal/ports"
23	"bnema.dev/ermite/internal/readertest"
24)
25
26// benchTarget is what the benchmarks ask about: a repository and the names in
27// it, found with git.
28type benchTarget struct {
29	name       domain.RepoName
30	dir        string
31	branch     string // a branch name
32	head       string // the full id of HEAD
33	abbrev     string // an abbreviated commit id
34	refPath    string // "branch/dir/file"
35	treeDir    string // a directory with many entries
36	file       string // a text file of some KiB
37	big        string // the largest blob of HEAD
38	history    string // a path with a long history
39	deepSkip   int    // a page far down the history
40	commitsAll int
41}
42
43// newBenchTarget builds the fixture repository, or clones EnvBenchRepo when it
44// is set, and looks for the targets with git.
45func newBenchTarget(b *testing.B, g *gitcli.Git, data string) benchTarget {
46	b.Helper()
47	var t benchTarget
48	if os.Getenv(EnvBenchRepo) != "" {
49		r := cloneBench(b, g, data)
50		t.name, t.dir = r.name, r.dir
51	} else {
52		f := benchFixture(b, data)
53		t.name, t.dir = f.Name, f.Bare
54		f.Maintain()
55	}
56	ageFiles(b, t.dir)
57	git := func(args ...string) string { return gitOutB(b, t.dir, args...) }
58	t.branch = git("symbolic-ref", "--short", "HEAD")
59	head := git("rev-parse", "HEAD")
60	t.head, t.abbrev = head, head[:10]
61	t.commitsAll, _ = strconv.Atoi(git("rev-list", "--count", "HEAD"))
62	t.deepSkip = min(5000, t.commitsAll*2/3)
63
64	var bigSize int64
65	dirCount := map[string]int{}
66	var files []string
67	for _, l := range strings.Split(git("ls-tree", "-r", "-l", "HEAD"), "\n") {
68		meta, p, ok := strings.Cut(l, "\t")
69		f := strings.Fields(meta)
70		if !ok || len(f) != 4 || f[1] != "blob" {
71			continue
72		}
73		size, _ := strconv.ParseInt(f[3], 10, 64)
74		if size > bigSize {
75			bigSize, t.big = size, p
76		}
77		if i := strings.LastIndexByte(p, '/'); i >= 0 {
78			dirCount[p[:i]]++
79		}
80		if size >= 2<<10 && size <= 100<<10 && strings.Count(p, "/") <= 2 && !strings.ContainsAny(p, " *") {
81			files = append(files, p)
82		}
83	}
84	for d, n := range dirCount {
85		if n > dirCount[t.treeDir] || (n == dirCount[t.treeDir] && d < t.treeDir) {
86			t.treeDir = d
87		}
88	}
89	require.NotEmpty(b, files, "no text file to read")
90	t.file = files[len(files)/2]
91	t.history = t.file
92	t.refPath = t.branch + "/" + t.file
93	return t
94}
95
96// BenchmarkRepoReader compares the CLI reader and the ermite-git adapter,
97// method by method, on a generated repository or, when ERMITE_GIT_BENCH_REPO
98// is set, on a clone of that repository:
99//
100//	go test -run '^$' -bench RepoReader -benchmem ./internal/adapters/ermitegit
101//	ERMITE_GIT_BENCH_REPO=/path/to/repo/.git go test -run '^$' -bench RepoReader -benchmem ...
102func BenchmarkRepoReader(b *testing.B) {
103	a, g, data := newAdapter(b, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
104	t := newBenchTarget(b, g, data)
105	b.Logf("repository: %d commits, branch %s, dir %s, file %s, big %s, deep skip %d", t.commitsAll, t.branch, t.treeDir, t.file, t.big, t.deepSkip)
106	ctx := context.Background()
107	rev := func(s string) domain.Revision {
108		r, err := domain.ParseRevision(s)
109		require.NoError(b, err)
110		return r
111	}
112	path := func(s string) domain.TreePath {
113		p, err := domain.ParseTreePath(s)
114		require.NoError(b, err)
115		return p
116	}
117	drain := func(r ports.RepoReader, v domain.Revision, p domain.TreePath) error {
118		_, rc, err := r.OpenBlob(ctx, t.name, v, p)
119		if err != nil {
120			return err
121		}
122		_, err = io.Copy(io.Discard, rc)
123		_ = rc.Close()
124		return err
125	}
126	// ermite-git re-reads a commit-graph it loaded less than two seconds ago on
127	// every Refresh (see ageFiles). Open the repository and let that window
128	// pass, as a long-lived server would have.
129	require.NoError(b, drain(a, domain.Revision{}, path(t.file)))
130	time.Sleep(2100 * time.Millisecond)
131	type method struct {
132		name string
133		run  func(r ports.RepoReader) error
134	}
135	methods := []method{
136		{"ListRefs", func(r ports.RepoReader) (err error) { _, err = r.ListRefs(ctx, t.name); return }},
137		{"ResolveRevision/branch", func(r ports.RepoReader) (err error) {
138			_, err = r.ResolveRevision(ctx, t.name, rev(t.branch))
139			return
140		}},
141		{"ResolveRevision/fullid", func(r ports.RepoReader) (err error) {
142			_, err = r.ResolveRevision(ctx, t.name, rev(t.head))
143			return
144		}},
145		{"ResolveRevision/abbrev", func(r ports.RepoReader) (err error) {
146			_, err = r.ResolveRevision(ctx, t.name, rev(t.abbrev))
147			return
148		}},
149		{"ResolveRefPath", func(r ports.RepoReader) (err error) { _, err = r.ResolveRefPath(ctx, t.name, t.refPath); return }},
150		{"GetTree/root", func(r ports.RepoReader) (err error) {
151			_, err = r.GetTree(ctx, t.name, domain.Revision{}, domain.TreePath{}, false)
152			return
153		}},
154		{"GetTree/dir", func(r ports.RepoReader) (err error) {
155			_, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), false)
156			return
157		}},
158		{"GetTree/root+last", func(r ports.RepoReader) (err error) {
159			_, err = r.GetTree(ctx, t.name, domain.Revision{}, domain.TreePath{}, true)
160			return
161		}},
162		{"GetTree/dir+last", func(r ports.RepoReader) (err error) {
163			_, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true)
164			return
165		}},
166		{"ListPaths", func(r ports.RepoReader) (err error) {
167			_, err = r.ListPaths(ctx, t.name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
168			return
169		}},
170		{"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }},
171		{"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }},
172		{"ListCommits/page1", func(r ports.RepoReader) (err error) {
173			_, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Limit: 30})
174			return
175		}},
176		{"ListCommits/deep", func(r ports.RepoReader) (err error) {
177			_, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Skip: t.deepSkip, Limit: 30})
178			return
179		}},
180		{"ListCommits/path", func(r ports.RepoReader) (err error) {
181			_, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Path: path(t.history), Limit: 30})
182			return
183		}},
184		{"ListCommits/dirpath", func(r ports.RepoReader) (err error) {
185			_, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Path: path(t.treeDir), Limit: 30})
186			return
187		}},
188	}
189	// Each method runs three ways, for both readers: warm (the repository is
190	// open, one caller), cold (the repository is closed before every call: the
191	// price of the first read after the idle TTL, an eviction or a restart) and
192	// parallel (b.RunParallel: the contention of the cache and of the library).
193	// The CLI reader keeps no repository open, so its cold run is its warm run.
194	for _, m := range methods {
195		b.Run(m.name, func(b *testing.B) {
196			for _, ad := range []struct {
197				name string
198				r    ports.RepoReader
199				cold func() // closes what the reader holds open
200			}{
201				{"cli", g, func() {}},
202				{"ermitegit", a, func() { a.Forget(t.dir) }},
203			} {
204				b.Run(ad.name, func(b *testing.B) {
205					require.NoError(b, m.run(ad.r)) // warm-up: processes, caches, graph
206					b.ReportAllocs()
207					b.ResetTimer()
208					for b.Loop() {
209						if err := m.run(ad.r); err != nil {
210							b.Fatal(err)
211						}
212					}
213				})
214				b.Run(ad.name+"_cold", func(b *testing.B) {
215					require.NoError(b, m.run(ad.r))
216					b.ReportAllocs()
217					b.ResetTimer()
218					for b.Loop() {
219						ad.cold()
220						if err := m.run(ad.r); err != nil {
221							b.Fatal(err)
222						}
223					}
224				})
225				b.Run(ad.name+"_parallel", func(b *testing.B) {
226					require.NoError(b, m.run(ad.r))
227					b.ReportAllocs()
228					b.ResetTimer()
229					b.RunParallel(func(pb *testing.PB) {
230						for pb.Next() {
231							if err := m.run(ad.r); err != nil {
232								b.Error(err)
233								return
234							}
235						}
236					})
237				})
238			}
239		})
240	}
241}
242
243// benchFixture builds the generated repository: a few hundred commits over a
244// tree of directories, merges, tags, and a 10 MiB blob.
245func benchFixture(b *testing.B, data string) *readertest.Fixture {
246	b.Helper()
247	f := readertest.NewFixture(b, data, "bench")
248	f.Git("config", "commitGraph.changedPathsVersion", "2")
249	f.Git("config", "core.compression", "1")
250	for i := range 200 {
251		for j := range 3 {
252			f.Write(fmt.Sprintf("pkg%d/file%02d.go", (i+j)%8, (i*3+j)%40), strings.Repeat(fmt.Sprintf("// version %d of this file\n", i), 160))
253		}
254		f.Write("README.md", fmt.Sprintf("# project\n\nrevision %d\n", i))
255		f.Commit(fmt.Sprintf("change %d\n\nbody of the message", i))
256		if i%25 == 24 {
257			f.CheckoutNew(fmt.Sprintf("topic%d", i))
258			f.Write(fmt.Sprintf("topics/topic%d.txt", i), "t\n")
259			f.Commit("topic work")
260			f.Checkout("main")
261			f.Write("pkg0/main-work.go", fmt.Sprintf("// main work %d\n", i))
262			f.Commit("main work")
263			f.Merge(fmt.Sprintf("topic%d", i))
264			f.Git("tag", "-a", "-m", "release", fmt.Sprintf("v%d", i/25))
265		}
266	}
267	f.WriteBytes("assets/big.bin", readertest.RandomBytes(10<<20, 5))
268	f.Commit("add a 10 MiB asset")
269	f.Git("gc", "--quiet")
270	return f
271}
272
273// ageFiles sets the modification time of every file of the repository to an
274// hour ago. ermite-git re-reads the files modified in the last two seconds (the
275// racy window of git's index rule): a repository that was just built would
276// measure that path. A repository that serves reads is not in that state, its
277// last push is older.
278func ageFiles(b *testing.B, dir string) {
279	b.Helper()
280	old := time.Now().Add(-time.Hour)
281	require.NoError(b, filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
282		if err != nil {
283			return err
284		}
285		return os.Chtimes(p, old, old)
286	}))
287}
288
289func gitOutB(b *testing.B, dir string, args ...string) string {
290	b.Helper()
291	return readertest.GitOut(b, dir, args...)
292}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close