Skip to content

Commit 6cc3ac5

Browse files
committed
fix(validator): let the rollout allowance bound the probe, not just the sleeps
Regression from the previous commit, caught in review. Probing with the parent context fixed the classification — a deadline landing mid-probe no longer surfaced as a bare timeout — and silently removed the bound. Each probe makes several sequential reads with their own timeouts, so a slow probe could cross the allowance and still report success if the installation completed meanwhile. On a loaded control plane that is an ordinary rollout, not a corner case. The probe runs under pollCtx again, and both expiry paths route through one classifier instead of the select owning the deadline alone. A canceled parent is Timeout; a local expiry with the parent still live is NotFound with the last known reason. Routing both through the same place is what keeps the verdict from depending on whether the clock ran out during a probe or during a sleep — the same cluster state must not produce two codes. Adds a test that fails with the unbounded form and passes with the bound restored, verified both ways. Completes the diagnostic threading. An absent admission configuration had no reason at all, a present configuration missing its webhook entry claimed the configuration itself was gone, and the mutating-webhook path said 'validating'. All three now name the object actually checked. While splitting that branch I briefly turned 'err != nil || !found' into a single not-found return, which would have reported a failed read as a missing deployment — the exact misclassification this PR exists to prevent. The error path is restored and propagates. Refs #2297 Signed-off-by: Yuan Chen <yuanchen97@gmail.com>
1 parent fe54f05 commit 6cc3ac5

2 files changed

Lines changed: 106 additions & 36 deletions

File tree

validators/performance/trainer_ensure_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,41 @@ func TestEnsureTrainerInstalled_DeclaredRolloutDoesNotFallThrough(t *testing.T)
431431
"does not cover the branch it claims to", probes)
432432
}
433433
}
434+
435+
// TestWaitForDeclaredTrainer_SlowProbeCannotOutrunTheDeadline pins the rollout
436+
// allowance as a bound on the whole wait, not just on the sleeps between probes.
437+
//
438+
// An earlier revision probed with the parent context so a deadline landing
439+
// mid-probe would not surface as a bare timeout. That fixed the classification and
440+
// removed the bound: each probe makes several sequential reads with their own
441+
// timeouts, so a slow probe could cross the allowance and still report success if
442+
// the installation happened to complete meanwhile. The probe runs under pollCtx
443+
// again, with the expiry classified rather than propagated blind.
444+
//
445+
// Here the first read sleeps past the allowance and the installation is complete
446+
// underneath, so a wait that respects its deadline must fail rather than succeed.
447+
func TestWaitForDeclaredTrainer_SlowProbeCannotOutrunTheDeadline(t *testing.T) {
448+
oldTimeout, oldInterval := trainerInstallWaitTimeout, trainerInstallPollInterval
449+
trainerInstallWaitTimeout = 20 * time.Millisecond
450+
trainerInstallPollInterval = time.Millisecond
451+
defer func() {
452+
trainerInstallWaitTimeout, trainerInstallPollInterval = oldTimeout, oldInterval
453+
}()
454+
455+
client := newTrainerFakeClient(completeTrainerInstall()...)
456+
client.PrependReactor("get", "customresourcedefinitions",
457+
func(k8stesting.Action) (bool, runtime.Object, error) {
458+
time.Sleep(60 * time.Millisecond) // outlives the allowance
459+
return false, nil, nil // then let the complete install answer
460+
})
461+
462+
_, err := waitForDeclaredTrainer(context.Background(), client)
463+
if err == nil {
464+
t.Fatal("wait returned success after its own deadline had passed; the rollout " +
465+
"allowance must bound the probe, not only the sleeps between probes")
466+
}
467+
if !stderrors.Is(err, aicrErrors.New(aicrErrors.ErrCodeNotFound, "")) {
468+
t.Errorf("error code = %v, want ErrCodeNotFound: the deadline expired locally "+
469+
"with the parent still live, which is a deployment that never completed", err)
470+
}
471+
}

validators/performance/trainer_lifecycle.go

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,8 @@ func isTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface) (t
269269
if !ok {
270270
slog.Info("Kubeflow Trainer incomplete: admission webhook missing",
271271
"configuration", trainerMutatingWebhookConfig, "webhook", trainerMutatingWebhookName)
272-
return trainerInstall{Incomplete: "the validating admission webhook configuration is missing"}, false, nil
272+
return trainerInstall{Incomplete: fmt.Sprintf(
273+
"admission configuration %q is missing", trainerMutatingWebhookConfig)}, false, nil
273274
}
274275

275276
// The controller Deployment is found by label: its name is release-derived on
@@ -313,13 +314,18 @@ func discoverTrainerInstall(ctx context.Context, dynamicClient dynamic.Interface
313314
gvr schema.GroupVersionResource, configName, webhookName string) (trainerInstall, bool, error) {
314315

315316
obj, found, err := getTrainerObject(ctx, dynamicClient, gvr, "", configName)
316-
if err != nil || !found {
317-
if err == nil {
318-
slog.Info("Kubeflow Trainer incomplete: admission configuration missing",
319-
"configuration", configName)
320-
}
317+
if err != nil {
318+
// A failed read is not evidence of absence. Propagate it so the caller
319+
// classifies it as transport or timeout rather than as a deployment that
320+
// never happened.
321321
return trainerInstall{}, false, err
322322
}
323+
if !found {
324+
slog.Info("Kubeflow Trainer incomplete: admission configuration missing",
325+
"configuration", configName)
326+
return trainerInstall{Incomplete: fmt.Sprintf(
327+
"admission configuration %q is missing", configName)}, false, nil
328+
}
323329

324330
entries, _, err := unstructured.NestedSlice(obj.Object, "webhooks")
325331
if err != nil {
@@ -346,7 +352,9 @@ func discoverTrainerInstall(ctx context.Context, dynamicClient dynamic.Interface
346352

347353
slog.Info("Kubeflow Trainer incomplete: admission webhook missing",
348354
"configuration", configName, "webhook", webhookName)
349-
return trainerInstall{Incomplete: "the validating admission webhook configuration is missing"}, false, nil
355+
return trainerInstall{Incomplete: fmt.Sprintf(
356+
"admission configuration %q exists but does not contain the %q webhook",
357+
configName, webhookName)}, false, nil
350358
}
351359

352360
// getTrainerObject fetches one object. NotFound reports found=false with no
@@ -537,6 +545,42 @@ func ensureTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface
537545
return nil, awaitTrainerController(ctx, dynamicClient, install)
538546
}
539547

548+
// classifyPollExpiry turns an expired poll context into the right verdict, or nil
549+
// when the poll context is still live and the caller should handle its own error.
550+
//
551+
// The two expiries mean opposite things. A canceled parent means the run was
552+
// aborted — catalog timeout, canceled phase, killed Job — which is not a customer
553+
// deployment defect. A local deadline means the delivered Trainer never became
554+
// complete, which is.
555+
//
556+
// Both paths route through here so the verdict does not depend on whether the clock
557+
// ran out during a probe or during a sleep: the same cluster state must not produce
558+
// two different codes.
559+
func classifyPollExpiry(ctx, pollCtx context.Context, last trainerInstall) error {
560+
if ctx.Err() != nil {
561+
return aicrErrors.Wrap(aicrErrors.ErrCodeTimeout,
562+
"canceled while waiting for the recipe-declared Kubeflow Trainer", ctx.Err())
563+
}
564+
if pollCtx.Err() == nil {
565+
return nil
566+
}
567+
568+
// ErrCodeNotFound, not Unavailable: the read succeeded and the answer was "not
569+
// deployed". Unavailable is this package's code for a transport failure — see
570+
// the decision table on validators.Require — and using it here would file a
571+
// product defect alongside apiserver hiccups, telling whoever triages it to
572+
// re-run rather than to fix their deployment.
573+
reason := last.Incomplete
574+
if reason == "" {
575+
reason = "no complete installation was found"
576+
}
577+
return aicrErrors.New(aicrErrors.ErrCodeNotFound, fmt.Sprintf(
578+
"the recipe declares the %s component but its Kubeflow Trainer installation "+
579+
"did not become complete within %s: %s. The benchmark will not self-install "+
580+
"over a delivered component that failed to deploy",
581+
kubeflowTrainerComponent, trainerInstallWaitTimeout, reason))
582+
}
583+
540584
// awaitTrainerController waits for a Trainer the benchmark does not own to finish
541585
// starting. The probe confirms every object exists; the controller may still be
542586
// rolling, and waiting is the alternative to reinstalling over a healthy Trainer.
@@ -573,13 +617,20 @@ func waitForDeclaredTrainer(ctx context.Context, dynamicClient dynamic.Interface
573617
defer cancel()
574618

575619
for {
576-
// Probe with the parent context, not pollCtx: getTrainerObject checks
577-
// ctx.Err() at the top of every read and returns Timeout, so a deadline
578-
// landing mid-probe would surface as a bare timeout instead of the
579-
// NotFound-plus-diagnosis this function exists to produce. Letting the
580-
// select below own the deadline keeps the two conditions separable.
581-
install, ok, err := isTrainerInstalled(ctx, dynamicClient)
620+
// Probe under pollCtx so the rollout allowance actually bounds it. Each probe
621+
// makes several sequential reads, each with its own DiagnosticTimeout, so a
622+
// probe running on the parent context could cross the deadline and still
623+
// report success — the allowance would bound only the sleeps.
624+
//
625+
// The cost is that getTrainerObject checks ctx.Err() at the top of every read
626+
// and returns Timeout, so an expiring deadline surfaces here as a probe error
627+
// rather than through the select. Classify it below instead of propagating it
628+
// blind, so the verdict does not depend on where in the loop the clock ran out.
629+
install, ok, err := isTrainerInstalled(pollCtx, dynamicClient)
582630
if err != nil {
631+
if verdict := classifyPollExpiry(ctx, pollCtx, last); verdict != nil {
632+
return trainerInstall{}, verdict
633+
}
583634
return trainerInstall{}, aicrErrors.PropagateOrWrap(err, aicrErrors.ErrCodeInternal,
584635
"failed to check Kubeflow Trainer installation")
585636
}
@@ -590,30 +641,11 @@ func waitForDeclaredTrainer(ctx context.Context, dynamicClient dynamic.Interface
590641

591642
select {
592643
case <-pollCtx.Done():
593-
// pollCtx expires for two different reasons and they mean opposite
594-
// things. A canceled parent means the run was aborted — catalog timeout,
595-
// phase cancellation, the Job killed — and reporting that as a customer
596-
// deployment defect is the same misclassification the NotFound code
597-
// above exists to avoid, one level down.
598-
if ctx.Err() != nil {
599-
return trainerInstall{}, aicrErrors.Wrap(aicrErrors.ErrCodeTimeout,
600-
"canceled while waiting for the recipe-declared Kubeflow Trainer", ctx.Err())
601-
}
602-
603-
// ErrCodeNotFound, not Unavailable: the read succeeded and the answer was
604-
// "not deployed". Unavailable is this package's code for a transport
605-
// failure — see the decision table on validators.Require — and using it
606-
// here would file a product defect alongside apiserver hiccups, telling
607-
// whoever triages it to re-run rather than to fix their deployment.
608-
reason := last.Incomplete
609-
if reason == "" {
610-
reason = "no complete installation was found"
644+
if verdict := classifyPollExpiry(ctx, pollCtx, last); verdict != nil {
645+
return trainerInstall{}, verdict
611646
}
612-
return trainerInstall{}, aicrErrors.New(aicrErrors.ErrCodeNotFound, fmt.Sprintf(
613-
"the recipe declares the %s component but its Kubeflow Trainer installation "+
614-
"did not become complete within %s: %s. The benchmark will not self-install "+
615-
"over a delivered component that failed to deploy",
616-
kubeflowTrainerComponent, trainerInstallWaitTimeout, reason))
647+
return trainerInstall{}, aicrErrors.New(aicrErrors.ErrCodeInternal,
648+
"Kubeflow Trainer wait ended without a verdict")
617649
case <-time.After(trainerInstallPollInterval):
618650
}
619651
}

0 commit comments

Comments
 (0)