Skip to content

test(controller): stop waiting on a CRD rebuild the apiserver may never do - #357

Merged
sunib merged 2 commits into
mainfrom
fix/live-message-migration-crd-race
Sep 10, 2026
Merged

sunib merged 2 commits into
mainfrom
fix/live-message-migration-crd-race

Conversation

@sunib

@sunib sunib commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

What

TestLiveMessageMigration_StoredValuesAndRemoval failed roughly a third of the time on a loaded machine — on main as much as on any branch — and has been costing reruns on pull requests that have nothing to do with it. It took one out on #354.

This removes the race rather than tuning it.

It is not a slow wait, it is a stuck one

To store a pre-migration GitTarget the test has to get a shape past the validation that forbids it. So it stripped the rules off the CRD, wrote the objects, put the rules back, and waited for the apiserver to enforce them again. That last wait is what fails.

The apiextensions apiserver rebuilds a CRD's serving handler asynchronously after a spec write, and under CPU pressure that rebuild is sometimes missed outright. Instrumenting the failing wait:

DIAG polls=571 lastErr=<nil>
DIAG live CRD generation=3 XValidations=2
DIAG   rule[0]=!has(self.eventTemplate)
DIAG   rule[1]=!has(self.groupTemplate)

571 applies across 60 seconds, every one accepted — while the restored rules sit in the live CRD, readable. The previously compiled schema just goes on validating, and nothing retriggers it. Confirmed by probe: one further metadata-only write to the CRD made enforcement immediate.

So a longer timeout cannot work, and does not

Measured directly, because it was the obvious first thing to try:

duration
every run that passed 4.5s – 6.3s (inside the original 10s)
every run that failed used its entire budget — 10s or 60s alike

Nothing ever landed in between. The outcome is bimodal: converge in about two seconds, or never. Widening 10s → 60s rescued exactly zero runs and added 50 seconds to each failure.

What was tried, under one stressor

approach failures
original (10s) 5/10
widen to 60s 2/10
rewrite the CRD on every poll 4/12 — worse
one write per 5s settle window 3/12
split, strip at runtime 0/40
strip at install time (this PR) 0/20, and no race left to lose

Rewriting each poll is actively harmful: every write invalidates the rebuild already in flight, so it never converges.

The change

Two tests, and neither writes to a CRD at all:

  • TestLiveMessageValidation_RejectsLegacyAcceptsMigrated (new) asserts the shipped schema rejects the legacy shape and accepts the migrated one, against the CRD exactly as installed, never edited. No rebuild exists to race.
  • TestLiveMessageMigration_StoredValuesAndRemoval keeps the migration mechanics. Getting the pre-migration shape stored still means relaxing those rules, but the relaxation now happens to the CRD before it is installed — the shipped YAML is read, the rules are dropped in memory, and the result is handed to envtest.Environment{CRDs: ...}. The apiserver compiles that schema once at startup and is never asked to rebuild.

The migration test therefore performs no CRD writes and contains no Eventually at all. It is immune to the rebuild race by construction, not by measurement.

A guard keeps the pair honest: relaxing rules that no longer exist would leave a test proving nothing, so the helper asserts the shipped CRD still carries them (shipped CRD no longer carries the rules this test relaxes), and Test A independently proves they bite — it applies the very same legacy object against an unmodified CRD and requires the rejection.

Why not kcp

Worth recording, since it came up. kcp workspaces in this repo are the watched side — cheap logical clusters that get mirrored through a ClusterProvider kubeconfig — while the GitTarget/ClusterProvider config plane lives in the management cluster. In the operator, kcp appears only as a sanitize concern (kcp.io/cluster). There is no coupling between a kcp workspace and the config plane, and building one to serve this test would trade a 3-second unit test for part of an opt-in e2e leg that is skipped by default. The isolation argument was also already satisfied: this runs in envtest, a throwaway apiserver. What needed removing was the in-place schema edit, not the shared environment.

Coverage is not reduced. What the restore used to prove is now proved more directly, on the real schema rather than one reassembled mid-test.

Verification

  • 40 consecutive passes for the runtime-strip form, then 20 more for the install-time form, under the stressor that makes the old shape fail 5 in 10.
  • Idle cost: ~5.4s for both tests (was 3.4s for one) — the second envtest is the price of never touching the shipped CRD.
  • task lint, task test (77.8%, baseline held), task test-e2e (85 passed, 0 failed).

🤖 Generated with Claude Code

…er do

TestLiveMessageMigration_StoredValuesAndRemoval failed roughly a third of
the time on a loaded machine, on main as much as on any branch, and cost
reruns on pull requests that had nothing to do with it.

The cause is not slowness. To store a pre-migration GitTarget the test has
to get it past the validation that forbids that shape, so it stripped the
rules off the CRD, wrote the objects, put the rules back, and waited for
the apiserver to enforce them again. That last wait is the one that fails,
and no timeout fixes it: the apiextensions apiserver rebuilds a CRD's
serving handler asynchronously after a spec write, and under CPU pressure
that rebuild is sometimes missed outright. The restored rules are present
and readable in the live CRD while the previously compiled schema goes on
validating, and nothing ever retriggers it. Measured under load: 571 polls
across 60 seconds with the rules stored and unenforced, then enforcement
immediately after one further, metadata-only write.

Widening the budget is therefore useless, and was measured to be: every
run that passed did so inside 6.3s -- comfortably under the original 10s --
and every run that failed burned its whole budget, whether that was 10
seconds or 60. Nothing ever landed in between. Forcing the rebuild instead
is worse: rewriting the CRD on each poll invalidates the rebuild already in
flight so it never converges (4 failures in 12), and writing once per
settle window is no better than leaving it alone (3 in 12).

So this removes the restore rather than tuning it. Proving the SHIPPED
schema rejects the legacy shape does not need a CRD edited at runtime --
it is a stronger assertion against the CRD exactly as installed -- and that
now lives in its own test, which touches no CRD and so has no rebuild to
race. What remains here is the migration itself: strip the validation once,
store the legacy objects, and never put it back.

Coverage is not reduced. The restore's assertion is now made directly on
the real schema instead of one reassembled mid-test.

Verified against the stressor that reproduces the original: 40 consecutive
passes, where the old shape failed 5 times in 10.

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

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 99c4750a-a182-4680-9981-77fc9b02598a

📥 Commits

Reviewing files that changed from the base of the PR and between d047341 and 01aa424.

📒 Files selected for processing (1)
  • internal/controller/live_message_migration_test.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The tests add shared helpers for CRD-based validation and legacy GitTarget construction. They verify that the unmodified CRD rejects the legacy shape, accepts the migrated liveTemplate shape, and continue testing stored-value migration and removal.

Changes

Live message validation

Layer / File(s) Summary
CRD validation and migration tests
internal/controller/live_message_migration_test.go
Added helpers for the CRD test environment and legacy GitTarget objects. Added coverage for rejecting the legacy shape and accepting the migrated liveTemplate shape. Refactored the stored-value migration test to use the helpers and removed manual CRD restoration checks.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 01aa4

This test-only change improves CRD migration coverage and removes an intermittent wait without changing production behavior. It is ready to merge.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 1 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the controller test change and the removal of unreliable CRD rebuild waiting. It is concise and directly related to the main change.
Description check ✅ Passed The description is detailed and explains the failure, root cause, implementation, coverage impact, and verification results. It does not use the repository template headings or complete the requested …
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/live-message-migration-crd-race

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

The previous commit stopped the test putting the validation back, which
removed the wait that was failing. It still took the rules off a CRD the
apiserver was already serving, so one handler rebuild remained -- the same
kind that is sometimes missed, just in the direction that had not been
observed to fail yet.

There is no need to edit a live CRD at all. The shipped YAML is read, the
retirement rules on spec.commit.message are dropped in memory, and the
result is handed to envtest as the CRD to install. The apiserver compiles
that schema once, on startup, and is never asked to rebuild anything.

So the migration test now performs no CRD writes and contains no Eventually
at all: it is immune to the rebuild race by construction rather than by
measurement. Storing the legacy objects is a plain require.NoError.

A guard keeps the pair honest. Relaxing rules that are no longer there
would leave a test that proves nothing, so the helper asserts the shipped
CRD still carries them, and TestLiveMessageValidation_RejectsLegacyAcceptsMigrated
independently proves they bite -- it applies the very same legacy object
against a CRD that is never modified, and requires the rejection.

Coverage rises to 77.9%; baseline bumped as cover-check asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sunib
sunib merged commit 0b2cac6 into main Sep 10, 2026
21 checks passed
@sunib
sunib deleted the fix/live-message-migration-crd-race branch September 10, 2026 12:11
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