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 | } | ||