Closes #152.
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 did not queue behind that: each one read a tree
it was about to lose, lost, and read it again. The retries were the load.
This makes writers take turns, makes each one read the branch once, and stops a push from joining the queue when it has no metadata to move.
What changed
Writers of a ref take turns. CommitFiles runs its read-modify-write while
holding a flock on a file in the repository (core/git/lock.go), so the server
and the post-receive hook — a separate process — queue behind each other. The
compare-and-swap stays, and a lost race now waits with jittered backoff before
deciding again, because the writers that bypass the lock are real: a push, a
hand-run git update-ref, another host. The lock is per-ref, so a metadata write
does not hold up a pages deploy, and the kernel releases it if a writer is killed.
Writers read the branch once. updateIssue, AddIssueComment,
EditIssueComment, DeleteIssueComment, CreateRequest, SetRequestCloses and
the request comment writers loaded the metadata and then let CommitFiles read it
again. They now parse from the tree the commit is built from, via a new
CommitFilesBy that lets the mutate decide the commit author from the same tree
(the author falls back to the entity’s own author when a caller has no account).
That is also a correctness fix: a decision made before the commit could replace a
change that landed in between. TestEditKeepsAChangeThatLandedFirst moves the
branch inside the write and asserts the other change survives.
A push only queues when something follows the branch. SyncBranchHead parsed
every file under requests/ on every push to every branch, then usually wrote
nothing. Post-receive now asks index.RequestsFollowing, which answers from one
indexed row and only when the index was built from the metadata commit the branch
is at now. A stale or missing index answers “maybe”, so the hook does the work
rather than skip a sync nobody repairs.
A load scenario that writes. VOLE_LOAD_SCENARIO=comment posts comments
through the web as the fixture’s writer, which is one commit on the shared branch.
Measurements
In-process, 2,000-file branch, 64 writes, GIT_TRACE counting update-ref:
| Writers | attempts/write before | after |
|---|---|---|
| 1 | 1.00 | 1.00 |
| 8 | 7.33 | 1.00 |
| 32 | 21.28 | 1.00 |
Through the web (VOLE_LOAD_SCENARIO=comment, 5 s, 303s only):
| Concurrency | before p50 / p90 / p99 | after p50 / p90 / p99 | before / after req/s |
|---|---|---|---|
| 4 | 60 / 115 / 680 ms | 79 / 104 / 127 ms | 39.0 / 49.8 |
| 16 | 144 / 819 / 1998 ms | 306 / 427 / 487 ms | 53.0 / 52.6 |
| 32 | 228 / 1828 / 4706 ms | 636 / 803 / 952 ms | 53.0 / 56.8 |
The tail is the point: p99 is 4.9× better at 32 writers, the distribution stops being bimodal (fast winners, slow losers), and throughput stays flat instead of falling. The median at 32 writers is higher because a queue has a middle; the mean is comparable, and the work behind it is a fifth of what it was. On a box with fewer spare cores the duplicated reads would show up in throughput as well.
Tests
TestCommitFilesSerializesWriters— 8 goroutines, 32 writes, every write lands and the attempts do not exceed the number of writes. Without the lock the same test reports 91 attempts for 32 writes.TestCommitFilesReleasesTheLockAfterAFailure,…LocksEachRefSeparately,…StillRetriesARefMovedByHand— the lock is not a new way to deadlock or to lose the safety net.TestCommentReadsTheBranchOnce— one comment, onels-treeand onecat-file, on a branch with 21 issues.TestEditKeepsAChangeThatLandedFirst,TestWritingAMissingEntityNamesIt.TestRequestsFollowingOnlyAnswersForTheBranchItIndexed— follows, does not follow, closed, and stale.TestPostReceiveOnlySyncsBranchesARequestFollows— a push to an unfollowed branch reads no tree at all (counted fromGIT_TRACE); a followed branch is read and its request moves.TestPostReceiveSyncsWithoutAnIndexkeeps the conservative path honest.
CGO_ENABLED=1 GOFLAGS=-tags=sqlite_fts5 go test -tags sqlite_fts5 -race ./...
passes.
Review notes
CommitFilesByis new public API oncore/git. The alternative was to keep reading the entity before the commit purely to learn the commit author, which is the second read this change removes; the two closures are one loop, so there is still a single write path.RequestsFollowingtrusts the index only whenrepos.issues_shaequals the metadata tip. That is deliberately conservative: a missed sync leaves a request naming an old head, and nothing repairs that.forge/indexgainedrequests(repo_id, head_ref). NoschemaVersionbump: it is a new index, not new rows, andCREATE INDEX IF NOT EXISTSruns on every open.- Not done, and left in #152: nothing else. The sharded-ref design from the Phase 0 spike stays out of scope by request.