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/hook/hook_test.go +191

@@ -13,6 +13,7 @@ import (
13 13 "github.com/rodent-software/vole/core/git"
14 14 "github.com/rodent-software/vole/forge/access"
15 15 "github.com/rodent-software/vole/forge/identity"
16 "github.com/rodent-software/vole/forge/index"
16 17 "github.com/rodent-software/vole/forge/repo"
17 18 "github.com/rodent-software/vole/metadata"
18 19 )
@@ -399,3 +400,193 @@ func TestNewChangeRequestRequiresALinkedIssueWhenConfigured(t *testing.T) {
399 400 t.Fatalf("the refusal does not explain the rule: %s", out)
400 401 }
401 402 }
403
404 // pushFixture is a repository with a metadata branch and an index that agrees with
405 // it, which is what post-receive sees on a healthy instance.
406 func pushFixture(t *testing.T) (*repo.Meta, string) {
407 t.Helper()
408 root := t.TempDir()
409 acls := testACLs(t, root)
410 st := repo.New(filepath.Join(root, "repos"), "/bin/true", acls)
411 m, err := st.Create("demo", "owner", "")
412 if err != nil {
413 t.Fatal(err)
414 }
415 g := m.Git()
416 tree, err := g.MkTree(nil)
417 if err != nil {
418 t.Fatal(err)
419 }
420 base, err := g.CommitTree(tree, "", "initial")
421 if err != nil {
422 t.Fatal(err)
423 }
424 if err := g.UpdateRef("refs/heads/main", base); err != nil {
425 t.Fatal(err)
426 }
427 // The metadata branch starts one commit further along, so a tree walk in the
428 // trace names which of the two it read.
429 first, err := g.CommitTree(tree, base, "metadata")
430 if err != nil {
431 t.Fatal(err)
432 }
433 if err := g.UpdateRef(metadata.Ref, first); err != nil {
434 t.Fatal(err)
435 }
436 path := filepath.Join(root, "index.db")
437 store, err := index.Open(path)
438 if err != nil {
439 t.Fatal(err)
440 }
441 defer store.Close()
442 if err := store.Upsert(m); err != nil {
443 t.Fatal(err)
444 }
445 // A full build, so the default branch summary is current too: post-receive
446 // refreshes it, and a fixture that left it empty would make that refresh read
447 // trees the test is counting.
448 if err := store.IndexRepo(m); err != nil {
449 t.Fatal(err)
450 }
451 return m, path
452 }
453
454 // followRequest opens a request that follows branch, which is the only reason a push
455 // to that branch has metadata work to do.
456 func followRequest(t *testing.T, m *repo.Meta, branch, head string) *metadata.Request {
457 t.Helper()
458 req, err := metadata.NewRequest("Land it", "", "keenan", "main", head, time.Now().UTC())
459 if err != nil {
460 t.Fatal(err)
461 }
462 req.HeadRef = branch
463 if err := metadata.CreateRequest(m.Git(), req, "keenan"); err != nil {
464 t.Fatal(err)
465 }
466 return req
467 }
468
469 // A push to a branch no open request follows moves no metadata, so post-receive must
470 // not read the branch or queue a commit behind the people writing in it. The index is
471 // what makes the answer cheap; without one, the hook falls back to doing the work.
472 func TestPostReceiveOnlySyncsBranchesARequestFollows(t *testing.T) {
473 m, indexDB := pushFixture(t)
474 head := "00112233445566778899aabbccddeeff00112233"
475 req := followRequest(t, m, "feature", head)
476 reindex(t, m, indexDB)
477
478 // Every child git logs its argv, so "was the metadata branch read?" is a question
479 // the test can answer rather than infer.
480 trace := filepath.Join(t.TempDir(), "git.trace")
481 t.Setenv("GIT_TRACE", trace)
482
483 before, err := m.Git().RevParseRaw(metadata.Ref)
484 if err != nil {
485 t.Fatal(err)
486 }
487 push := func(branch, newTip string) {
488 t.Helper()
489 var out bytes.Buffer
490 code := PostReceive(Config{
491 Actor: "keenan",
492 RepoDir: m.Dir,
493 RepoID: m.ID,
494 IndexDB: indexDB,
495 Out: &out,
496 In: strings.NewReader(git.ZeroSHA + " " + newTip + " refs/heads/" + branch + "\n"),
497 })
498 if code != 0 {
499 t.Fatalf("post-receive exited %d: %s", code, out.String())
500 }
501 }
502 reads := func() int {
503 t.Helper()
504 data, err := os.ReadFile(trace)
505 if os.IsNotExist(err) {
506 return 0
507 }
508 if err != nil {
509 t.Fatal(err)
510 }
511 return bytes.Count(data, []byte("ls-tree"))
512 }
513
514 // A branch nobody proposes from: nothing follows it, so the branch is not read.
515 push("other", head)
516 if n := reads(); n != 0 {
517 t.Fatalf("a push to an unfollowed branch read the metadata tree %d time(s)", n)
518 }
519 if after, err := m.Git().RevParseRaw(metadata.Ref); err != nil || after != before {
520 t.Fatalf("a push to an unfollowed branch moved %s: %s -> %s (%v)", metadata.Ref, before, after, err)
521 }
522
523 // The branch a request follows is read, and the push moves that request's head.
524 if err := os.Remove(trace); err != nil {
525 t.Fatal(err)
526 }
527 next := "ffeeddccbbaa99887766554433221100ffeeddcc"
528 push("feature", next)
529 if n := reads(); n == 0 {
530 t.Fatal("a push to a followed branch did not read the metadata tree")
531 }
532 if after, err := m.Git().RevParseRaw(metadata.Ref); err != nil || after == before {
533 t.Fatalf("a push to a followed branch did not move %s", metadata.Ref)
534 }
535 synced, err := metadata.GetRequest(m.Git(), req.ID)
536 if err != nil {
537 t.Fatal(err)
538 }
539 if synced.Head != next {
540 t.Fatalf("the request follows %s, want %s", synced.Head, next)
541 }
542 }
543
544 // reindex brings the fixture's index up to the metadata branch, the way a web write
545 // would have: without it the hook correctly decides it cannot answer from a stale
546 // index and reads the branch.
547 func reindex(t *testing.T, m *repo.Meta, path string) {
548 t.Helper()
549 store, err := index.Open(path)
550 if err != nil {
551 t.Fatal(err)
552 }
553 defer store.Close()
554 if err := store.Ensure(m); err != nil {
555 t.Fatal(err)
556 }
557 }
558
559 // With no index to ask, the hook cannot rule a follower out, so it has to read the
560 // branch: the sync is worth more than the read.
561 func TestPostReceiveSyncsWithoutAnIndex(t *testing.T) {
562 t.Parallel()
563 m, _ := pushFixture(t)
564 head := "00112233445566778899aabbccddeeff00112233"
565 req := followRequest(t, m, "feature", head)
566 before, err := m.Git().RevParseRaw(metadata.Ref)
567 if err != nil {
568 t.Fatal(err)
569 }
570 next := "ffeeddccbbaa99887766554433221100ffeeddcc"
571 var out bytes.Buffer
572 code := PostReceive(Config{
573 Actor: "keenan",
574 RepoDir: m.Dir,
575 RepoID: m.ID,
576 Out: &out,
577 In: strings.NewReader(git.ZeroSHA + " " + next + " refs/heads/feature\n"),
578 })
579 if code != 0 {
580 t.Fatalf("post-receive exited %d: %s", code, out.String())
581 }
582 if after, err := m.Git().RevParseRaw(metadata.Ref); err != nil || after == before {
583 t.Fatalf("%s did not move: %s -> %s (%v)", metadata.Ref, before, after, err)
584 }
585 got, err := metadata.GetRequest(m.Git(), req.ID)
586 if err != nil {
587 t.Fatal(err)
588 }
589 if got.Head != next {
590 t.Fatalf("the request follows %s, want %s", got.Head, next)
591 }
592 }