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 {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }},
167 {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }},
168 {"ListCommits/page1", func(r ports.RepoReader) (err error) {
169 _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Limit: 30})
170 return
171 }},
172 {"ListCommits/deep", func(r ports.RepoReader) (err error) {
173 _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Skip: t.deepSkip, Limit: 30})
174 return
175 }},
176 {"ListCommits/path", func(r ports.RepoReader) (err error) {
177 _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Path: path(t.history), Limit: 30})
178 return
179 }},
180 {"ListCommits/dirpath", func(r ports.RepoReader) (err error) {
181 _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Path: path(t.treeDir), Limit: 30})
182 return
183 }},
184 }
185 // Each method runs three ways, for both readers: warm (the repository is
186 // open, one caller), cold (the repository is closed before every call: the
187 // price of the first read after the idle TTL, an eviction or a restart) and
188 // parallel (b.RunParallel: the contention of the cache and of the library).
189 // The CLI reader keeps no repository open, so its cold run is its warm run.
190 for _, m := range methods {
191 b.Run(m.name, func(b *testing.B) {
192 for _, ad := range []struct {
193 name string
194 r ports.RepoReader
195 cold func() // closes what the reader holds open
196 }{
197 {"cli", g, func() {}},
198 {"ermitegit", a, func() { a.Forget(t.dir) }},
199 } {
200 b.Run(ad.name, func(b *testing.B) {
201 require.NoError(b, m.run(ad.r)) // warm-up: processes, caches, graph
202 b.ReportAllocs()
203 b.ResetTimer()
204 for b.Loop() {
205 if err := m.run(ad.r); err != nil {
206 b.Fatal(err)
207 }
208 }
209 })
210 b.Run(ad.name+"_cold", func(b *testing.B) {
211 require.NoError(b, m.run(ad.r))
212 b.ReportAllocs()
213 b.ResetTimer()
214 for b.Loop() {
215 ad.cold()
216 if err := m.run(ad.r); err != nil {
217 b.Fatal(err)
218 }
219 }
220 })
221 b.Run(ad.name+"_parallel", func(b *testing.B) {
222 require.NoError(b, m.run(ad.r))
223 b.ReportAllocs()
224 b.ResetTimer()
225 b.RunParallel(func(pb *testing.PB) {
226 for pb.Next() {
227 if err := m.run(ad.r); err != nil {
228 b.Error(err)
229 return
230 }
231 }
232 })
233 })
234 }
235 })
236 }
237}
238
239// benchFixture builds the generated repository: a few hundred commits over a
240// tree of directories, merges, tags, and a 10 MiB blob.
241func benchFixture(b *testing.B, data string) *readertest.Fixture {
242 b.Helper()
243 f := readertest.NewFixture(b, data, "bench")
244 f.Git("config", "commitGraph.changedPathsVersion", "2")
245 f.Git("config", "core.compression", "1")
246 for i := range 200 {
247 for j := range 3 {
248 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))
249 }
250 f.Write("README.md", fmt.Sprintf("# project\n\nrevision %d\n", i))
251 f.Commit(fmt.Sprintf("change %d\n\nbody of the message", i))
252 if i%25 == 24 {
253 f.CheckoutNew(fmt.Sprintf("topic%d", i))
254 f.Write(fmt.Sprintf("topics/topic%d.txt", i), "t\n")
255 f.Commit("topic work")
256 f.Checkout("main")
257 f.Write("pkg0/main-work.go", fmt.Sprintf("// main work %d\n", i))
258 f.Commit("main work")
259 f.Merge(fmt.Sprintf("topic%d", i))
260 f.Git("tag", "-a", "-m", "release", fmt.Sprintf("v%d", i/25))
261 }
262 }
263 f.WriteBytes("assets/big.bin", readertest.RandomBytes(10<<20, 5))
264 f.Commit("add a 10 MiB asset")
265 f.Git("gc", "--quiet")
266 return f
267}
268
269// ageFiles sets the modification time of every file of the repository to an
270// hour ago. ermite-git re-reads the files modified in the last two seconds (the
271// racy window of git's index rule): a repository that was just built would
272// measure that path. A repository that serves reads is not in that state, its
273// last push is older.
274func ageFiles(b *testing.B, dir string) {
275 b.Helper()
276 old := time.Now().Add(-time.Hour)
277 require.NoError(b, filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
278 if err != nil {
279 return err
280 }
281 return os.Chtimes(p, old, old)
282 }))
283}
284
285func gitOutB(b *testing.B, dir string, args ...string) string {
286 b.Helper()
287 return readertest.GitOut(b, dir, args...)
288}