Skip to content

telemetry: fix the flaky consent-cache TTL test that broke CI on main - #643

Merged
zzet merged 1 commit into
mainfrom
fix/telemetry-consent-cache-flake
Aug 20, 2026
Merged

telemetry: fix the flaky consent-cache TTL test that broke CI on main#643
zzet merged 1 commit into
mainfrom
fix/telemetry-consent-cache-flake

Conversation

@zzet

@zzet zzet commented Aug 20, 2026

Copy link
Copy Markdown
Owner

What broke

The test (ubuntu-latest, 1.26) leg of CI failed on main:

--- FAIL: TestCachedConsentResolver (0.07s)
    recorder_test.go:159: within TTL the cached value should persist
FAIL	github.com/zzet/gortex/internal/telemetry	0.115s

Nothing in the merged change touched telemetry — the test is timing-dependent.

Root cause

TestCachedConsentResolver built a resolver with a 20 ms TTL against the real
clock, then:

  1. resolve() → primes the cache with enabled=true and stamps checkedAt
  2. SaveConsent(dir, false, …) → a file write to disk
  3. resolve() → asserts the cached true is still served

Step 2 is the problem. CachedConsentResolver re-reads whenever
now.Sub(checkedAt) >= ttl, so the assertion in step 3 only holds if the disk
write plus scheduler latency finishes inside 20 ms. On a loaded runner — the
ubuntu leg runs go test ./... with coverage across packages that take 90–145 s
apiece — it doesn't, the cache expires legitimately, and the test reports a bug
that isn't there. macOS passed on both attempts, which is the signature of a
margin that is too thin rather than broken logic.

Fix

Add an unexported cachedConsentResolver(dir, ttl, clock); the exported
CachedConsentResolver is now a one-line wrapper passing time.Now and keeps
its signature (no caller changes — 4 direct dependents, all untouched).

The test steps that clock across the boundary instead of racing it:

  • base + (ttl-1) → still cached, must return the stale true
  • base + ttl → expired, must re-read and return false

That asserts the boundary exactly rather than approximately, and drops a
30 ms time.Sleep from the suite. A new TestCachedConsentResolverUsesRealClock
keeps the exported constructor's wiring (dir + real clock) covered.

Verification

The rewritten test still catches a broken cache — checked by neutering the
implementation three ways and confirming each is caught:

Injected defect Result
never cache (>= 0) ✅ fails on within TTL the cached value should persist
never expire (if !valid) ✅ fails on after TTL the resolver should re-read
off-by-one (> ttl for >= ttl) ✅ fails on the boundary assertion
unmodified ✅ passes

go test -race -count=3 ./internal/telemetry/... green; golangci-lint run internal/telemetry/... reports 0 issues; cmd/gortex and internal/serverstack
still build.

TestCachedConsentResolver raced a real 20ms deadline: it primed the cache,
wrote a second consent file to disk, and then asserted the cached value had
not yet expired. On a loaded runner the SaveConsent write plus scheduling
delay between those two calls can exceed 20ms, the cache re-reads, and the
"within TTL the cached value should persist" assertion fails — which is what
took down the ubuntu leg of CI on main while macOS passed.

Give CachedConsentResolver an unexported constructor that takes a clock and
have the test step it across the TTL boundary (ttl-1, then exactly ttl), so
the boundary under test is asserted exactly instead of approximately. The
exported constructor keeps its signature and gains a small test covering the
real-clock wiring. Drops a 30ms sleep from the suite as a side effect.
@zzet
zzet merged commit 59962ab into main Aug 20, 2026
10 checks passed
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