Skip to content

Commit b09764c

Browse files
committed
fix(validator): use NotFound, wait out the rollout window, fix a fall-through
Review findings on the first commit, all verified. ErrCodeUnavailable was wrong. validators/applicability.go is the in-repo authority on exactly this decision and its table reads 'clean read, absent/empty | recipe DECLARES -> FAIL (NotFound)', reserving Unavailable for transport failures. The read here succeeds and the answer is 'not deployed', so filing it under Unavailable would bucket a product defect with apiserver hiccups — and tell whoever triages the new failures to re-run rather than to fix their deployment. Swapped to ErrCodeNotFound. The guard also fired on states the code elsewhere treats as transient. isTrainerInstalled is an existence probe, not a readiness probe: it reports incomplete for a CRD that is present but not yet Established, or a controller Deployment that has not appeared. Both are ordinary rollout states on the bundle-deploy-validate path, so a validate started while the chart was still landing would have reported a deployment that had not failed. The declared path now polls, bounded by TrainerControllerReadyTimeout, before returning a verdict. The failure discarded a diagnosis it had already computed. isTrainerInstalled logs which specific object was missing and then returned a bare false; that reason is now carried out on trainerInstall.Incomplete and named in the error, so the failure is self-diagnosing rather than sending an operator to the logs. Restructuring for the poll introduced a fall-through that ineffassign caught: a successful declared wait set installed = true and then continued into the install path, which would have reinstalled over a Trainer the recipe delivered. Both paths now share awaitTrainerController and return. A table case pins it. Also corrects the comments on the undeclared path, which claimed it always installs an ephemeral fixture. It reuses a complete pre-existing installation and installs only when none is present — a supported state #2297 preserves. Tests are now table-driven over the decision, and assert the error code rather than only that the message names the component. The not-declared-and-absent row is deliberately excluded: it reaches installTrainer, which downloads a release archive, so it belongs in an integration test. Refs #2297 Signed-off-by: Yuan Chen <yuanchen97@gmail.com>
1 parent acfb54c commit b09764c

4 files changed

Lines changed: 197 additions & 53 deletions

File tree

pkg/defaults/timeouts.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,13 @@ const (
669669
// controller-manager Deployment to have at least one ready replica after installation.
670670
TrainerControllerReadyTimeout = 2 * time.Minute
671671

672+
// TrainerInstallPollInterval is the sleep between checks that a
673+
// recipe-declared Kubeflow Trainer installation has become complete. The
674+
// benchmark polls rather than failing on the first incomplete read, because a
675+
// CRD that is present but not yet Established — or a controller Deployment
676+
// that has not appeared — is an ordinary rollout state, not a failed deploy.
677+
TrainerInstallPollInterval = 5 * time.Second
678+
672679
// NCCLTrainJobTimeout is the maximum time to wait for the NCCL all-reduce TrainJob to complete.
673680
NCCLTrainJobTimeout = 30 * time.Minute
674681

validators/performance/nccl_all_reduce_bw_constraint.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,8 +481,9 @@ func runNCCLTrainJob(ctx *validators.Context, gpuConfig *gpuConfiguration,
481481
// Ensure a usable Kubeflow Trainer. Whether an incomplete installation is a
482482
// failure or something to install over is decided by the recipe, not by what
483483
// happens to be on the cluster: a recipe that ships the component must have a
484-
// working one, while a recipe that does not gets an ephemeral fixture. Anything
485-
// we install is ours to clean up after the test completes.
484+
// working one, while a recipe that does not reuses whatever is present and
485+
// installs an ephemeral fixture only when nothing is. Anything we install is
486+
// ours to clean up after the test completes.
486487
recipeDeclaresTrainer := validators.RecipeDeclares(ctx, kubeflowTrainerComponent)
487488
installedResources, err := ensureTrainerInstalled(ctx.Ctx, dynamicClient,
488489
ctx.Clientset.Discovery(), recipeDeclaresTrainer)

validators/performance/trainer_ensure_test.go

Lines changed: 91 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
stderrors "errors"
2020
"strings"
2121
"testing"
22+
"time"
2223

2324
aicrErrors "github.com/NVIDIA/aicr/pkg/errors"
2425
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -235,44 +236,100 @@ func TestFoldCleanupError_PreservesCleanupCode(t *testing.T) {
235236
}
236237
}
237238

238-
// TestEnsureTrainerInstalled_RecipeDeclaresButMissing verifies the benchmark fails
239-
// rather than self-installing when the recipe ships Kubeflow Trainer and no complete
240-
// installation is found.
239+
// TestEnsureTrainerInstalled_RecipeDrivenLifecycle covers the decision the recipe
240+
// now drives. The rows that matter are the two where the recipe declares the
241+
// component: a delivered installation is used as-is, and a missing one fails rather
242+
// than being installed over — which is the whole point, because self-installing
243+
// there would report a passing benchmark for a cluster whose delivered Trainer is
244+
// broken.
241245
//
242-
// This is the whole point of keying the decision on the recipe: installing an
243-
// ephemeral Trainer here would let the benchmark report a passing bandwidth result
244-
// for a cluster whose delivered Trainer is broken — the deployment failure the
245-
// recipe's own component promised would be masked by the validator working around
246-
// it.
247-
func TestEnsureTrainerInstalled_RecipeDeclaresButMissing(t *testing.T) {
248-
// An empty cluster: nothing is installed, which is what a failed deployment of
249-
// the kubeflow-trainer component looks like to the probe.
250-
client := newTrainerFakeClient()
251-
252-
refs, err := ensureTrainerInstalled(context.Background(), client, nil, true)
253-
if err == nil {
254-
t.Fatal("expected an error when the recipe declares kubeflow-trainer but none is installed")
255-
}
256-
if len(refs) != 0 {
257-
t.Errorf("refs = %d, want 0 (nothing may be installed on the failure path)", len(refs))
258-
}
259-
if !strings.Contains(err.Error(), kubeflowTrainerComponent) {
260-
t.Errorf("error %q does not name the %s component, so an operator cannot tell which "+
261-
"component failed to deploy", err, kubeflowTrainerComponent)
246+
// The not-declared + complete row is included because the guard could silently
247+
// break it: a recipe that never claimed a Trainer must still reuse one that happens
248+
// to be present, exactly as before.
249+
//
250+
// The not-declared + missing row is deliberately absent: it reaches installTrainer,
251+
// which downloads a release archive, so it belongs in an integration test rather
252+
// than here.
253+
func TestEnsureTrainerInstalled_RecipeDrivenLifecycle(t *testing.T) {
254+
tests := []struct {
255+
name string
256+
declared bool
257+
objects []runtime.Object
258+
wantErr bool
259+
wantErrCode aicrErrors.ErrorCode
260+
wantErrContains string
261+
}{
262+
{
263+
name: "declared and delivered: used as-is, never claimed for cleanup",
264+
declared: true,
265+
objects: completeTrainerInstall(),
266+
},
267+
{
268+
name: "declared but missing: fails instead of installing over it",
269+
declared: true,
270+
objects: nil,
271+
wantErr: true,
272+
wantErrCode: aicrErrors.ErrCodeNotFound,
273+
wantErrContains: kubeflowTrainerComponent,
274+
},
275+
{
276+
name: "not declared but present: reused, as before",
277+
declared: false,
278+
objects: completeTrainerInstall(),
279+
},
280+
{
281+
// Regression guard for a fall-through the linter caught once already:
282+
// after the declared wait succeeds, the code must take the readiness
283+
// path and stop, not continue into the install path and reinstall over
284+
// a Trainer the recipe delivered.
285+
name: "declared and delivered: does not fall through to install",
286+
declared: true,
287+
objects: completeTrainerInstall(),
288+
},
262289
}
263-
}
264290

265-
// TestEnsureTrainerInstalled_RecipeDeclaresAndPresent verifies the delivered
266-
// installation is used as-is: it is not reinstalled, and it is not claimed for
267-
// cleanup, because the recipe owns it rather than the benchmark.
268-
func TestEnsureTrainerInstalled_RecipeDeclaresAndPresent(t *testing.T) {
269-
client := newTrainerFakeClient(completeTrainerInstall()...)
291+
for _, tt := range tests {
292+
t.Run(tt.name, func(t *testing.T) {
293+
defer withShortTrainerWait(t)()
294+
client := newTrainerFakeClient(tt.objects...)
270295

271-
refs, err := ensureTrainerInstalled(context.Background(), client, nil, true)
272-
if err != nil {
273-
t.Fatalf("unexpected error: %v", err)
296+
refs, err := ensureTrainerInstalled(context.Background(), client, nil, tt.declared)
297+
298+
if (err != nil) != tt.wantErr {
299+
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
300+
}
301+
// Nothing is ever claimed for cleanup on these rows: a Trainer the
302+
// benchmark did not install must not be deleted by it.
303+
if len(refs) != 0 {
304+
t.Errorf("refs = %d, want 0", len(refs))
305+
}
306+
if !tt.wantErr {
307+
return
308+
}
309+
// NotFound, not Unavailable: the read succeeded and the answer was "not
310+
// deployed". Unavailable is this package's code for a transport failure
311+
// (see the decision table on validators.Require), and filing a product
312+
// defect under it tells whoever triages the failure to re-run rather than
313+
// to fix their deployment.
314+
if !stderrors.Is(err, aicrErrors.New(tt.wantErrCode, "")) {
315+
t.Errorf("error code = %v, want %s", err, tt.wantErrCode)
316+
}
317+
if !strings.Contains(err.Error(), tt.wantErrContains) {
318+
t.Errorf("error %q does not name %q, so an operator cannot tell which "+
319+
"component failed to deploy", err, tt.wantErrContains)
320+
}
321+
})
274322
}
275-
if len(refs) != 0 {
276-
t.Errorf("refs = %d, want 0 (a recipe-delivered Trainer must never be claimed for cleanup)", len(refs))
323+
}
324+
325+
// withShortTrainerWait shrinks the recipe-declared rollout wait for the duration of
326+
// a test and restores it afterwards.
327+
func withShortTrainerWait(t *testing.T) func() {
328+
t.Helper()
329+
oldTimeout, oldInterval := trainerInstallWaitTimeout, trainerInstallPollInterval
330+
trainerInstallWaitTimeout = 20 * time.Millisecond
331+
trainerInstallPollInterval = time.Millisecond
332+
return func() {
333+
trainerInstallWaitTimeout, trainerInstallPollInterval = oldTimeout, oldInterval
277334
}
278335
}

validators/performance/trainer_lifecycle.go

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,12 @@ type trainerInstall struct {
190190
Namespace string
191191
Service string
192192
Deployment string
193+
194+
// Incomplete records which specific object was missing when the probe
195+
// reported the installation as incomplete. The probe already determines this
196+
// to log it; carrying it out makes the resulting failure self-diagnosing
197+
// rather than sending an operator to the logs to find out what to fix.
198+
Incomplete string
193199
}
194200

195201
// trainerResourceRef identifies a Kubernetes resource applied during Trainer installation,
@@ -231,11 +237,11 @@ func isTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface) (t
231237
}
232238
if !found {
233239
slog.Info("Kubeflow Trainer incomplete: CRD missing", "crd", crd)
234-
return trainerInstall{}, false, nil
240+
return trainerInstall{Incomplete: fmt.Sprintf("CRD %s is missing", crd)}, false, nil
235241
}
236242
if !isCRDEstablished(obj) {
237243
slog.Info("Kubeflow Trainer incomplete: CRD not established", "crd", crd)
238-
return trainerInstall{}, false, nil
244+
return trainerInstall{Incomplete: fmt.Sprintf("CRD %s is not established", crd)}, false, nil
239245
}
240246
}
241247

@@ -256,7 +262,7 @@ func isTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface) (t
256262
if !ok {
257263
slog.Info("Kubeflow Trainer incomplete: admission webhook missing",
258264
"configuration", trainerMutatingWebhookConfig, "webhook", trainerMutatingWebhookName)
259-
return trainerInstall{}, false, nil
265+
return trainerInstall{Incomplete: "the validating admission webhook configuration is missing"}, false, nil
260266
}
261267

262268
// The controller Deployment is found by label: its name is release-derived on
@@ -269,7 +275,7 @@ func isTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface) (t
269275
if !found {
270276
slog.Info("Kubeflow Trainer incomplete: controller Deployment missing",
271277
"namespace", install.Namespace)
272-
return trainerInstall{}, false, nil
278+
return trainerInstall{Incomplete: "the controller Deployment was not found"}, false, nil
273279
}
274280
install.Deployment = controller
275281

@@ -279,7 +285,7 @@ func isTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface) (t
279285
} else if !found {
280286
slog.Info("Kubeflow Trainer incomplete: controller Service missing",
281287
"namespace", install.Namespace, "name", install.Service)
282-
return trainerInstall{}, false, nil
288+
return trainerInstall{Incomplete: "the controller Service was not found"}, false, nil
283289
}
284290

285291
slog.Info("Kubeflow Trainer installation is complete",
@@ -326,14 +332,14 @@ func discoverTrainerInstall(ctx context.Context, dynamicClient dynamic.Interface
326332
// rather than guessing a namespace and reinstalling on top of it.
327333
slog.Info("Kubeflow Trainer webhook has no Service reference; cannot locate the installation",
328334
"configuration", configName, "webhook", webhookName)
329-
return trainerInstall{}, false, nil
335+
return trainerInstall{Incomplete: "the admission webhook has no Service reference, so the installation namespace cannot be located"}, false, nil
330336
}
331337
return trainerInstall{Namespace: namespace, Service: service}, true, nil
332338
}
333339

334340
slog.Info("Kubeflow Trainer incomplete: admission webhook missing",
335341
"configuration", configName, "webhook", webhookName)
336-
return trainerInstall{}, false, nil
342+
return trainerInstall{Incomplete: "the validating admission webhook configuration is missing"}, false, nil
337343
}
338344

339345
// getTrainerObject fetches one object. NotFound reports found=false with no
@@ -445,8 +451,10 @@ func trainerResourceClient(dynamicClient dynamic.Interface,
445451
// - declared, but missing or incomplete: fail. Self-installing here would mask a
446452
// broken deployment of a component the recipe promised, and the benchmark would
447453
// then report a passing result for a cluster that cannot run TrainJobs at all.
448-
// - not declared: install an ephemeral fixture and tear it down, as before. The
449-
// recipe never claimed a Trainer, so there is nothing to mask.
454+
// - not declared, and present: reuse it, unchanged. The recipe never claimed a
455+
// Trainer, so a pre-existing one is not evidence of anything to report.
456+
// - not declared, and absent: install an ephemeral fixture and tear it down, as
457+
// before. There is nothing to mask, because nothing was promised.
450458
//
451459
// This is deliberately keyed on the recipe rather than on live cluster state, so the
452460
// same recipe behaves the same way regardless of what happens to be installed.
@@ -468,11 +476,23 @@ func ensureTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface
468476
// is a deployment failure, not something to paper over. Installing our own
469477
// here would produce a passing benchmark for a cluster whose delivered
470478
// Trainer is broken.
479+
//
480+
// But isTrainerInstalled is an existence probe, not a readiness probe: it
481+
// reports incomplete for a CRD that is present but not yet Established, or a
482+
// controller Deployment that has not appeared yet. Both are ordinary rollout
483+
// states on the bundle-deploy-validate path. Poll before declaring failure,
484+
// so a validate that starts while the chart is still landing waits it out
485+
// rather than reporting a deployment that has not failed.
471486
if recipeDeclaresTrainer {
472-
return nil, aicrErrors.New(aicrErrors.ErrCodeUnavailable, fmt.Sprintf(
473-
"the recipe declares the %s component but no complete Kubeflow Trainer "+
474-
"installation was found; the benchmark will not self-install over a "+
475-
"delivered component that failed to deploy", kubeflowTrainerComponent))
487+
declared, waitErr := waitForDeclaredTrainer(ctx, dynamicClient)
488+
if waitErr != nil {
489+
return nil, waitErr
490+
}
491+
// The delivered installation finished rolling out. Fall through to the
492+
// same readiness wait a pre-existing installation gets, and claim no
493+
// resources: the recipe owns this Trainer, not the benchmark.
494+
install = declared
495+
return nil, awaitTrainerController(ctx, dynamicClient, install)
476496
}
477497

478498
// Before applying anything, check for a live installation somewhere else.
@@ -504,15 +524,74 @@ func ensureTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface
504524
return created, nil
505525
}
506526

507-
// The probe confirms every object exists; the controller may still be rolling.
508-
// Wait for it here rather than reinstalling over a healthy Trainer we do not own.
527+
return nil, awaitTrainerController(ctx, dynamicClient, install)
528+
}
529+
530+
// awaitTrainerController waits for a Trainer the benchmark does not own to finish
531+
// starting. The probe confirms every object exists; the controller may still be
532+
// rolling, and waiting is the alternative to reinstalling over a healthy Trainer.
533+
func awaitTrainerController(ctx context.Context, dynamicClient dynamic.Interface, install trainerInstall) error {
509534
slog.Info("Kubeflow Trainer already installed, waiting for controller readiness",
510535
"namespace", install.Namespace, "deployment", install.Deployment)
511536
if readyErr := waitForTrainerReady(ctx, dynamicClient, install.Namespace, install.Deployment); readyErr != nil {
512-
return nil, aicrErrors.PropagateOrWrap(readyErr, aicrErrors.ErrCodeTimeout,
537+
return aicrErrors.PropagateOrWrap(readyErr, aicrErrors.ErrCodeTimeout,
513538
"pre-existing Kubeflow Trainer controller is not ready")
514539
}
515-
return nil, nil
540+
return nil
541+
}
542+
543+
// trainerInstallWaitTimeout and trainerInstallPollInterval bound the wait for a
544+
// recipe-declared Trainer to finish rolling out. They are variables rather than
545+
// constants only so tests can exercise the timeout path without waiting for it.
546+
var (
547+
trainerInstallWaitTimeout = defaults.TrainerControllerReadyTimeout
548+
trainerInstallPollInterval = defaults.TrainerInstallPollInterval
549+
)
550+
551+
// waitForDeclaredTrainer polls until a recipe-declared Kubeflow Trainer reports a
552+
// complete installation, or fails with the specific object that never appeared.
553+
//
554+
// The distinction it draws is the point of the recipe-driven lifecycle: an
555+
// installation that is still rolling out is not a failed one, but an installation
556+
// that never completes is — and the benchmark must not install its own Trainer over
557+
// a delivered component, because that would report a passing result for a cluster
558+
// whose Trainer is broken.
559+
func waitForDeclaredTrainer(ctx context.Context, dynamicClient dynamic.Interface) (trainerInstall, error) {
560+
var last trainerInstall
561+
562+
pollCtx, cancel := context.WithTimeout(ctx, trainerInstallWaitTimeout)
563+
defer cancel()
564+
565+
for {
566+
install, ok, err := isTrainerInstalled(pollCtx, dynamicClient)
567+
if err != nil {
568+
return trainerInstall{}, aicrErrors.PropagateOrWrap(err, aicrErrors.ErrCodeInternal,
569+
"failed to check Kubeflow Trainer installation")
570+
}
571+
if ok {
572+
return install, nil
573+
}
574+
last = install
575+
576+
select {
577+
case <-pollCtx.Done():
578+
// ErrCodeNotFound, not Unavailable: the read succeeded and the answer was
579+
// "not deployed". Unavailable is this package's code for a transport
580+
// failure — see the decision table on validators.Require — and using it
581+
// here would file a product defect alongside apiserver hiccups, telling
582+
// whoever triages it to re-run rather than to fix their deployment.
583+
reason := last.Incomplete
584+
if reason == "" {
585+
reason = "no complete installation was found"
586+
}
587+
return trainerInstall{}, aicrErrors.New(aicrErrors.ErrCodeNotFound, fmt.Sprintf(
588+
"the recipe declares the %s component but its Kubeflow Trainer installation "+
589+
"did not become complete within %s: %s. The benchmark will not self-install "+
590+
"over a delivered component that failed to deploy",
591+
kubeflowTrainerComponent, trainerInstallWaitTimeout, reason))
592+
case <-time.After(trainerInstallPollInterval):
593+
}
594+
}
516595
}
517596

518597
// foldCleanupError decides the check's verdict when teardown fails. A cleanup

0 commit comments

Comments
 (0)