Skip to content

test: wait on goroutines and barriers instead of wall-clock sleeps in Go tests - #1298

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-36
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-36

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Root cause

Several Go tests used time.Sleep as a stand-in for synchronization: wait "long enough" for a goroutine to fire (or not fire), for a background write to land, or for workers to overlap. Those tests either flake under CI load or, worse, pass when the behavior they guard is broken. The 20ms negative windows in the scheduler tests are false-green against a slow wrongly-spawned goroutine, and the 5ms fan-out "overlap" sleep passed against a serial loop.

Fix (test code only, no production change)

  • New internal/testutil/goroutines: a small helper with no dependencies (a separate package, like testutil/lambdatimeout, so scheduler and purchase tests can import it without a cycle). It parses runtime.Stack(all=true), which stops the world. A goroutine shows up in that dump as soon as its go statement runs, scheduled or not, and stays until its function returns. Mark.Spawned() / Mark.StartedBy() / Mark.Started() list goroutines started after a mark (goroutine IDs are never reused). It has its own unit tests, including one for a goroutine that hasn't been scheduled yet and one for nested spawns.
  • scheduler
    • TestScheduler_ListRecommendations_LambdaSkipsBackgroundRefresh, TestListRecommendations_UnmarkedColdStart_NeverMarksOrCollects, TestListRecommendations_UnmarkedStaleCache_NeverRefreshes: the 20ms sleep is gone. The new assertNoBackgroundCollect takes a goroutine snapshot first and then checks the GetGlobalConfig call log. A wrongly spawned collect is either still alive (it's in the snapshot) or already finished (and then it called GetGlobalConfig, which the mock records whether or not it was expected). There's no window left for it to slip through.
    • TestFanOutPerAccount_RespectsParallelismLimit: every fake call now parks on a channel. The test waits until the fan-out goroutine is blocked in errgroup.(*Group).Go on the limit's semaphore and every worker it started has entered fn, then reads the in-flight count, which must be exactly 3. It also asserts peak == 3 (the old <= 3 passed on a serial loop).
  • api
    • TestRIUtilizationCache_StaleOnNonLambdaServesStaleAndKicksRefresh, plus its integration twin TestRIUtilizationCache_Integration_StaleNonLambdaServesStaleAndRefreshes: the fetcher blocks until the test releases it, so getOrFetch returning at all proves SWR served the stale copy. That replaces the elapsed < 50ms / < 100ms bounds. require.Eventually (10s, 1ms/20ms polls) replaces the sleep-poll loops.
    • TestRIUtilizationCache_SingleflightCollapsesConcurrentRefreshes: the 250ms head-start sleep and the 200ms settle sleep are gone. The test releases the fetch only once all 10 refreshes are accounted for: each one either called the fetcher or is parked in singleflight.(*Group).Do on the in-flight call's WaitGroup as a duplicate. After that it waits for the fresh row to land and for every refresh goroutine to return.
    • TestDBRateLimiter_maybeCleanup_ReadyToCleanup used to assert nothing; it now checks lastCleanup was stamped and waits for the cleanup goroutine to exit and release cleanupRunning. TestDBRateLimiter_StartCleanupWorker_StopsOnContextCancel was vacuous: its done channel closed as soon as StartCleanupWorker returned. It now checks a worker goroutine was started and waits for that goroutine to exit after cancel(). It no longer shortens dbRateLimiterScheduledCleanupInterval. The worker reads that variable on its own goroutine, and once the sleep is gone, restoring it in t.Cleanup is a race the race detector reports (1 in 20 under -race). A tick isn't observable with a nil pool anyway.
  • purchase TestExecuteMultiAccount_RunsAccountsInParallel: a two-party barrier in PurchaseCommitment replaces the 300ms sleep and the elapsed < 500ms bound. Parallel calls meet at once; a serial loop leaves the first call alone at the barrier until it gives up after 10s, and the test fails on the overlap count.
  • server TestScheduledTaskIntegration (integration tag): drops the 100ms "simulate actual work" sleep and asserts the collect ran exactly once.

The 10s limits are only there so a broken build fails instead of hanging; no test asserts on elapsed time.

Refs #36

This is partial: the frontend waits are left for the UI owner (below).

Verification

All evidence below is unit/mock/fixture level, plus one real-Postgres integration test (testcontainers). Tests ran with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true.

Build, vet, lint:

go build ./...                                    # ok
go vet ./... && go vet -tags integration ./...    # ok
golangci-lint v2.10.1 run --allow-parallel-runners [--build-tags integration] \
  ./internal/testutil/goroutines/ ./internal/scheduler/ ./internal/purchase/ ./internal/server/ ./internal/api/   # 0 issues (both)
gocyclo -over 10 -ignore "_test\.go" internal/testutil/goroutines/                                               # clean

Unit packages touched, -count=1: ./internal/testutil/goroutines/, ./internal/scheduler/, ./internal/purchase/, ./internal/server/, ./internal/api/ and ./internal/mocks/ all ok.

Stability, -race -count=20 on every converted test, all pass:

go test -race -count=20 -run '^(TestScheduler_ListRecommendations_LambdaSkipsBackgroundRefresh|TestListRecommendations_UnmarkedColdStart_NeverMarksOrCollects|TestListRecommendations_UnmarkedStaleCache_NeverRefreshes|TestFanOutPerAccount_RespectsParallelismLimit)$' ./internal/scheduler/   # 4 x 20 PASS
go test -race -count=20 -run '^(TestRIUtilizationCache_StaleOnNonLambdaServesStaleAndKicksRefresh|TestRIUtilizationCache_SingleflightCollapsesConcurrentRefreshes|TestDBRateLimiter_maybeCleanup_ReadyToCleanup|TestDBRateLimiter_StartCleanupWorker_StopsOnContextCancel)$' ./internal/api/   # 4 x 20 PASS
go test -race -count=20 -run '^TestExecuteMultiAccount_RunsAccountsInParallel$' ./internal/purchase/      # 20 PASS
go test -race -count=20 ./internal/testutil/goroutines/                                                  # 3 x 20 PASS (also -count=200: ok)
go test -tags integration -race -count=20 -run '^TestScheduledTaskIntegration$' ./internal/server/         # 20 PASS
go test -tags integration -race -count=5 -timeout 20m -run '^TestRIUtilizationCache_Integration_StaleNonLambdaServesStaleAndRefreshes$' ./internal/api/   # 5 PASS (real Postgres)

An earlier -count=20 integration run on a busy shared machine gave 19 PASS and 1 FAIL. The failure was inside the container/migration setup (setupRICacheIntegration, line 35), before any of the changed code runs. A later -count=5 run on the final code passed 5/5.

Mutation probes. Each one changes production code, runs the converted test, and restores the file with git checkout:

Mutation (production code) Test Result
drop !s.isLambda from the refresh guard in ListRecommendations LambdaSkipsBackgroundRefresh 20/20 FAIL (GetGlobalConfig called)
Lambda read spawns go func(){ sleep 50ms; CollectRecommendations }() LambdaSkipsBackgroundRefresh 20/20 FAIL ("the read started a background goroutine")
drop mayCollect from the cold-start condition UnmarkedColdStart 20/20 FAIL
unmarked cold read spawns a 50ms-delayed collect UnmarkedColdStart 20/20 FAIL
drop mayCollect from the refresh guard UnmarkedStaleCache 20/20 FAIL
unmarked stale read spawns a 50ms-delayed collect UnmarkedStaleCache 20/20 FAIL
g.SetLimit(-1) (no limit) in fanOutPerAccount RespectsParallelismLimit 3/3 FAIL (never parks)
g.SetLimit(limit+1) RespectsParallelismLimit 20/20 FAIL (bound 4)
g.SetLimit(1) (serial) RespectsParallelismLimit 20/20 FAIL (bound 1)
SWR branch fetches synchronously (if true || c.isLambda) StaleOnNonLambda… (unit + integration) FAIL (2/2 unit, 1/1 integration)
no kickBackgroundRefresh call StaleOnNonLambda… 2/2 FAIL
background refresh doesn't store the payload StaleOnNonLambda… (unit + integration) FAIL (2/2 unit, 1/1 integration)
singleflight removed (call fn directly) Singleflight… 20/20 FAIL (got 10 fetches)
per-call singleflight key Singleflight… 20/20 FAIL (got 10 fetches)
cleanup goroutine never releases cleanupRunning maybeCleanup_ReadyToCleanup 2/2 FAIL
lastCleanup not stamped maybeCleanup_ReadyToCleanup 20/20 FAIL
worker ignores ctx cancel StartCleanupWorker… 2/2 FAIL
worker never started StartCleanupWorker… 20/20 FAIL
executeMultiAccount concurrency forced to 1 RunsAccountsInParallel 2/2 FAIL
collect task returns without calling the scheduler TestScheduledTaskIntegration 20/20 FAIL

Fail-before evidence for the false greens: on origin/main, with the 50ms-delayed-spawn mutations applied, the old Lambda and unmarked-read tests passed 60/60 (-count=20 each). With g.SetLimit(1), the old fan-out test passed 20/20.

Conflicts with in-flight branches: git merge-tree of this branch against fork/fix/issue-111, fix/issue-326, fix/issue-343, fix/issue-353 and fix/issue-84 (#1099) is clean. #111 edits scheduler_test.go only around lines 465-600, and #353 edits only TestVersionEndpoint in tests/e2e/e2e_test.go. No conversion had to be deferred.

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Sleeps kept on purpose

Site Why it stays
internal/api/inmemory_rate_limiter_test.go:113, :201 They test a real TTL window (10ms / 1ms), and InMemoryRateLimiter calls time.Now() directly with no clock to inject. A sleep is a lower bound, so "sleep 2x the window, then expect expiry" can't flake. (See residual risk below.)
internal/database/advisory_lock_holder_integration_test.go:93 Asserts the lock's age as computed by Postgres (>= 1s): this is real elapsed time on the DB clock.
internal/config/store_postgres_no_submission_integration_test.go:72 updated_at is set by a DB trigger; the row has to age past the 1s floor on the DB clock.
internal/server/http_scheduled_context_test.go:96 The handler must outlive a shortened WriteTimeout (100ms); the 400ms is the behavior under test.
internal/api/ladder_resolve_race_integration_test.go:113, :117; internal/purchase/plan_step_fail_atomic_integration_test.go:247 Deliberate head starts that vary the interleaving across race rounds. They don't synchronize anything, and correctness is asserted for every round whichever side wins.
internal/server/http_shutdown_test.go:81, :115; http_scheduled_context_test.go:173; internal/purchase/plan_step_fail_atomic_integration_test.go:151; internal/config/store_postgres_ri_exchange_approved_reservation_integration_test.go:158; internal/server/scheduledauth/kidguard_test.go:216 Already hand-rolled "poll until a condition, with a generous deadline" loops (2-10s) over a real listener, pg_stat_activity, or go-oidc's in-flight fetch. They are the require.Eventually pattern written out; converting them would be cosmetic.
tests/e2e/e2e_test.go:60 The 2s poll interval of a deadline-bounded readiness loop against the docker-compose stack. That module is stdlib-only by design (no testify), and #353 also has that file open.

Out of scope / residual risk (not changed here)

  • TestInMemoryRateLimiter_Allow_WindowExpiry asserts that the second request is blocked inside a 10ms window. A pause longer than 10ms between the first and second Allow would let it through. A real fix needs a clock seam in InMemoryRateLimiter, which is a production change and was left alone.
  • TestStartHTTPServerWaitsForBackgroundInitOnShutdown uses a 300ms time.After negative window ("did not return while init is parked"). It's not a sleep, but it has the same false-green shape against a slow wrong return.
  • The fan-out test and the singleflight test recognize goroutine states by stack text ([chan send + errgroup.(*Group).Go(, singleflight.(*Group).Do( + sync.(*WaitGroup).Wait(). If an x/sync upgrade changes those internals, the tests fail loudly (the Eventually times out). They can't silently pass.

Frontend follow-up for the UI owner

No frontend/ changes here. The frontend Jest suites now have 112 fixed non-zero waits (await new Promise(resolve => setTimeout(resolve, N))): 49 at 50ms, 54 at 10ms, 6 at 20ms and 3 at 100ms. The issue counted ~70. There are also 219 zero-delay setTimeout(resolve, 0) macrotask flushes. Non-zero waits per file:

File Waits
frontend/src/__tests__/auth.test.ts (incl. lines 126, 162 from the issue) 39
frontend/src/__tests__/plans.test.ts 34
frontend/src/__tests__/auth-mfa-enroll.test.ts 11
frontend/src/__tests__/history-retry-button.test.ts 6
frontend/src/__tests__/savings-history.test.ts 4
frontend/src/__tests__/history-approve-button.test.ts 4
frontend/src/__tests__/history-cancel-button.test.ts 3
frontend/src/__tests__/history-approval-queue.test.ts 3
frontend/src/__tests__/dashboard.test.ts 3
frontend/src/__tests__/plans-service-ids.test.ts 2
frontend/src/__tests__/plans-range-validation.test.ts 2
frontend/src/__tests__/riexchange.test.ts 1

Suggested approach, in order of preference:

  1. Await the mocked API promise the handler is waiting on (e.g. keep the mockResolvedValue promise and await it, then flush microtasks with await Promise.resolve()), or use @testing-library's waitFor(() => expect(api.login).toHaveBeenCalled...) if it's added.
  2. For code that really waits on a timer (debounce, toast timeouts), use jest.useFakeTimers() with await jest.advanceTimersByTimeAsync(ms); 16 suites already use fake timers.
  3. A shared flushPromises() helper would replace most of the zero-delay flushes.

Labels

A maintainer, please mirror the issue's triage labels onto this PR: impact/internal, type/chore, triaged, priority/p3, severity/low, urgency/eventually, effort/m.

🤖 Generated with Claude Code

… Go tests

Replace the Go test sleeps that stood in for async synchronization with
waits that cannot lose to a slow scheduler:

- internal/testutil/goroutines: a dependency-free helper that reads
  runtime.Stack(all) to list goroutines started after a mark. A go
  statement is in the dump as soon as it executes, so a negative check
  ("this read spawned nothing") no longer needs a sleep window.
- scheduler: the Lambda and unmarked-read "no background refresh"
  tests check the goroutine snapshot plus the GetGlobalConfig call log
  instead of sleeping 20ms; the fan-out limit test parks every worker
  and reads the in-flight count once errgroup.Go is blocked on the
  limit, instead of a 5ms "overlap" sleep that passed on a serial loop.
- api: the RI-utilization SWR tests gate the fetcher on a channel and
  wait with require.Eventually (no elapsed-time bound, no 250ms/200ms
  sleeps); the singleflight test releases only after all 10 refreshes
  are inside Group.Do. The DB rate limiter cleanup tests wait for the
  cleanup goroutine to exit instead of sleeping.
- purchase: the multi-account parallelism test uses a two-party
  barrier instead of a 300ms sleep and an elapsed-time bound.
- server: the scheduled-task integration test counts the collect call
  instead of sleeping 100ms.

Sleeps that test real elapsed time (TTL windows, DB-clock ages) or
poll an external system with a generous deadline are kept. The
frontend setTimeout waits are left for the UI owner.

Refs LeanerCloud#36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added impact/internal Team-internal only type/chore Maintenance / non-user-visible triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline effort/m Days labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant