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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Metadata writers queue on the branch instead of racing for it

122cbe4f62bf87a941538450584ba1b20444c135 · Keenan Nemetz · 7d ago · parent 4739664b

Metadata writers queue on the branch instead of racing for it

Every write to refs/heads/vole read the whole branch, decided, and moved the
ref with a compare-and-swap, retrying up to 64 times with no backoff and no
coordination. Concurrent writers therefore did not queue: they each paid a full
read, lost, and paid it again. Measured on a 2,000-file branch, one write needed
one attempt alone and 21 with 32 writers, and throughput fell as writers were
added because the retries were the load. At 64 simultaneous writers the loop ran
out and the write failed for being unlucky.

The read-modify-write now runs with one writer at a time, holding a flock in the
repository so the SSH hook and the server queue behind each other. The
compare-and-swap stays for writers that go around the lock — a push, a hand-run
git command, another host — and losing one now waits with jittered backoff
before deciding again.

Writers read the branch once. The load and the decision happen inside the commit
instead of before it, which also stops an edit from replacing a change that
landed while the page was open: the decision is made against the tree it writes.

A push only does that work when it has metadata to move. Post-receive asks the
index which open requests follow the branch it moved, and a branch nobody is
proposing from now writes an audit line and no commit.

Measurements, 32 concurrent writers: 21.3 CAS attempts per write before, 1.0
after. Through the web, posting comments at concurrency 32: p99 4.7 s -> 0.95 s,
and throughput no longer falls as workers are added. The load harness gains a
`comment` scenario so the number is visible.

Closes: 01M6D9G8H6E0RCQ3AD5TMAGX34

core/git/lock.go +101

@@ -0,0 +1,101 @@
1 package git
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8 )
9
10 // Writers of one ref are serialized inside one repository.
11 //
12 // CommitFiles reads the branch, decides, and moves the ref with a compare-and-swap.
13 // That is correct on its own, but under load every writer reads a tree it is about
14 // to lose and pays for the read again: measured on a 2,000-file branch, a write
15 // needed one attempt alone and 21 with 32 writers, and the retries were then most
16 // of the work. Holding this lock across the read-modify-write turns that race into
17 // a queue, so the compare-and-swap is left for writers that went around the lock —
18 // an admin push, a hand-run `git update-ref`, or a second host.
19 //
20 // The lock is a file in the git directory held with flock(2), so it covers the
21 // server, the post-receive hook (which is its own process), and a CLI on the same
22 // host. The kernel releases it when the process exits, so a writer that is killed
23 // mid-commit does not leave the branch waiting behind a lock nobody holds. It is
24 // advisory and per-ref: nothing else in the repository reads it, and a writer that
25 // takes the lock still competes correctly with one that does not.
26 func (r *Repo) lockWrites(ref string) (func(), error) {
27 path := filepath.Join(r.gitDir(), "vole-write-"+lockName(ref)+".lock")
28 f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
29 if err != nil {
30 return nil, fmt.Errorf("lock %s: %w", ref, err)
31 }
32 release, err := lockFile(f)
33 if err != nil {
34 f.Close()
35 return nil, fmt.Errorf("lock %s: %w", ref, err)
36 }
37 return func() {
38 release()
39 _ = f.Close()
40 }, nil
41 }
42
43 // lockName turns a ref into one file name: refs/heads/vole becomes
44 // refs-heads-vole. Anything that is not plainly a name becomes a dash, so a ref can
45 // never be read as a path.
46 func lockName(ref string) string {
47 var b strings.Builder
48 for _, c := range ref {
49 switch {
50 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
51 b.WriteRune(c)
52 default:
53 b.WriteByte('-')
54 }
55 }
56 return b.String()
57 }
58
59 // gitDir is the directory git keeps this repository's own files in: the directory
60 // itself for a bare repository, .git for a clone with a worktree. RefCommit takes
61 // the same view of Dir, and the lock file belongs beside the refs it guards.
62 //
63 // It answers from the filesystem because a write already starts several git
64 // processes and the lock has to be taken before the first of them. The process is
65 // a fallback for the layouts a directory check cannot recognise.
66 func (r *Repo) gitDir() string {
67 if isGitDir(r.Dir) {
68 return r.Dir
69 }
70 dot := filepath.Join(r.Dir, ".git")
71 if isGitDir(dot) {
72 return dot
73 }
74 // A linked worktree or a submodule keeps a .git file that names the real
75 // directory instead of being one.
76 if data, err := os.ReadFile(dot); err == nil {
77 if rest, ok := strings.CutPrefix(strings.TrimSpace(string(data)), "gitdir:"); ok {
78 dir := strings.TrimSpace(rest)
79 if !filepath.IsAbs(dir) {
80 dir = filepath.Join(r.Dir, dir)
81 }
82 if isGitDir(dir) {
83 return dir
84 }
85 }
86 }
87 if out, _, err := r.run(nil, "rev-parse", "--absolute-git-dir"); err == nil {
88 if dir := strings.TrimSpace(string(out)); dir != "" {
89 return dir
90 }
91 }
92 return r.Dir
93 }
94
95 func isGitDir(dir string) bool {
96 if _, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil {
97 return false
98 }
99 _, err := os.Stat(filepath.Join(dir, "objects"))
100 return err == nil
101 }