dynamo/deploy/operator/internal/controller/dynamographdeployment_rolli...

903 lines
30 KiB
Go

/*
* SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package controller
import (
"context"
"fmt"
"slices"
"sort"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
nvidiacomv1alpha1 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1alpha1"
"github.com/ai-dynamo/dynamo/deploy/operator/internal/consts"
"github.com/ai-dynamo/dynamo/deploy/operator/internal/dynamo"
)
// shouldTriggerRollingUpdate determines if worker spec changes require a rolling update.
func (r *DynamoGraphDeploymentReconciler) shouldTriggerRollingUpdate(
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
) bool {
computedHash := dynamo.ComputeDGDWorkersSpecHash(dgd)
currentHash := r.getCurrentWorkerHash(dgd)
// If no current hash exists (new deployment), no rolling update needed
if currentHash == "" {
return false
}
return computedHash != currentHash
}
// initializeWorkerHashIfNeeded sets the current worker hash annotation on first deployment.
// For existing DGDs being upgraded from a pre-rolling-update operator version, this handles
// patching the legacy DCDs with the new worker hash label and then triggering a rolling update on the next reconcile.
func (r *DynamoGraphDeploymentReconciler) initializeWorkerHashIfNeeded(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
) error {
logger := log.FromContext(ctx)
if r.getCurrentWorkerHash(dgd) != "" {
return nil // Already initialized
}
// Check for legacy (pre-rolling-update) worker DCDs
legacyDCDs, err := r.findLegacyWorkerDCDs(ctx, dgd)
if err != nil {
return fmt.Errorf("failed to check for legacy worker DCDs: %w", err)
}
if len(legacyDCDs) > 0 {
logger.Info("Found legacy worker DCDs without hash label, initiating migration",
"count", len(legacyDCDs))
// Backfill hash label on legacy DCDs so they're manageable by the rolling update machinery
for i := range legacyDCDs {
dcd := &legacyDCDs[i]
patch := client.MergeFrom(dcd.DeepCopy())
if dcd.Labels == nil {
dcd.Labels = make(map[string]string)
}
dcd.Labels[consts.KubeLabelDynamoWorkerHash] = consts.LegacyWorkerHash
if err := r.Patch(ctx, dcd, patch); err != nil {
return fmt.Errorf("failed to backfill hash label on legacy DCD %s: %w", dcd.Name, err)
}
logger.Info("Backfilled worker hash label on legacy DCD",
"dcdName", dcd.Name, "hash", consts.LegacyWorkerHash)
}
// Set sentinel hash — next reconcile triggers a real rolling update from "legacy" -> computed hash
r.setCurrentWorkerHash(dgd, consts.LegacyWorkerHash)
if err := r.Update(ctx, dgd); err != nil {
return fmt.Errorf("failed to set legacy worker hash: %w", err)
}
r.Recorder.Eventf(dgd, corev1.EventTypeNormal, "LegacyMigrationStarted",
"Detected %d legacy worker DCDs, initiating rolling update migration", len(legacyDCDs))
return nil
}
// Normal first deploy — set the actual computed hash
hash := dynamo.ComputeDGDWorkersSpecHash(dgd)
r.setCurrentWorkerHash(dgd, hash)
if err := r.Update(ctx, dgd); err != nil {
return fmt.Errorf("failed to initialize worker hash: %w", err)
}
logger.Info("Initialized current worker hash", "hash", hash)
return nil
}
// findLegacyWorkerDCDs returns worker DCDs owned by this DGD that lack the worker hash label.
// These are DCDs created by a pre-rolling-update operator version.
func (r *DynamoGraphDeploymentReconciler) findLegacyWorkerDCDs(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
) ([]nvidiacomv1alpha1.DynamoComponentDeployment, error) {
// List all DCDs for this DGD
dcdList := &nvidiacomv1alpha1.DynamoComponentDeploymentList{}
listOpts := []client.ListOption{
client.InNamespace(dgd.Namespace),
client.MatchingLabels{
consts.KubeLabelDynamoGraphDeploymentName: dgd.Name,
},
}
if err := r.List(ctx, dcdList, listOpts...); err != nil {
return nil, fmt.Errorf("failed to list DCDs for DGD %s: %w", dgd.Name, err)
}
var legacyDCDs []nvidiacomv1alpha1.DynamoComponentDeployment
for _, dcd := range dcdList.Items {
if !dynamo.IsWorkerComponent(dcd.Spec.ComponentType) {
continue
}
// Legacy DCDs lack the worker hash label
if dcd.Labels[consts.KubeLabelDynamoWorkerHash] == "" {
legacyDCDs = append(legacyDCDs, dcd)
}
}
return legacyDCDs, nil
}
// supportsManagedRollingUpdate checks if DGD pathway supports operator managed rolling updates.
// Grove and LWS deployments currently do not support operator managed rolling updates.
// They fall back to the default rolling update mechanism.
func (r *DynamoGraphDeploymentReconciler) supportsManagedRollingUpdate(
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
) bool {
return !r.isGrovePathway(dgd) && !dgd.HasAnyMultinodeService()
}
// getCurrentWorkerHash returns the stored worker hash from DGD annotations.
// during a rolling update, this is the previous worker hash and is not updated until the rolling update is completed.
// Returns empty string if no hash has been set (new deployment).
func (r *DynamoGraphDeploymentReconciler) getCurrentWorkerHash(
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
) string {
if dgd.Annotations == nil {
return ""
}
return dgd.Annotations[consts.AnnotationCurrentWorkerHash]
}
// setCurrentWorkerHash stores the worker hash in DGD annotations.
func (r *DynamoGraphDeploymentReconciler) setCurrentWorkerHash(
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
hash string,
) {
if dgd.Annotations == nil {
dgd.Annotations = make(map[string]string)
}
dgd.Annotations[consts.AnnotationCurrentWorkerHash] = hash
}
// getOrCreateRollingUpdateStatus returns the existing rolling update status or creates a new one.
func (r *DynamoGraphDeploymentReconciler) getOrCreateRollingUpdateStatus(
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
) *nvidiacomv1alpha1.RollingUpdateStatus {
if dgd.Status.RollingUpdate == nil {
dgd.Status.RollingUpdate = &nvidiacomv1alpha1.RollingUpdateStatus{
Phase: nvidiacomv1alpha1.RollingUpdatePhaseNone,
}
}
return dgd.Status.RollingUpdate
}
// isRollingUpdateInProgress returns true if a rolling update is currently active.
func (r *DynamoGraphDeploymentReconciler) isRollingUpdateInProgress(
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
) bool {
if dgd.Status.RollingUpdate == nil {
return false
}
phase := dgd.Status.RollingUpdate.Phase
return phase == nvidiacomv1alpha1.RollingUpdatePhasePending ||
phase == nvidiacomv1alpha1.RollingUpdatePhaseInProgress
}
// reconcileRollingUpdate handles the rolling update lifecycle.
func (r *DynamoGraphDeploymentReconciler) reconcileRollingUpdate(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
) error {
logger := log.FromContext(ctx)
// Get or create rollingUpdate status
rollingUpdateStatus := r.getOrCreateRollingUpdateStatus(dgd)
// Compute hash information
newWorkerHash := dynamo.ComputeDGDWorkersSpecHash(dgd)
prevWorkerHash := r.getCurrentWorkerHash(dgd)
logger.Info("Reconciling rolling update",
"phase", rollingUpdateStatus.Phase,
"prevWorkerHash", prevWorkerHash,
"newWorkerHash", newWorkerHash)
if (rollingUpdateStatus.Phase == nvidiacomv1alpha1.RollingUpdatePhaseCompleted) && prevWorkerHash != newWorkerHash {
// Check if DCDs with the new hash already exist and are serving.
// If so, this is just a stale annotation — update it without starting a new rollout.
newInfo, err := r.getWorkerInfoForWorkerHash(ctx, dgd, newWorkerHash)
if err == nil && newInfo.TotalReadyWorkers() > 0 {
logger.Info("Updating stale worker hash annotation",
"prevWorkerHash", prevWorkerHash, "newHash", newWorkerHash)
r.setCurrentWorkerHash(dgd, newWorkerHash)
return r.Update(ctx, dgd)
}
// New spec change: reset to start a proper rolling update cycle with surge/drain.
logger.Info("New worker spec change detected, starting new rolling update cycle",
"prevWorkerHash", prevWorkerHash, "newHash", newWorkerHash,
"previousPhase", rollingUpdateStatus.Phase)
rollingUpdateStatus.Phase = nvidiacomv1alpha1.RollingUpdatePhaseNone
rollingUpdateStatus.StartTime = nil
rollingUpdateStatus.EndTime = nil
rollingUpdateStatus.UpdatedServices = nil
}
if prevWorkerHash == newWorkerHash &&
rollingUpdateStatus.Phase == nvidiacomv1alpha1.RollingUpdatePhaseInProgress {
logger.Info("Detected stuck rolling update: hashes match but phase is InProgress",
"hash", newWorkerHash,
"phase", rollingUpdateStatus.Phase)
return r.completeRollingUpdate(ctx, dgd, rollingUpdateStatus, newWorkerHash)
}
switch rollingUpdateStatus.Phase {
case nvidiacomv1alpha1.RollingUpdatePhaseNone:
return r.startRollingUpdate(ctx, dgd, rollingUpdateStatus, newWorkerHash)
case nvidiacomv1alpha1.RollingUpdatePhasePending:
rollingUpdateStatus.Phase = nvidiacomv1alpha1.RollingUpdatePhaseInProgress
if err := r.Status().Update(ctx, dgd); err != nil {
return fmt.Errorf("failed to update rolling update status to InProgress: %w", err)
}
return nil
case nvidiacomv1alpha1.RollingUpdatePhaseInProgress:
return r.continueRollingUpdate(ctx, dgd, rollingUpdateStatus, newWorkerHash)
case nvidiacomv1alpha1.RollingUpdatePhaseCompleted:
// Cleanup is now done atomically in completeRollingUpdate, nothing to do here
logger.Info("Rolling update already completed")
return nil
}
return nil
}
// startRollingUpdate initializes a new rolling update.
func (r *DynamoGraphDeploymentReconciler) startRollingUpdate(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
rollingUpdateStatus *nvidiacomv1alpha1.RollingUpdateStatus,
newWorkerHash string,
) error {
logger := log.FromContext(ctx)
prevWorkerHash := r.getCurrentWorkerHash(dgd)
logger.Info("Starting rolling update",
"prevHash", prevWorkerHash,
"newHash", newWorkerHash)
now := metav1.Now()
rollingUpdateStatus.Phase = nvidiacomv1alpha1.RollingUpdatePhasePending
rollingUpdateStatus.StartTime = &now
rollingUpdateStatus.UpdatedServices = nil
r.Recorder.Eventf(dgd, corev1.EventTypeNormal, "RollingUpdateStarted",
"Starting rolling update from worker hash %s to %s", prevWorkerHash, newWorkerHash)
if err := r.Status().Update(ctx, dgd); err != nil {
return fmt.Errorf("failed to initialize rolling update status: %w", err)
}
return nil
}
// continueRollingUpdate handles the in-progress phase of a rolling update.
func (r *DynamoGraphDeploymentReconciler) continueRollingUpdate(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
rollingUpdateStatus *nvidiacomv1alpha1.RollingUpdateStatus,
newWorkerHash string,
) error {
logger := log.FromContext(ctx)
oldInfo, err := r.getOldWorkerInfo(ctx, dgd, newWorkerHash)
if err != nil {
logger.Error(err, "Failed to get old worker info")
oldInfo = &dynamoNamespaceWorkerInfo{}
}
newInfo, err := r.getWorkerInfoForWorkerHash(ctx, dgd, newWorkerHash)
if err != nil {
logger.Error(err, "Failed to get new worker hash status")
newInfo = &dynamoNamespaceWorkerInfo{}
}
desiredReplicas := r.getDesiredWorkerReplicas(dgd)
logger.Info("Rolling update progress",
"oldReadyWorkers", oldInfo.TotalReadyWorkers(),
"newReadyWorkers", newInfo.TotalReadyWorkers(),
"desiredReplicas", desiredReplicas,
"newWorkerHash", newWorkerHash)
// Compute per-service completion
var updatedServices []string
for serviceName, spec := range dgd.Spec.Services {
if spec == nil || !dynamo.IsWorkerComponent(spec.ComponentType) {
continue
}
desired := int32(1)
if spec.Replicas != nil {
desired = *spec.Replicas
}
newSvc := newInfo.services[serviceName]
oldSvc := oldInfo.services[serviceName]
newReady := newSvc != nil && newSvc.readyReplicas >= desired
oldGone := oldSvc == nil || oldSvc.readyReplicas == 0
if newReady && oldGone {
updatedServices = append(updatedServices, serviceName)
}
}
sort.Strings(updatedServices)
rollingUpdateStatus.UpdatedServices = updatedServices
// Count total worker services
totalWorkerServices := 0
for _, spec := range dgd.Spec.Services {
if spec != nil && dynamo.IsWorkerComponent(spec.ComponentType) {
totalWorkerServices++
}
}
// Rolling update is complete when every worker service is individually updated
if len(updatedServices) == totalWorkerServices && totalWorkerServices > 0 {
return r.completeRollingUpdate(ctx, dgd, rollingUpdateStatus, newWorkerHash)
}
// Persist updated services list mid-rolling update
if err := r.Status().Update(ctx, dgd); err != nil {
return fmt.Errorf("failed to update rolling update status with updated services: %w", err)
}
return nil
}
// completeRollingUpdate marks the rolling update as completed, cleans up old resources, and updates status.
// This performs all cleanup atomically to avoid race conditions with subsequent reconciles.
func (r *DynamoGraphDeploymentReconciler) completeRollingUpdate(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
rollingUpdateStatus *nvidiacomv1alpha1.RollingUpdateStatus,
newWorkerHash string,
) error {
logger := log.FromContext(ctx)
// Delete all non-current worker DCDs (any number of old generations)
if err := r.deleteOldWorkerDCDs(ctx, dgd, newWorkerHash); err != nil {
logger.Error(err, "Failed to delete non-current worker DCDs", "newWorkerHash", newWorkerHash)
r.Recorder.Eventf(dgd, corev1.EventTypeWarning, "CleanupPartialFailure",
"Failed to delete some old worker DCDs: %v", err)
// Continue anyway - we don't want cleanup failures to block the rolling update completion
} else {
logger.Info("Old resources cleaned up", "newWorkerHash", newWorkerHash)
}
// Update rolling update status to Completed
rollingUpdateStatus.Phase = nvidiacomv1alpha1.RollingUpdatePhaseCompleted
now := metav1.Now()
rollingUpdateStatus.EndTime = &now
// Mark all worker services as updated
var allWorkerServices []string
for serviceName, spec := range dgd.Spec.Services {
if spec != nil && dynamo.IsWorkerComponent(spec.ComponentType) {
allWorkerServices = append(allWorkerServices, serviceName)
}
}
sort.Strings(allWorkerServices)
rollingUpdateStatus.UpdatedServices = allWorkerServices
r.Recorder.Eventf(dgd, corev1.EventTypeNormal, "RollingUpdateCompleted",
"Rolling update completed, worker hash %s", newWorkerHash)
if err := r.Status().Update(ctx, dgd); err != nil {
return fmt.Errorf("failed to update rolling update status: %w", err)
}
// Update the current worker hash to the new hash
r.setCurrentWorkerHash(dgd, newWorkerHash)
if err := r.Update(ctx, dgd); err != nil {
return fmt.Errorf("failed to update current worker hash: %w", err)
}
logger.Info("Rolling update finalized", "newWorkerHash", newWorkerHash)
return nil
}
// workerServiceInfo holds ready replica count for a worker service.
type workerServiceInfo struct {
readyReplicas int32
desired int32
}
// dynamoNamespaceWorkerInfo holds aggregated worker status for a single dynamo namespace.
type dynamoNamespaceWorkerInfo struct {
// totalReadyWorkers is the sum of ready replicas across all worker services
totalReadyWorkers int32
// services contains per-component-type status (e.g., "prefill", "decode", "worker")
services map[string]*workerServiceInfo
}
func (s *dynamoNamespaceWorkerInfo) TotalReadyWorkers() int32 {
return s.totalReadyWorkers
}
// getWorkerInfoForWorkerHash queries DCDs for a specific worker hash and returns
// aggregated worker info.
func (r *DynamoGraphDeploymentReconciler) getWorkerInfoForWorkerHash(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
workerHash string,
) (*dynamoNamespaceWorkerInfo, error) {
dcdList := &nvidiacomv1alpha1.DynamoComponentDeploymentList{}
listOpts := []client.ListOption{
client.InNamespace(dgd.Namespace),
client.MatchingLabels{
consts.KubeLabelDynamoGraphDeploymentName: dgd.Name,
consts.KubeLabelDynamoWorkerHash: workerHash,
},
}
if err := r.List(ctx, dcdList, listOpts...); err != nil {
return nil, fmt.Errorf("failed to list DCDs: %w", err)
}
status := &dynamoNamespaceWorkerInfo{
services: make(map[string]*workerServiceInfo),
}
for _, dcd := range dcdList.Items {
if !dynamo.IsWorkerComponent(dcd.Spec.ComponentType) {
continue
}
// Add ready replicas
readyReplicas := int32(0)
if dcd.Status.Service != nil && dcd.Status.Service.ReadyReplicas != nil {
readyReplicas = *dcd.Status.Service.ReadyReplicas
}
// Add desired replicas
desiredReplicas := int32(0)
if dcd.Spec.Replicas != nil {
desiredReplicas = *dcd.Spec.Replicas
}
status.services[dcd.Spec.ServiceName] = &workerServiceInfo{
readyReplicas: readyReplicas,
desired: desiredReplicas,
}
status.totalReadyWorkers += readyReplicas
}
return status, nil
}
// getOldWorkerInfo aggregates ready replicas across ALL non-current worker DCDs.
func (r *DynamoGraphDeploymentReconciler) getOldWorkerInfo(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
newWorkerHash string,
) (*dynamoNamespaceWorkerInfo, error) {
oldDCDs, err := r.listOldWorkerDCDs(ctx, dgd, newWorkerHash)
if err != nil {
return nil, fmt.Errorf("failed to list non-current worker DCDs: %w", err)
}
status := &dynamoNamespaceWorkerInfo{
services: make(map[string]*workerServiceInfo),
}
for _, dcd := range oldDCDs {
readyReplicas := int32(0)
if dcd.Status.Service != nil && dcd.Status.Service.ReadyReplicas != nil {
readyReplicas = *dcd.Status.Service.ReadyReplicas
}
if existing, ok := status.services[dcd.Spec.ServiceName]; ok {
existing.readyReplicas += readyReplicas
} else {
status.services[dcd.Spec.ServiceName] = &workerServiceInfo{
readyReplicas: readyReplicas,
}
}
status.totalReadyWorkers += readyReplicas
}
return status, nil
}
// getDesiredWorkerReplicas returns the total desired replicas across all worker services.
func (r *DynamoGraphDeploymentReconciler) getDesiredWorkerReplicas(
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
) int32 {
var total int32
for _, spec := range dgd.Spec.Services {
if spec != nil && dynamo.IsWorkerComponent(spec.ComponentType) {
if spec.Replicas != nil {
total += *spec.Replicas
} else {
total += 1 // Default to 1 if not specified
}
}
}
return total
}
// scaleOldWorkerDCDs patches the replicas field on old worker DCDs during a rolling update.
// When multiple old generations exist for the same service, replicas are distributed to the
// newest old DCD first, with older DCDs drained to 0 (matching K8s Deployment controller behavior).
func (r *DynamoGraphDeploymentReconciler) scaleOldWorkerDCDs(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
rollingUpdateCtx dynamo.RollingUpdateContext,
) error {
logger := log.FromContext(ctx)
if !rollingUpdateCtx.InProgress() {
return nil
}
oldDCDs, err := r.listOldWorkerDCDs(ctx, dgd, rollingUpdateCtx.NewWorkerHash)
if err != nil {
return fmt.Errorf("failed to list old worker DCDs: %w", err)
}
// Group old DCDs by service name
dcdsByService := make(map[string][]*nvidiacomv1alpha1.DynamoComponentDeployment)
for i := range oldDCDs {
svc := oldDCDs[i].Spec.ServiceName
dcdsByService[svc] = append(dcdsByService[svc], &oldDCDs[i])
}
for serviceName, dcds := range dcdsByService {
oldNeeded, ok := rollingUpdateCtx.OldWorkerReplicas[serviceName]
if !ok {
continue
}
// Sort by creation time descending (newest first) so newest old DCDs get replicas first
sort.Slice(dcds, func(i, j int) bool {
return dcds[i].CreationTimestamp.After(dcds[j].CreationTimestamp.Time)
})
remaining := oldNeeded
for _, dcd := range dcds {
var desiredReplicas int32
if remaining > 0 {
currentSpec := int32(1)
if dcd.Spec.Replicas != nil {
currentSpec = *dcd.Spec.Replicas
}
// Give this DCD up to its current spec count, but no more than remaining
desiredReplicas = min(remaining, currentSpec)
remaining -= desiredReplicas
}
currentReplicas := int32(1)
if dcd.Spec.Replicas != nil {
currentReplicas = *dcd.Spec.Replicas
}
if currentReplicas == desiredReplicas {
logger.V(1).Info("Old worker DCD replicas already at desired value",
"dcdName", dcd.Name, "replicas", desiredReplicas)
continue
}
patch := client.MergeFrom(dcd.DeepCopy())
dcd.Spec.Replicas = &desiredReplicas
if err := r.Patch(ctx, dcd, patch); err != nil {
return fmt.Errorf("failed to patch old worker DCD %s replicas: %w", dcd.Name, err)
}
logger.Info("Scaled old worker DCD",
"dcdName", dcd.Name,
"service", serviceName,
"oldReplicas", currentReplicas,
"newReplicas", desiredReplicas)
}
}
return nil
}
// listOldWorkerDCDs returns all worker DCDs for this DGD whose worker hash label
// does NOT match the given newWorkerHash. This captures all old generations (including legacy).
func (r *DynamoGraphDeploymentReconciler) listOldWorkerDCDs(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
newWorkerHash string,
) ([]nvidiacomv1alpha1.DynamoComponentDeployment, error) {
dcdList := &nvidiacomv1alpha1.DynamoComponentDeploymentList{}
listOpts := []client.ListOption{
client.InNamespace(dgd.Namespace),
client.MatchingLabels{
consts.KubeLabelDynamoGraphDeploymentName: dgd.Name,
},
}
if err := r.List(ctx, dcdList, listOpts...); err != nil {
return nil, err
}
var workers []nvidiacomv1alpha1.DynamoComponentDeployment
for _, dcd := range dcdList.Items {
if !dynamo.IsWorkerComponent(dcd.Spec.ComponentType) {
continue
}
if dcd.Labels[consts.KubeLabelDynamoWorkerHash] != newWorkerHash {
workers = append(workers, dcd)
}
}
return workers, nil
}
// deleteOldWorkerDCDs deletes all worker DCDs belonging to this DGD whose hash label
// does NOT match the given newWorkerHash. This cleans up all old generations at once.
func (r *DynamoGraphDeploymentReconciler) deleteOldWorkerDCDs(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
newWorkerHash string,
) error {
logger := log.FromContext(ctx)
oldDCDs, err := r.listOldWorkerDCDs(ctx, dgd, newWorkerHash)
if err != nil {
return fmt.Errorf("failed to list non-current worker DCDs: %w", err)
}
if len(oldDCDs) == 0 {
logger.Info("No non-current worker DCDs found to delete", "newWorkerHash", newWorkerHash)
return nil
}
logger.Info("Deleting non-current worker DCDs", "count", len(oldDCDs), "newWorkerHash", newWorkerHash)
var deleteErrors []error
for i := range oldDCDs {
dcd := &oldDCDs[i]
logger.Info("Deleting non-current worker DCD", "name", dcd.Name, "hash", dcd.Labels[consts.KubeLabelDynamoWorkerHash])
if err := r.Delete(ctx, dcd); err != nil {
if !apierrors.IsNotFound(err) {
deleteErrors = append(deleteErrors, fmt.Errorf("failed to delete DCD %s: %w", dcd.Name, err))
}
}
}
if len(deleteErrors) > 0 {
return fmt.Errorf("failed to delete %d DCDs: %v", len(deleteErrors), deleteErrors)
}
return nil
}
// aggregateOldWorkerServiceStatuses fetches all non-current worker DCDs and returns their
// aggregated service statuses keyed by service name. Accumulates across multiple old generations.
func (r *DynamoGraphDeploymentReconciler) aggregateOldWorkerServiceStatuses(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
rollingUpdateCtx dynamo.RollingUpdateContext,
) (map[string]nvidiacomv1alpha1.ServiceReplicaStatus, error) {
oldStatuses := make(map[string]nvidiacomv1alpha1.ServiceReplicaStatus)
oldDCDs, err := r.listOldWorkerDCDs(ctx, dgd, rollingUpdateCtx.NewWorkerHash)
if err != nil {
return nil, fmt.Errorf("failed to list old worker DCDs for status aggregation: %w", err)
}
for _, dcd := range oldDCDs {
if _, inRollout := rollingUpdateCtx.OldWorkerReplicas[dcd.Spec.ServiceName]; !inRollout {
continue
}
if dcd.Status.Service == nil {
continue
}
existing, found := oldStatuses[dcd.Spec.ServiceName]
if !found {
status := *dcd.Status.Service
status.ComponentNames = []string{dcd.Status.Service.ComponentName}
oldStatuses[dcd.Spec.ServiceName] = status
} else {
// Accumulate across multiple old DCDs
existing.Replicas += dcd.Status.Service.Replicas
existing.ReadyReplicas = addOptionalInt32(existing.ReadyReplicas, dcd.Status.Service.ReadyReplicas)
existing.AvailableReplicas = addOptionalInt32(existing.AvailableReplicas, dcd.Status.Service.AvailableReplicas)
existing.ComponentNames = append(existing.ComponentNames, dcd.Status.Service.ComponentName)
oldStatuses[dcd.Spec.ServiceName] = existing
}
}
return oldStatuses, nil
}
// resolveRollingUpdateParams reads the deployment strategy annotations from a service spec
// and resolves maxSurge and maxUnavailable to concrete replica counts.
// Defaults: maxSurge=25%, maxUnavailable=25% (matches Kubernetes Deployment defaults).
// TODO: support the recreate strategy
func resolveRollingUpdateParams(annotations map[string]string, desiredReplicas int32) (maxSurge int32, maxUnavailable int32) {
surgeValue := intstr.FromString("25%")
unavailValue := intstr.FromString("25%")
if v := annotations[KubeAnnotationDeploymentRollingUpdateMaxSurge]; v != "" {
surgeValue = intstr.Parse(v)
}
if v := annotations[KubeAnnotationDeploymentRollingUpdateMaxUnavailable]; v != "" {
unavailValue = intstr.Parse(v)
}
// Resolve percentages against desiredReplicas. Round up for surge (more aggressive scale-up),
// round down for unavailable (more conservative, matches Kubernetes deployment controller behavior).
// https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#max-unavailable
surge, _ := intstr.GetScaledValueFromIntOrPercent(&surgeValue, int(desiredReplicas), true)
unavail, _ := intstr.GetScaledValueFromIntOrPercent(&unavailValue, int(desiredReplicas), false)
// Ensure at least one of surge/unavailable is > 0 to guarantee progress
if surge == 0 && unavail == 0 {
surge = 1
}
return int32(surge), int32(unavail)
}
// buildRollingUpdateContext creates a RollingUpdateContext.
// It computes namespaces and pre-calculates old and new worker replica counts.
//
// Replica calculation:
// - oldReplicas = max(0, desiredReplicas - newReadyReplicas - maxUnavailable)
// - newReplicas = min(desiredReplicas, desiredReplicas + maxSurge - oldReplicas)
func (r *DynamoGraphDeploymentReconciler) buildRollingUpdateContext(
ctx context.Context,
dgd *nvidiacomv1alpha1.DynamoGraphDeployment,
) dynamo.RollingUpdateContext {
logger := log.FromContext(ctx)
// Compute hashes
newWorkerHash := dynamo.ComputeDGDWorkersSpecHash(dgd)
prevWorkerHash := r.getCurrentWorkerHash(dgd)
if prevWorkerHash == newWorkerHash {
return dynamo.RollingUpdateContext{
NewWorkerHash: newWorkerHash,
OldWorkerReplicas: make(map[string]int32),
NewWorkerReplicas: make(map[string]int32),
}
}
// Pre-calculate old and new worker replicas based on new worker readiness
oldWorkerReplicas := make(map[string]int32)
newWorkerReplicas := make(map[string]int32)
for serviceName, spec := range dgd.Spec.Services {
if spec == nil || !dynamo.IsWorkerComponent(spec.ComponentType) {
continue
}
// Get desired replicas from spec
desiredReplicas := int32(1)
if spec.Replicas != nil {
desiredReplicas = *spec.Replicas
}
maxSurge, maxUnavailable := resolveRollingUpdateParams(spec.Annotations, desiredReplicas)
// Query new DCD to get ready replicas (using hash-based naming)
newDCDName := dynamo.GetDCDResourceName(dgd, serviceName, newWorkerHash)
newDCD := &nvidiacomv1alpha1.DynamoComponentDeployment{}
err := r.Get(ctx, types.NamespacedName{Name: newDCDName, Namespace: dgd.Namespace}, newDCD)
newReadyReplicas := int32(0)
if err == nil && newDCD.Status.Service != nil && newDCD.Status.Service.ReadyReplicas != nil {
newReadyReplicas = *newDCD.Status.Service.ReadyReplicas
}
// Calculate old replicas: allow scaling down by maxUnavailable
// oldReplicas = max(0, desiredReplicas - newReadyReplicas - maxUnavailable)
oldNeeded := desiredReplicas - newReadyReplicas - maxUnavailable
if oldNeeded < 0 {
oldNeeded = 0
}
// Calculate new replicas: stay within surge budget
// newReplicas = min(desiredReplicas, desiredReplicas + maxSurge - oldNeeded)
newNeeded := desiredReplicas + maxSurge - oldNeeded
if newNeeded > desiredReplicas {
newNeeded = desiredReplicas
}
if newNeeded < 0 {
newNeeded = 0
}
newWorkerReplicas[serviceName] = newNeeded
oldWorkerReplicas[serviceName] = oldNeeded
logger.V(1).Info("Calculated worker replicas for rollingUpdate",
"service", serviceName,
"desired", desiredReplicas,
"newReady", newReadyReplicas,
"maxSurge", maxSurge,
"maxUnavailable", maxUnavailable,
"newNeeded", newNeeded,
"oldNeeded", oldNeeded)
}
return dynamo.RollingUpdateContext{
NewWorkerHash: newWorkerHash,
OldWorkerReplicas: oldWorkerReplicas,
NewWorkerReplicas: newWorkerReplicas,
}
}
// mergeWorkerServiceStatuses merges old worker service statuses into the existing service statuses.
// For each worker service present in both maps, it aggregates replica counts so that the status
// reflects the total across old and new worker DCDs during a rolling update.
func mergeWorkerServiceStatuses(
serviceStatuses map[string]nvidiacomv1alpha1.ServiceReplicaStatus,
oldWorkerStatuses map[string]nvidiacomv1alpha1.ServiceReplicaStatus,
) {
for serviceName, oldStatus := range oldWorkerStatuses {
newStatus, exists := serviceStatuses[serviceName]
if !exists {
continue
}
// Build sorted ComponentNames from old and new DCD names.
componentNames := append(oldStatus.ComponentNames, newStatus.ComponentName)
slices.Sort(componentNames)
newStatus.ComponentNames = componentNames
// Aggregate replica counts
newStatus.Replicas += oldStatus.Replicas
// UpdatedReplicas stays as-is (only new are "updated")
newStatus.ReadyReplicas = addOptionalInt32(newStatus.ReadyReplicas, oldStatus.ReadyReplicas)
newStatus.AvailableReplicas = addOptionalInt32(newStatus.AvailableReplicas, oldStatus.AvailableReplicas)
serviceStatuses[serviceName] = newStatus
}
}
// addOptionalInt32 adds two optional int32 pointers. Returns nil only if both are nil.
func addOptionalInt32(a, b *int32) *int32 {
if a == nil && b == nil {
return nil
}
var sum int32
if a != nil {
sum += *a
}
if b != nil {
sum += *b
}
return &sum
}