Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Stop repeating the index check and the history summaries

73ec800beafe53eea186fbc1cebec3306cc49da0 · Keenan Nemetz · 10d ago · parent 5d440e37

Stop repeating the index check and the history summaries

The request memo covered the repository, the open counts and the change
list, but two more reads were repeated:

- The index refresh (issue rows, filters, `#42` numbers) ran on every
  surface that shows one, and each run started by resolving the metadata
  ref. A page calls it from the header, the number lookup and its own
  view, so it now happens once per request, and the resolved commit is
  shared with the change-request read instead of being resolved twice.
- The overview walked the repository's whole history on every load:
  `shortlog` for contributors, `rev-list --count` for the code page's
  commit count, and the tree grouping for languages. Those are properties
  of a commit, and a commit id is content-addressed, so they cannot go
  stale: they are remembered by commit id in a bounded per-process cache
  that evicts oldest-first.

On the fixture (30 change requests, 30 issues) the overview drops from 19
git calls to 9 on a repeated load, the code page from 12 to 11, and every
page loses one `rev-parse` for the metadata ref.

server/web/cache.go +126

@@ -0,0 +1,126 @@
1 package web
2
3 // Answers that cannot change while a commit id stays the same.
4 //
5 // The overview summarises a repository by walking its history: how many commits
6 // it has, who wrote them, and what it is made of. Those are properties of the
7 // commit graph, and a commit is content-addressed — its ancestors are part of
8 // its identity — so remembering them by commit id is not a stale-data risk. It
9 // is a cheaper second read of the same bytes, which matters because a
10 // repository page is read far more often than its history is written.
11 //
12 // The cache belongs to the process, not to an account or a request: the values
13 // depend on nothing about the viewer. It is bounded and evicts oldest-first, so
14 // a thousand branches cannot turn it into a leak.
15
16 import (
17 "sync"
18
19 "github.com/rodent-software/vole/core/git"
20 )
21
22 // commitCacheEntries is how many commits one cache remembers. A repository page
23 // is usually read at its tip, so a few hundred covers an instance's active
24 // repositories; past that the oldest entry is evicted, which only costs the
25 // next reader one git call.
26 const commitCacheEntries = 256
27
28 type commitCache[T any] struct {
29 mu sync.Mutex
30 entries map[string]T
31 order []string
32 }
33
34 func (c *commitCache[T]) get(key string) (T, bool) {
35 c.mu.Lock()
36 defer c.mu.Unlock()
37 v, ok := c.entries[key]
38 return v, ok
39 }
40
41 func (c *commitCache[T]) put(key string, v T) {
42 c.mu.Lock()
43 defer c.mu.Unlock()
44 if c.entries == nil {
45 c.entries = make(map[string]T, commitCacheEntries)
46 }
47 if _, ok := c.entries[key]; ok {
48 return
49 }
50 c.entries[key] = v
51 c.order = append(c.order, key)
52 if len(c.order) > commitCacheEntries {
53 delete(c.entries, c.order[0])
54 c.order = c.order[1:]
55 }
56 }
57
58 // commitKey names a value by repository and commit. The directory separates two
59 // repositories' identical commits, which is cheap and enough: an id is unique
60 // per repository, and the same content in two repositories should not share a
61 // slot by accident.
62 func commitKey(gitDir, commit string) string { return gitDir + "\x00" + commit }
63
64 // countCommits returns how many commits rev is made of. On a large repository
65 // this walks the whole history, which is the expensive part of the code page
66 // header, so it is worth remembering by commit.
67 func (w *Web) countCommits(g *git.Repo, rev string) (int, error) {
68 if rev == "" {
69 return 0, nil
70 }
71 key := commitKey(g.Dir, rev)
72 if n, ok := w.commitCounts.get(key); ok {
73 return n, nil
74 }
75 n, err := g.CountCommits(rev)
76 if err != nil {
77 return 0, err
78 }
79 w.commitCounts.put(key, n)
80 return n, nil
81 }
82
83 // contributors returns the authors of rev's history. `shortlog` walks every
84 // commit, which is the single most expensive read on the overview page and one
85 // whose answer never changes for a given commit.
86 func (w *Web) contributors(g *git.Repo, rev string) []contributorView {
87 if rev == "" {
88 return nil
89 }
90 key := commitKey(g.Dir, rev)
91 if list, ok := w.commitAuthors.get(key); ok {
92 return list
93 }
94 authors, err := g.Contributors(rev)
95 if err != nil {
96 return nil
97 }
98 list := make([]contributorView, 0, len(authors))
99 for _, c := range authors {
100 list = append(list, contributorView{
101 Name: c.Name, Commits: c.Commits, Initial: initial(c.Name), Tone: tone(c.Name),
102 })
103 }
104 w.commitAuthors.put(key, list)
105 return list
106 }
107
108 // languages describes what rev is made of, by extension. It reads the tree and
109 // every blob size once, so the overview remembers the grouping rather than the
110 // file list: the answer is small and the tree is not.
111 func (w *Web) languages(g *git.Repo, rev string) []languageShare {
112 if rev == "" {
113 return nil
114 }
115 key := commitKey(g.Dir, rev)
116 if list, ok := w.commitLanguages.get(key); ok {
117 return list
118 }
119 blobs, err := g.TreeBlobs(rev)
120 if err != nil {
121 return nil
122 }
123 list := languageShares(blobs)
124 w.commitLanguages.put(key, list)
125 return list
126 }