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