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.go +66−12

@@ -677,6 +677,8 @@ func PostReceive(cfg Config) int {
677 677 if who == "" {
678 678 who = "unknown"
679 679 }
680 following := openFollower(cfg)
681 defer following.close()
680 682 sc := bufio.NewScanner(cfg.In)
681 683 for sc.Scan() {
682 684 fields := strings.Fields(sc.Text())
@@ -700,21 +702,27 @@ func PostReceive(cfg Config) int {
700 702 // ref update, because the new tip is the event; pre-receive could only guess.
701 703 // Nothing about this may fail the push, and a sync that does not happen leaves
702 704 // the previous (already reviewed) head in place rather than approving new work.
705 //
706 // A branch no open request follows is the common case by a long way, and the
707 // answer is one indexed row: leaving it out of this block would make every
708 // push read the metadata branch and queue behind every comment.
703 709 if strings.HasPrefix(ref, "refs/heads/") && ref != metadata.Ref &&
704 710 next != "" && next != git.ZeroSHA && actor != "" {
705 711 branch := strings.TrimPrefix(ref, "refs/heads/")
706 n, err := metadata.SyncBranchHead(git.Open(cfg.RepoDir), branch, next, actor)
707 switch {
708 case err != nil:
709 fmt.Fprintf(cfg.Out, "vole: could not move requests following %s: %v\n", branch, err)
710 case n > 0:
711 if err := auditLine(cfg.AuditPath, audit.Record{
712 Action: "request.follow",
713 Actor: actor,
714 Subject: repo,
715 Detail: fmt.Sprintf("%s -> %s (%d request(s))", ref, shortOrNone(next), n),
716 }); err != nil {
717 fmt.Fprintf(cfg.Out, "vole: audit log write failed: %v\n", err)
712 if following.follows(branch) {
713 n, err := metadata.SyncBranchHead(git.Open(cfg.RepoDir), branch, next, actor)
714 switch {
715 case err != nil:
716 fmt.Fprintf(cfg.Out, "vole: could not move requests following %s: %v\n", branch, err)
717 case n > 0:
718 if err := auditLine(cfg.AuditPath, audit.Record{
719 Action: "request.follow",
720 Actor: actor,
721 Subject: repo,
722 Detail: fmt.Sprintf("%s -> %s (%d request(s))", ref, shortOrNone(next), n),
723 }); err != nil {
724 fmt.Fprintf(cfg.Out, "vole: audit log write failed: %v\n", err)
725 }
718 726 }
719 727 }
720 728 }
@@ -776,6 +784,52 @@ func RefreshIndex(cfg Config) error {
776 784 return store.RefreshContributors(m)
777 785 }
778 786
787 // requestsFollow reports whether any open change request follows branch, which is
788 // the question post-receive asks before it reads the metadata branch to move a
789 // request's head.
790 //
791 // Every doubt answers yes. A missing, unreadable, or stale index means the caller
792 // does the work, because skipping the sync leaves a request naming an old head and
793 // nothing repairs that afterwards; doing it unnecessarily costs one metadata commit
794 // that changes nothing.
795 type follower struct {
796 cfg Config
797 store *index.Store
798 tried bool
799 }
800
801 // openFollower prepares the question a push asks once per branch it moved. The index
802 // is opened at most once for the whole push, and not at all for a push that touched
803 // no branch.
804 func openFollower(cfg Config) *follower { return &follower{cfg: cfg} }
805
806 func (f *follower) follows(branch string) bool {
807 if f.cfg.IndexDB == "" || f.cfg.RepoID == "" {
808 return true
809 }
810 if !f.tried {
811 f.tried = true
812 if store, err := index.Open(f.cfg.IndexDB); err == nil {
813 f.store = store
814 }
815 }
816 if f.store == nil {
817 return true
818 }
819 m := &repo.Meta{ID: f.cfg.RepoID, Dir: f.cfg.RepoDir}
820 follows, ok, err := f.store.RequestsFollowing(m, branch)
821 if err != nil || !ok {
822 return true
823 }
824 return follows
825 }
826
827 func (f *follower) close() {
828 if f.store != nil {
829 _ = f.store.Close()
830 }
831 }
832
779 833 // shortOrNone renders a sha for the log, naming the two states that are not a commit:
780 834 // a ref being created and one being deleted.
781 835 func shortOrNone(sha string) string {