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

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