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