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

server/load_test.go +36

@@ -28,6 +28,7 @@ import (
28 28 "io"
29 29 "math/rand"
30 30 "net/http"
31 "net/url"
31 32 "os"
32 33 "os/exec"
33 34 "path/filepath"
@@ -177,6 +178,7 @@ func loadScenarios(t *testing.T, f *fixture, seed loadSeed) []scenario {
177 178 {name: "artifact", step: getStep(repo + "/releases/v1/artifacts/load-artifact.txt")},
178 179 {name: "artifact-range", step: getStepWith(repo+"/releases/v1/artifacts/load-artifact.txt", map[string]string{"Range": "bytes=0-1023"})},
179 180 {name: "site", step: siteStep(siteURL, seed.repo+"."+loadPagesDomain)},
181 {name: "comment", step: commentStep(t, f, f.writerID, repo+"/issues/"+seed.issueID)},
180 182 }
181 183 }
182 184
@@ -224,6 +226,40 @@ func siteStep(url, host string) loadStep {
224 226 }
225 227 }
226 228
229 // commentStep is the only scenario that writes. Each request is one comment, and a
230 // comment is one commit on the metadata branch every write in the instance shares, so
231 // this is the scenario that measures what concurrency on that branch costs. The
232 // redirect after a successful post is not followed: the write is the measurement, and
233 // the page it lands on is the issue scenario.
234 func commentStep(t *testing.T, f *fixture, accountID, issuePath string) loadStep {
235 t.Helper()
236 sess, err := f.srv.Sessions.Create(accountID, nil, "load")
237 if err != nil {
238 t.Fatal(err)
239 }
240 cookie := "__Host-vole_session=" + sess.Token
241 comment := &http.Client{
242 Timeout: 120 * time.Second,
243 CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
244 }
245 return func(client *http.Client, worker int) (string, error) {
246 form := url.Values{"csrf": {sess.CSRF}, "body": {"load comment"}}
247 req, err := http.NewRequest(http.MethodPost, issuePath+"/comments", strings.NewReader(form.Encode()))
248 if err != nil {
249 return "error", err
250 }
251 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
252 req.Header.Set("Cookie", cookie)
253 resp, err := comment.Do(req)
254 if err != nil {
255 return "error", err
256 }
257 _, _ = io.Copy(io.Discard, resp.Body)
258 resp.Body.Close()
259 return strconv.Itoa(resp.StatusCode), nil
260 }
261 }
262
227 263 // cloneStep probes `info/refs` and then clones. The probe is what a server can
228 264 // refuse (503 past the concurrency limit), so it is recorded as the step's
229 265 // status; the git process's own failure is recorded separately.