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.

Code

395 commit(s) on main

update readme Keenan Nemetz · 29m ago 32290114
history blame raw 5.6 KiB
1package git
2
3// Writers of one ref take turns. These tests hold the two things that makes true:
4// concurrent writes all land, and they land without each writer re-reading the
5// tree it is about to lose. The attempt count is read from GIT_TRACE, the same
6// way TestCommitFilesStaysProportionalToTheChange counts processes: every
7// `update-ref` is one attempt, won or lost.
8
9import (
10 "bytes"
11 "fmt"
12 "os"
13 "path/filepath"
14 "strings"
15 "sync"
16 "testing"
17)
18
19func lockRepo(t *testing.T) (*Repo, string) {
20 t.Helper()
21 dir := filepath.Join(t.TempDir(), "r.git")
22 if err := InitBare(dir, "main"); err != nil {
23 t.Fatal(err)
24 }
25 g := Open(dir)
26 ref := "refs/heads/main"
27 if _, err := CommitFiles(g, ref, func(files map[string][]byte) error {
28 // Enough unrelated content that a full read is worth losing, which is what
29 // the retry used to pay for.
30 for i := 0; i < 200; i++ {
31 files[fmt.Sprintf("issues/%03d/issue.md", i)] = []byte("seed\n")
32 }
33 return nil
34 }, "seed", "test"); err != nil {
35 t.Fatal(err)
36 }
37 return g, ref
38}
39
40// TestCommitFilesSerializesWriters is the regression test for the retry storm: with
41// writers taking turns, every write needs exactly one attempt, and every write is in
42// the tree afterwards.
43func TestCommitFilesSerializesWriters(t *testing.T) {
44 g, ref := lockRepo(t)
45 const writers, each = 8, 4
46 const total = writers * each
47
48 trace := filepath.Join(t.TempDir(), "git.trace")
49 traced := *g
50 traced.ExtraEnv = append(traced.ExtraEnv, "GIT_TRACE="+trace)
51
52 var wg sync.WaitGroup
53 errs := make(chan error, total)
54 for w := 0; w < writers; w++ {
55 wg.Add(1)
56 go func(w int) {
57 defer wg.Done()
58 for i := 0; i < each; i++ {
59 path := fmt.Sprintf("issues/w%02d-%d/issue.md", w, i)
60 if _, err := CommitFiles(&traced, ref, func(files map[string][]byte) error {
61 files[path] = []byte(fmt.Sprintf("w%d i%d\n", w, i))
62 return nil
63 }, "write", "test"); err != nil {
64 errs <- err
65 return
66 }
67 }
68 }(w)
69 }
70 wg.Wait()
71 close(errs)
72 for err := range errs {
73 t.Fatal(err)
74 }
75
76 data, err := os.ReadFile(trace)
77 if err != nil {
78 t.Fatal(err)
79 }
80 attempts := bytes.Count(data, []byte("update-ref"))
81 if attempts > total {
82 t.Fatalf("%d writes from %d goroutines took %d attempts: writers are not taking turns",
83 total, writers, attempts)
84 }
85
86 // Nothing was lost on the way.
87 files, err := g.FlatTree(ref)
88 if err != nil {
89 t.Fatal(err)
90 }
91 for w := 0; w < writers; w++ {
92 for i := 0; i < each; i++ {
93 path := fmt.Sprintf("issues/w%02d-%d/issue.md", w, i)
94 want := fmt.Sprintf("w%d i%d\n", w, i)
95 if got := string(files[path]); got != want {
96 t.Fatalf("%s = %q, want %q", path, got, want)
97 }
98 }
99 }
100}
101
102// A write that fails still gives the lock back, or the branch would be stuck for
103// every writer after it.
104func TestCommitFilesReleasesTheLockAfterAFailure(t *testing.T) {
105 g, ref := lockRepo(t)
106 _, err := CommitFiles(g, ref, func(files map[string][]byte) error {
107 return fmt.Errorf("nothing to do")
108 }, "failed", "test")
109 if err == nil {
110 t.Fatal("a failing mutation was reported as a commit")
111 }
112 if _, err := CommitFiles(g, ref, func(files map[string][]byte) error {
113 files["issues/after/issue.md"] = []byte("after\n")
114 return nil
115 }, "after", "test"); err != nil {
116 t.Fatalf("the write after a failure waited forever or failed: %v", err)
117 }
118}
119
120// Two refs are two queues: a slow write on one must not hold up the other, which is
121// what makes the lock per-ref rather than per-repository.
122func TestCommitFilesLocksEachRefSeparately(t *testing.T) {
123 g, ref := lockRepo(t)
124 other := "refs/heads/pages"
125 started := make(chan struct{})
126 release := make(chan struct{})
127 done := make(chan error, 1)
128 go func() {
129 _, err := CommitFiles(g, ref, func(files map[string][]byte) error {
130 close(started)
131 <-release
132 files["issues/slow/issue.md"] = []byte("slow\n")
133 return nil
134 }, "slow", "test")
135 done <- err
136 }()
137 <-started
138 if _, err := CommitFiles(g, other, func(files map[string][]byte) error {
139 files["index.html"] = []byte("hello\n")
140 return nil
141 }, "pages", "test"); err != nil {
142 t.Fatalf("a write to another ref was held up: %v", err)
143 }
144 close(release)
145 if err := <-done; err != nil {
146 t.Fatal(err)
147 }
148}
149
150// The retry loop is still there for a writer that went around the lock, and still
151// finishes. A ref moved by hand between the read and the compare-and-swap is the
152// only way to reach it once writers take turns.
153func TestCommitFilesStillRetriesARefMovedByHand(t *testing.T) {
154 g, ref := lockRepo(t)
155 before, err := g.RevParse(ref)
156 if err != nil {
157 t.Fatal(err)
158 }
159 // A commit that does not move the tree, so a write that rebases onto it is
160 // still the write the caller asked for.
161 out, err := g.Run(nil, "rev-parse", "--verify", before+"^{tree}")
162 if err != nil {
163 t.Fatal(err)
164 }
165 tree := strings.TrimSpace(string(out))
166 side, err := g.CommitTree(tree, before, "hand")
167 if err != nil {
168 t.Fatal(err)
169 }
170 moved := false
171 _, err = CommitFiles(g, ref, func(files map[string][]byte) error {
172 if !moved {
173 // Stand in for `git update-ref` run by someone else: the lock cannot see
174 // it, so the first compare-and-swap must lose.
175 moved = true
176 if err := g.UpdateRef(ref, side); err != nil {
177 return err
178 }
179 }
180 files["issues/racing/issue.md"] = []byte("racing\n")
181 return nil
182 }, "write", "test")
183 if err != nil {
184 t.Fatalf("a write that lost one compare-and-swap gave up: %v", err)
185 }
186 files, err := g.FlatTree(ref)
187 if err != nil {
188 t.Fatal(err)
189 }
190 if string(files["issues/racing/issue.md"]) != "racing\n" {
191 t.Fatal("the write that lost the race is not in the tree")
192 }
193 if head, err := g.RevParse(ref); err != nil || head == side {
194 t.Fatalf("the branch is still where the hand edit left it: %s, %v", head, err)
195 }
196}
197