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.

Run the integration tests in parallel: 168s of tests becomes 16s

44530e746def72216bf02e88716f121936576ab7 · Keenan Nemetz · 17d ago · parent 6c03e461

Run the integration tests in parallel: 168s of tests becomes 16s

The suite was serial, and it is the wrong shape to be serial. 95 tests in
internal/server each start a server, push over SSH and shell out to git, so
they spend nearly all their time waiting on child processes and almost none
on CPU: the package took 148s wall for 23s of user time. Sixteen cores were
sitting idle while tests waited for git.

Every test now calls t.Parallel(). Nothing else about the fixtures had to
change, because they were already isolated the way parallelism needs: each
gets t.TempDir(), binds 127.0.0.1:0, and passes its environment through
cmd.Env rather than the process's. binPath is built once by TestMain and is
read-only.

Measured on 16 cores: internal/server 148.4s -> 12.8s, the whole suite
168s -> 16s, and `scripts/ci.sh` (gofmt, vet, build, link check, then the
whole suite under -race) 19.5s end to end. Verified stable across six
repeated runs, two `-shuffle=on` runs and two `-race` runs, with no data
races reported, so the parallelism is exposing isolation the fixtures
already had rather than luck.

The critical path is now one test, TestKilledRunnerIsRequeued, because it is
genuinely a timing test: it waits for a lease to expire and for the requeue
sweep. Its fixture workflow held the step for `sleep 8`, which also runs on
the retry, so eight of its fourteen seconds were the fixture waiting for
itself. Three seconds keeps the property it is testing — the runner must
still be working when it is killed, and the kill happens as soon as the lease
is visible — and takes the test to 9.7s.

What is left is fixture setup, and most of it is SQLite schema creation:
runs.Open is ~89ms and index.Open ~58ms on a *fresh* file. Measured, that is
not the eleven ALTER TABLE statements that are expected to fail on "duplicate
column" — on a warm connection those cost 2us for all eleven. It is the
CREATE TABLE / CREATE INDEX work itself, and modernc's pure-Go engine is much
slower at DDL than C SQLite. Reopening an already-created database is 408us,
so this is a cost paid once per database file, not once per open: in
production it is `vole init` and nowhere else. Tests pay it 95 times because
every fixture starts from an empty data directory.

Left alone deliberately, for two reasons. It is ~1s of wall time across the
suite, and the only fixes available are test-side (build the empty databases
once in TestMain and copy the files per fixture) or would mean changing
storage engines — which is the wrong conclusion from 150ms, and is not what
those 89ms are about.

The invariant is written down at the top of internal/server's test file, with
the rules a new test has to keep: no t.Setenv, no fixed paths, no shared
state beyond binPath, no ordering assumptions, and the shortest sleep a
fixture can get away with — because a test's own wait sets the floor for the
whole package and parallelism cannot hide it.

internal/gitx and cmd/vole keep `t.Setenv` tests, which cannot be parallel,
so those packages are unchanged. They take 0.2s and 0.5s.

internal/issues/issues_test.go +15

@@ -33,6 +33,7 @@ func validContents() map[string]string {
33 33 }
34 34
35 35 func TestParseValid(t *testing.T) {
36 t.Parallel()
36 37 list, err := Parse(validContents())
37 38 if err != nil {
38 39 t.Fatalf("unexpected error: %v", err)
@@ -63,6 +64,7 @@ func TestParseValid(t *testing.T) {
63 64 }
64 65
65 66 func TestParseIgnoresUnrelatedFiles(t *testing.T) {
67 t.Parallel()
66 68 c := validContents()
67 69 c["README.md"] = "notes about the issues branch\n"
68 70 if _, err := Parse(c); err != nil {
@@ -75,6 +77,7 @@ func TestParseIgnoresUnrelatedFiles(t *testing.T) {
75 77 // unquoted `#` truncates, a leading `-` reads as a sequence, `null` reads as
76 78 // nothing — which is why free text is always quoted.
77 79 func TestRenderParseRoundTrip(t *testing.T) {
80 t.Parallel()
78 81 titles := []string{
79 82 "crash on startup",
80 83 "crash on a<b> & c",
@@ -124,6 +127,7 @@ func TestRenderParseRoundTrip(t *testing.T) {
124 127 // be something the roster could issue. Anything else — a capital, a dot, a
125 128 // leading @ — is a typo that would render as a filter link to nobody.
126 129 func TestAssigneesAreAccountNames(t *testing.T) {
130 t.Parallel()
127 131 // Build the files the way a writer would, so the validator is what refuses
128 132 // rather than YAML refusing to parse the line first.
129 133 mk := func(assignees ...string) map[string]string {
@@ -166,6 +170,7 @@ func TestAssigneesAreAccountNames(t *testing.T) {
166 170 // name rule honest. A name the roster would accept but an issue would reject
167 171 // (or the reverse) is a bug that only shows up as a rejected push.
168 172 func TestAssigneeRuleMatchesAccountNames(t *testing.T) {
173 t.Parallel()
169 174 for _, s := range []string{"keenan", "wilbur-2", "a_b", "x1", "@keenan", "Keenan", "a.b",
170 175 "a", strings.Repeat("a", 32), strings.Repeat("a", 33), "with space"} {
171 176 want := identity.ValidateName(s) == nil
@@ -178,6 +183,7 @@ func TestAssigneeRuleMatchesAccountNames(t *testing.T) {
178 183 // TestAssigneeRoundTrip: what the writer emits is what the reader reads, and the
179 184 // list is written sorted so an assignment is one added line.
180 185 func TestAssigneeRoundTrip(t *testing.T) {
186 t.Parallel()
181 187 iss := &Issue{
182 188 ID: idA,
183 189 Title: "assigned",
@@ -206,6 +212,7 @@ func TestAssigneeRoundTrip(t *testing.T) {
206 212 // TestAnchorRoundTrips: an anchored comment is four extra fields on the same file
207 213 // shape, and the writer and reader have to agree about all of them.
208 214 func TestAnchorRoundTrips(t *testing.T) {
215 t.Parallel()
209 216 c := &Comment{
210 217 ID: idB,
211 218 Author: "keenan",
@@ -245,6 +252,7 @@ func TestAnchorRoundTrips(t *testing.T) {
245 252 // TestAnchorMustBeWhole: half an anchor, or one pointing outside the repository,
246 253 // is a mistake that would render as a link to nowhere.
247 254 func TestAnchorMustBeWhole(t *testing.T) {
255 t.Parallel()
248 256 const blob = "0123456789abcdef0123456789abcdef01234567"
249 257 cases := []struct{ name, head string }{
250 258 {"line alone", "author: keenan\ncreated: 2026-10-23T09:00:00Z\nline: 4\n"},
@@ -271,6 +279,7 @@ func TestAnchorMustBeWhole(t *testing.T) {
271 279 // belong to a different entity — an issue file's title, state and labels are not
272 280 // the reader's business.
273 281 func TestAuthorOfReadsAnyMetadataFile(t *testing.T) {
282 t.Parallel()
274 283 comment := "---\nauthor: wilbur\ncreated: 2026-10-22T21:01:00Z\n---\nwords\n"
275 284 if got, err := AuthorOf([]byte(comment)); err != nil || got != "wilbur" {
276 285 t.Fatalf("comment author = %q, %v", got, err)
@@ -291,6 +300,7 @@ func TestAuthorOfReadsAnyMetadataFile(t *testing.T) {
291 300 // TestCommentPathMatchesTheReader: the writer and the validator have to agree
292 301 // about where a comment lives, or an edit commits to a path nobody reads.
293 302 func TestCommentPathMatchesTheReader(t *testing.T) {
303 t.Parallel()
294 304 path := CommentPath(idA, idB)
295 305 if path != "issues/"+idA+"/comments/"+idB+".md" {
296 306 t.Fatalf("path = %q", path)
@@ -309,6 +319,7 @@ func TestCommentPathMatchesTheReader(t *testing.T) {
309 319 // TestUnquotedTitleIsRefused: a hand-edit that drops the quotes on a title with
310 320 // " # " would otherwise store a shorter title and say nothing.
311 321 func TestUnquotedTitleIsRefused(t *testing.T) {
322 t.Parallel()
312 323 c := validContents()
313 324 c["issues/"+idA+"/issue.md"] = "---\ntitle: fix #12 crash\nstate: open\nauthor: keenan\n" +
314 325 "created: 2026-10-22T21:00:00Z\n---\nbody\n"
@@ -325,6 +336,7 @@ func TestUnquotedTitleIsRefused(t *testing.T) {
325 336 // TestTitleOnOneLine: the writer refuses control characters, so the reader must
326 337 // too — otherwise a tree the server accepts is a tree nobody can edit again.
327 338 func TestTitleOnOneLine(t *testing.T) {
339 t.Parallel()
328 340 c := validContents()
329 341 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"two\\nlines\"\nstate: open\nauthor: keenan\n" +
330 342 "created: 2026-10-22T21:00:00Z\n---\nbody\n"
@@ -334,6 +346,7 @@ func TestTitleOnOneLine(t *testing.T) {
334 346 }
335 347
336 348 func TestValidationFailures(t *testing.T) {
349 t.Parallel()
337 350 for name, mutate := range map[string]func(map[string]string){
338 351 "bad ulid directory": func(c map[string]string) {
339 352 c["issues/not-a-ulid/issue.md"] = "---\ntitle: \"x\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
@@ -397,6 +410,7 @@ func TestValidationFailures(t *testing.T) {
397 410 }
398 411
399 412 func TestAllProblemsReported(t *testing.T) {
413 t.Parallel()
400 414 c := validContents()
401 415 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"\"\nstate: nope\nauthor: \"\"\ncreated: \"\"\n---\n"
402 416 _, err := Parse(c)
@@ -410,6 +424,7 @@ func TestAllProblemsReported(t *testing.T) {
410 424 }
411 425
412 426 func TestULIDAndOrdering(t *testing.T) {
427 t.Parallel()
413 428 a, err := NewULID(time.Unix(1700000000, 0))
414 429 if err != nil {
415 430 t.Fatal(err)