Skip to content

test(migrations): run integration tests in parallel; harden shared test server - #1401

Merged
IAmJSD merged 3 commits into
LeanerCloud:mainfrom
IAmJSD:test/migrations-parallel
Oct 25, 2026
Merged

IAmJSD merged 3 commits into
LeanerCloud:mainfrom
IAmJSD:test/migrations-parallel

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

Why

On main's CI run 40153816430 (after #1400 moved integration tests onto one shared Postgres server per package), the Integration Tests job took 531s. Its test step was 480s of wall time holding 856s of package time on a 4-vCPU runner. internal/database/postgres/migrations was the slowest package at 190s (152 tests), and TestMigrations_DownAllRoundTrip alone took 21s. Most of the package's time went to tests walking the 127 migrations one after another. Since #1400 each test has its own database (RequireEmptyDatabase / RequireMigratedDatabase), so most of them can run at once.

What changed

Three commits:

  1. fix(testhelpers): hardens the shared test server for parallel callers. These are the three non-blocking findings from the review of test: share one Postgres server per package across integration tests #1400:
    • The migrated template was built under the first caller's context, and the result, errors included, is cached for the whole binary. A canceled or timed-out first caller made every later RequireMigratedDatabase call fail. The build now runs under context.WithoutCancel with its own 10-minute backstop, the same way start() already handles the container.
    • The Docker health probe ran on every call. Under load, a probe that times out would skip a test whose database is reachable, which is a #1597-style false green. It now runs only until the shared server is up (checked under the mutex).
    • dropDatabase read s.admin without the mutex while TerminateSharedPostgres sets it to nil. It now holds the mutex and treats a stopped server as nothing left to drop.
    • Three new tests in shared_integration_test.go, one per fix. Each fails on the old code: a cached template error, a nil-pointer panic in cleanup, and a probe on a running server.
  2. test(testhelpers): raises max_connections on the shared server to 500. In the worst case each running test holds about 13 connections (a pool of up to 10, the migrator's connection, and a spare session), and the admin pool adds up to 10 more. At -parallel=16 that is over 200, past PostgreSQL's default of 100. I raised the server limit instead of shrinking the per-database pool because it is the less invasive change: every test's pool stays the same size, so no test that holds several connections can end up waiting on its own pool. Only the shared server gets the flag, through an unexported setupPostgresContainer(..., extra...). SetupPostgresContainer and dedicated containers are unchanged.
  3. test(migrations): runs the integration tests in parallel.
    • 62 of 69 top-level tests call t.Parallel().
    • Table subtests are parallel only where each subtest creates its own database and the parent holds none. That covers 000064, 000065 (both tests), 000086, 000088, 000096, ensure_admin_user, savings_snapshots_pk and split_savingsplans. Subtests that share the parent's database stay sequential (000078, 000095, 000102, 000128, 000132).
    • Seven tests stay sequential, each with a one-line reason:
      • TestMigrations_AutoHealDirty: t.Setenv.
      • TestPGPasswordAndKeywordDSN_ConnectWithHostilePassword: t.Setenv. It also keeps its dedicated server because of ALTER ROLE.
      • TestAdminBootstrap_LogsToStderr_NotStdout: swaps os.Stdout and the standard logger.
      • The four deadline/cancel tests in migrate_cancel_integration_test.go: they assert wall-clock bounds.
    • The parallel tests that use advisory locks or pg_stat_activity only see their own database. Advisory locks are per database (I checked: the same key is lockable from two databases at once). The pg_stat_activity queries filter by PID or current_database().
    • The doc comments on captureStdout and captureLogOutput now say their callers must not be parallel.

No assertion was changed or removed.

Verification

All runs used the -race -tags integration test binary (go test -c -race -tags integration, then run from the package directory). The machine has 16 cores and was shared with other agents' container workloads (load average 7 to 17), so absolute times are noisy.

Run Wall time Result Peak connections (all / client backends)
origin/main, sequential (-test.parallel default, nothing parallel) 252s 152 pass n/a
this branch, -test.parallel=4 (CI-like) 111s (177s in an earlier run at load 15 to 17) 152 pass 19 / 14 (21 / 16 in the earlier run)
this branch, -test.parallel=16 81s 152 pass 50 / 45
-test.shuffle=on x3 (seeds 1792896703861082493, 1792896816583106797, 1792896954462828728) 113s / 138s / 140s (golangci-lint was running at the same time) 152 pass each 48 to 54
-test.count=2 165s 304 pass 51 / 46
  • Peak connections came from sampling SELECT count(*) FROM pg_stat_activity every ~0.2s via docker exec on the shared container during each run.
  • go test -race -tags integration -v ./internal/database/postgres/testhelpers/: all 16 tests pass, including the false-green guard tests and the three new ones.
  • go test -race -short for testhelpers and migrations: pass.
  • golangci-lint v2.10.1 on both packages: 0 issues. pre-commit on the changed files: pass. trivy config (docker, v0.69.3): exit 0. git-secrets scan and scripts/test-git-secrets-allowlist.sh: clean.

Not verified locally:

  • Timing on the real 4-vCPU CI runner. There, packages also run side by side (go test -p 4), so CPU contention will be higher than in my -parallel=4 run.

Expected effect

  • The migrations package should drop from about 190s toward roughly half on CI. Locally it fell from 252s to 111s at -parallel=4.
  • The critical path is now TestMigrations_DownAllRoundTrip plus the sequential tests (AutoHealDirty, which runs the full chain four times, the PGPASSWORD test with its own container, and a few short ones).
  • How much the job's wall time drops depends on how much CPU headroom the runner has left with four packages in flight.

Note for cristim

  • The max_connections=500 flag only applies to the shared server and doesn't change any pool size. If you'd rather cap pools instead (for example, MaxConnections 4 for shared test databases), that's a one-line alternative, but it could make a test that holds several connections at once wait on its own pool.
  • TestMigrations_AutoHealDirty stays sequential because of t.Setenv, and it is now one of the longer items on the critical path. A follow-up could start its subtests from RequireMigratedDatabase instead of migrating an empty database four times. I left it out here to keep this PR to parallelism only.

🤖 Generated with Claude Code

IAmJSD and others added 3 commits October 25, 2026 02:52
A review of LeanerCloud#1400 found three issues in shared.go that bite harder once
tests on the shared server run in parallel:

- The migrated template was built under the first caller's context and
  its result, errors included, is cached for the whole binary. A first
  caller that was canceled or hit its deadline mid-build failed every
  later RequireMigratedDatabase call. The build now runs detached from
  the caller, like the container start, with its own 10-minute backstop.
- The Docker health probe ran on every call. Under CI load a probe that
  times out would skip a test whose database is reachable, a #1597-style
  false green. It now runs only until the shared server is up.
- dropDatabase read s.admin without the mutex while
  TerminateSharedPostgres sets it to nil, so a cleanup after Terminate
  could race or panic. It now holds the mutex and treats a stopped
  server as nothing left to drop.

Each fix has a test that fails without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests on the shared server are about to run in parallel. Each running
test can hold a pool of up to 10 connections plus the migrator's own and
a spare session, about 13, and the admin pool adds up to 10. At the
default -parallel (GOMAXPROCS, 16 on a dev machine) that worst case is
over 200, past PostgreSQL's default max_connections of 100.

Raising the server's limit to 500 is the least invasive fix: every
test's pool keeps its size, so no test that holds several connections
at once can start waiting on its own pool. Dedicated containers from
SetupPostgresContainer are unchanged; only the shared server gets the
flag, through an unexported variant that takes extra container options.

Measured peak in the migrations package: 50 connections at -parallel=16,
21 at -parallel=4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since LeanerCloud#1400 every integration test here gets its own database on the
shared server, so most of them can run at once. The package was the
slowest integration package on CI (190s), mostly tests walking the
migration chain one after another.

62 of 69 top-level tests now call t.Parallel, and table subtests do too
where each subtest creates its own database and the parent holds none.
Subtests that share their parent's database stay sequential.

Seven tests stay sequential, each with a one-line reason:
- t.Setenv: TestMigrations_AutoHealDirty, and
  TestPGPasswordAndKeywordDSN_ConnectWithHostilePassword (which also
  keeps its dedicated server for ALTER ROLE)
- swaps os.Stdout and the standard logger:
  TestAdminBootstrap_LogsToStderr_NotStdout
- wall-clock deadline assertions: the four deadline and cancel tests in
  migrate_cancel_integration_test.go

Advisory locks and pg_stat_activity checks in the parallel tests are
scoped to the test's own database (advisory locks are per database;
the pg_stat_activity queries filter by PID or current_database()), so
other databases cannot affect them.

Local wall time of the -race test binary: 252s sequentially on
origin/main, 111s at -parallel=4, 81s at -parallel=16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@IAmJSD
IAmJSD marked this pull request as ready for review October 25, 2026 03:29
@IAmJSD
IAmJSD merged commit 8793aa3 into LeanerCloud:main Oct 25, 2026
26 of 27 checks passed
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
Brings in LeanerCloud#1401 (parallel migrations tests) and gives the per-shard
build caches a second, warm run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant