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
metadata/request_write.go +84−89
| @@ -112,24 +112,6 @@ func CreateRequest(g *git.Repo, r *Request, actor string) error { | |||
| 112 | 112 | if err := validateRequestForWrite(r); err != nil { | |
| 113 | 113 | return err | |
| 114 | 114 | } | |
| 115 | // A link is a promise that a merge closes the issue, so it has to name one. Doing | ||
| 116 | // this here rather than at the merge means a typo is refused while the author is | ||
| 117 | // still looking at it, instead of at the landing of a request they waited on. | ||
| 118 | if len(r.Closes) > 0 { | ||
| 119 | issues, err := LoadIssues(g, Ref) | ||
| 120 | if err != nil { | ||
| 121 | return err | ||
| 122 | } | ||
| 123 | known := make(map[string]bool, len(issues)) | ||
| 124 | for _, iss := range issues { | ||
| 125 | known[iss.ID] = true | ||
| 126 | } | ||
| 127 | for _, id := range r.Closes { | ||
| 128 | if !known[id] { | ||
| 129 | return fmt.Errorf("requests: closes %s, and there is no such issue", id) | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | 115 | raw, err := renderRequest(r) | |
| 134 | 116 | if err != nil { | |
| 135 | 117 | return err | |
| @@ -139,6 +121,13 @@ func CreateRequest(g *git.Repo, r *Request, actor string) error { | |||
| 139 | 121 | if _, exists := files[base+requestName]; exists { | |
| 140 | 122 | return fmt.Errorf("requests: %s already exists", r.ID) | |
| 141 | 123 | } | |
| 124 | // A link is a promise that a merge closes the issue, so it has to name one. | ||
| 125 | // Checking it against the tree this commit replaces means a typo is refused | ||
| 126 | // while the author is still looking at it, instead of at the landing of a | ||
| 127 | // request they waited on. | ||
| 128 | if err := issuesExist(files, r.Closes); err != nil { | ||
| 129 | return err | ||
| 130 | } | ||
| 142 | 131 | files[base+requestName] = raw | |
| 143 | 132 | return nil | |
| 144 | 133 | }, fmt.Sprintf("request: %s (%s)", r.Title, r.ID), actor) | |
| @@ -607,78 +596,72 @@ func AddRequestComment(g *git.Repo, requestID string, c *Comment, actor string) | |||
| 607 | 596 | // keeps its id and one `git log -p -- <path>` answers what it used to say; there | |
| 608 | 597 | // is no `edited` field, for the reason nothing else in this format has one. | |
| 609 | 598 | func EditRequestComment(g *git.Repo, requestID, commentID, body, actor string) (*Comment, error) { | |
| 610 | c, path, err := findRequestComment(g, requestID, commentID) | ||
| 611 | if err != nil { | ||
| 612 | return nil, err | ||
| 613 | } | ||
| 614 | if c.Deleted { | ||
| 615 | return nil, fmt.Errorf("requests: cannot edit a deleted comment") | ||
| 616 | } | ||
| 617 | c.Body = strings.TrimRight(body, "\n") | ||
| 618 | if err := validateComment(c); err != nil { | ||
| 619 | return nil, err | ||
| 620 | } | ||
| 621 | raw, err := RenderComment(c) | ||
| 622 | if err != nil { | ||
| 623 | return nil, err | ||
| 624 | } | ||
| 625 | _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 626 | if _, ok := files[path]; !ok { | ||
| 627 | return fmt.Errorf("requests: no comment %s", commentID) | ||
| 599 | body = strings.TrimRight(body, "\n") | ||
| 600 | var out *Comment | ||
| 601 | path := RequestCommentPath(requestID, commentID) | ||
| 602 | _, err := git.CommitFilesBy(g, Ref, func(files map[string][]byte) (string, error) { | ||
| 603 | r, err := loadFrom(files, requestID) | ||
| 604 | if err != nil { | ||
| 605 | return "", err | ||
| 606 | } | ||
| 607 | c, ok := commentOf(r.Comments, commentID) | ||
| 608 | if !ok { | ||
| 609 | return "", fmt.Errorf("requests: no comment %s on %s", commentID, requestID) | ||
| 610 | } | ||
| 611 | if c.Deleted { | ||
| 612 | return "", fmt.Errorf("requests: cannot edit a deleted comment") | ||
| 613 | } | ||
| 614 | c.Body = body | ||
| 615 | if err := validateComment(c); err != nil { | ||
| 616 | return "", err | ||
| 617 | } | ||
| 618 | raw, err := RenderComment(c) | ||
| 619 | if err != nil { | ||
| 620 | return "", err | ||
| 628 | 621 | } | |
| 629 | 622 | files[path] = raw | |
| 630 | return nil | ||
| 631 | }, fmt.Sprintf("request: edit comment %s on %s", commentID, requestID), actorOr(actor, c.Author)) | ||
| 623 | out = c | ||
| 624 | return actorOr(actor, c.Author), nil | ||
| 625 | }, fmt.Sprintf("request: edit comment %s on %s", commentID, requestID)) | ||
| 632 | 626 | if err != nil { | |
| 633 | 627 | return nil, err | |
| 634 | 628 | } | |
| 635 | return c, nil | ||
| 629 | return out, nil | ||
| 636 | 630 | } | |
| 637 | 631 | ||
| 638 | 632 | // DeleteComment replaces a comment with a tombstone. The text stays in history: | |
| 639 | 633 | // deleting is not redaction, which is an admin force-push. Replies keep the | |
| 640 | 634 | // parent they answer. | |
| 641 | 635 | func DeleteRequestComment(g *git.Repo, requestID, commentID, actor string) (*Comment, error) { | |
| 642 | c, path, err := findRequestComment(g, requestID, commentID) | ||
| 643 | if err != nil { | ||
| 644 | return nil, err | ||
| 645 | } | ||
| 646 | if c.Deleted { | ||
| 647 | return nil, fmt.Errorf("requests: comment %s is already deleted", commentID) | ||
| 648 | } | ||
| 649 | c.Deleted = true | ||
| 650 | c.Body = "" | ||
| 651 | c.Anchor = nil | ||
| 652 | raw, err := RenderComment(c) | ||
| 653 | if err != nil { | ||
| 654 | return nil, err | ||
| 655 | } | ||
| 656 | _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 657 | if _, ok := files[path]; !ok { | ||
| 658 | return fmt.Errorf("requests: no comment %s", commentID) | ||
| 636 | var out *Comment | ||
| 637 | path := RequestCommentPath(requestID, commentID) | ||
| 638 | _, err := git.CommitFilesBy(g, Ref, func(files map[string][]byte) (string, error) { | ||
| 639 | r, err := loadFrom(files, requestID) | ||
| 640 | if err != nil { | ||
| 641 | return "", err | ||
| 642 | } | ||
| 643 | c, ok := commentOf(r.Comments, commentID) | ||
| 644 | if !ok { | ||
| 645 | return "", fmt.Errorf("requests: no comment %s on %s", commentID, requestID) | ||
| 646 | } | ||
| 647 | if c.Deleted { | ||
| 648 | return "", fmt.Errorf("requests: comment %s is already deleted", commentID) | ||
| 649 | } | ||
| 650 | c.Deleted = true | ||
| 651 | c.Body = "" | ||
| 652 | c.Anchor = nil | ||
| 653 | raw, err := RenderComment(c) | ||
| 654 | if err != nil { | ||
| 655 | return "", err | ||
| 659 | 656 | } | |
| 660 | 657 | files[path] = raw | |
| 661 | return nil | ||
| 662 | }, fmt.Sprintf("request: delete comment %s on %s", commentID, requestID), actorOr(actor, c.Author)) | ||
| 658 | out = c | ||
| 659 | return actorOr(actor, c.Author), nil | ||
| 660 | }, fmt.Sprintf("request: delete comment %s on %s", commentID, requestID)) | ||
| 663 | 661 | if err != nil { | |
| 664 | 662 | return nil, err | |
| 665 | 663 | } | |
| 666 | return c, nil | ||
| 667 | } | ||
| 668 | |||
| 669 | // findComment locates one comment and the path it lives at. | ||
| 670 | func findRequestComment(g *git.Repo, requestID, commentID string) (*Comment, string, error) { | ||
| 671 | r, err := GetRequest(g, requestID) | ||
| 672 | if err != nil { | ||
| 673 | return nil, "", err | ||
| 674 | } | ||
| 675 | for i := range r.Comments { | ||
| 676 | if r.Comments[i].ID == commentID { | ||
| 677 | c := r.Comments[i] | ||
| 678 | return &c, RequestCommentPath(requestID, commentID), nil | ||
| 679 | } | ||
| 680 | } | ||
| 681 | return nil, "", fmt.Errorf("requests: no comment %s on %s", commentID, requestID) | ||
| 664 | return out, nil | ||
| 682 | 665 | } | |
| 683 | 666 | ||
| 684 | 667 | // loadFrom reads a request out of the tree map a commit is being built from, so | |
| @@ -716,21 +699,6 @@ func SetRequestCloses(g *git.Repo, id string, closes []string, actor string) (*R | |||
| 716 | 699 | if len(problems) > 0 { | |
| 717 | 700 | return nil, fmt.Errorf("%s", strings.Join(problems, "; ")) | |
| 718 | 701 | } | |
| 719 | if len(closes) > 0 { | ||
| 720 | issues, err := LoadIssues(g, Ref) | ||
| 721 | if err != nil { | ||
| 722 | return nil, err | ||
| 723 | } | ||
| 724 | known := make(map[string]bool, len(issues)) | ||
| 725 | for _, iss := range issues { | ||
| 726 | known[iss.ID] = true | ||
| 727 | } | ||
| 728 | for _, want := range closes { | ||
| 729 | if !known[want] { | ||
| 730 | return nil, fmt.Errorf("requests: closes %s, and there is no such issue", want) | ||
| 731 | } | ||
| 732 | } | ||
| 733 | } | ||
| 734 | 702 | var out *Request | |
| 735 | 703 | base := fmt.Sprintf("%s/%s/", RequestsDir, id) | |
| 736 | 704 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { | |
| @@ -745,6 +713,12 @@ func SetRequestCloses(g *git.Repo, id string, closes []string, actor string) (*R | |||
| 745 | 713 | out = r | |
| 746 | 714 | return ErrNoChange | |
| 747 | 715 | } | |
| 716 | // The ids are checked against the issues tree this commit replaces, because a | ||
| 717 | // request may be linked long after it was opened, and a typo at that point | ||
| 718 | // would otherwise surface as an issue that quietly never closes. | ||
| 719 | if err := issuesExist(files, closes); err != nil { | ||
| 720 | return err | ||
| 721 | } | ||
| 748 | 722 | r.Closes = closes | |
| 749 | 723 | raw, err := renderRequest(r) | |
| 750 | 724 | if err != nil { | |
| @@ -760,6 +734,27 @@ func SetRequestCloses(g *git.Repo, id string, closes []string, actor string) (*R | |||
| 760 | 734 | return out, err | |
| 761 | 735 | } | |
| 762 | 736 | ||
| 737 | // issuesExist reports whether every issue a request promises to close is on the | ||
| 738 | // branch the request is being written to. It reads the map the commit is built from, | ||
| 739 | // so the promise is checked against the issues the writer is about to keep. | ||
| 740 | func issuesExist(files map[string][]byte, closes []string) error { | ||
| 741 | if len(closes) == 0 { | ||
| 742 | return nil | ||
| 743 | } | ||
| 744 | known := make(map[string]bool, len(files)) | ||
| 745 | for path := range files { | ||
| 746 | if id, ok := IssueIDFromPath(path); ok && !IsIssueCommentPath(path) { | ||
| 747 | known[id] = true | ||
| 748 | } | ||
| 749 | } | ||
| 750 | for _, id := range closes { | ||
| 751 | if !known[id] { | ||
| 752 | return fmt.Errorf("requests: closes %s, and there is no such issue", id) | ||
| 753 | } | ||
| 754 | } | ||
| 755 | return nil | ||
| 756 | } | ||
| 757 | |||
| 763 | 758 | func equalStrings(a, b []string) bool { | |
| 764 | 759 | if len(a) != len(b) { | |
| 765 | 760 | return false | |