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.

#153

Serialize metadata writes instead of retrying a lost race

merged · by keenan 7d ago · merged into main at 122cbe4f by keenan 7d ago

keenan opened this 7d ago

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, one ls-tree and one cat-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 from GIT_TRACE); a followed branch is read and its request moves. TestPostReceiveSyncsWithoutAnIndex keeps the conservative path honest.

CGO_ENABLED=1 GOFLAGS=-tags=sqlite_fts5 go test -tags sqlite_fts5 -race ./... passes.

Review notes

  • CommitFilesBy is new public API on core/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.
  • RequestsFollowing trusts the index only when repos.issues_sha equals the metadata tip. That is deliberately conservative: a missed sync leaves a request naming an old head, and nothing repairs that.
  • forge/index gained requests(repo_id, head_ref). No schemaVersion bump: it is a new index, not new rows, and CREATE INDEX IF NOT EXISTS runs 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.

Discussion (0)

No discussion yet.