Skip to content

refactor(operator): move rollout control into workload programs - #12121

Closed
julienmancuso wants to merge 3 commits into
jsm/12035-2from
jsm/12035-3
Closed

refactor(operator): move rollout control into workload programs#12121
julienmancuso wants to merge 3 commits into
jsm/12035-2from
jsm/12035-3

Conversation

@julienmancuso

@julienmancuso julienmancuso commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

This MR moves workload rollout control out of the outer DynamoGraphDeployment reconciliation flow and into the selected workload program.

After finalization, the outer DGD controller now:

  1. Builds the ephemeral reconciliation state.
  2. Selects exactly one workload program.
  3. Invokes that program once.
  4. Projects the successful result into DGD status.

Each concrete program composes its complete ordered reconciliation flow.

The component pathway now performs:

  1. Worker-hash migration.
  2. Managed or unsupported-path rollout handling.
  3. Shared resource reconciliation.
  4. Component-specific multinode validation.
  5. Restart-state resolution.
  6. DCD workload reconciliation.
  7. Shared checkpoint-readiness and scaling-adapter processing.

The Grove pathway performs the equivalent composition while retaining its existing unsupported managed-rollout behavior and temporary Grove workload adapter.

This MR is stacked on the preceding workload-program MR.

Motivation

The outer DGD controller previously selected the workload pathway in one place while independently deciding rollout behavior in another.

That left the common controller coupled to component-specific decisions such as:

  • whether managed rolling updates are supported;
  • when to initialize worker hashes;
  • when to start or advance a rollout;
  • how unsupported pathways acknowledge worker changes.

It also meant the selected workload program did not yet own its complete graph-level state machine.

This change makes rollout behavior part of each concrete program’s composition while keeping shared resource operations reusable and explicit.

Design

Complete workload programs

The workloadProgram contract remains a single Reconcile method. No provider lifecycle callbacks such as Prepare, Render, Scale, or Cleanup are added.

componentProgram.Reconcile and groveProgram.Reconcile explicitly compose their operations in order. Earlier operations enrich graphReconcileState; later operations consume those resolved values.

Shared reconciliation steps

The former reconcileResources flow is split into focused shared operations:

  • reconcileProgramInputs reconciles common resources and records resolved checkpoint and multinode information.
  • resolveProgramRestartState computes restart state after pathway-specific validation.
  • reconcileProgramResult applies checkpoint-readiness policy and reconciles scaling adapters.

These are concrete shared operations composed by each program, not hooks driven by a generic framework.

Rollout ownership

componentProgram now decides whether a component deployment uses:

  • the existing managed rolling-update state machine for single-node component workloads; or
  • the existing unsupported-path worker-hash behavior for multinode component workloads.

groveProgram explicitly composes the unsupported-path worker-hash behavior.

The underlying rolling-update helper implementations remain on DynamoGraphDeploymentReconciler temporarily. This MR moves control-flow ownership without combining it with a large mechanical dependency/receiver migration.

Failure semantics

Program-specific rollout failures retain their existing DGD failure reasons:

  • failed_to_migrate_worker_hash
  • failed_to_initialize_worker_hash
  • rolling_update_failed

Other program failures retain failed_to_reconcile_the_resources.

A program publishes state.Result only after its entire reconciliation sequence succeeds. A rollout, shared-resource, workload, or post-processing error therefore cannot replace the last-known-good component or restart status with a partial result.

Behavior and compatibility

This is intended to be a behavior-preserving refactor:

  • Finalization remains owned by the outer DGD controller.
  • The outer DGD controller remains the only DGD status writer.
  • Workload-program selection semantics are unchanged.
  • Managed rolling updates remain limited to single-node component workloads.
  • Grove and multinode workloads retain their existing unsupported-path behavior.
  • Worker-hash migration and compatibility behavior are unchanged.
  • Rollout reconciliation still occurs before shared resource reconciliation.
  • Component multinode validation still occurs before restart-state resolution.
  • Checkpoint readiness and scaling-adapter ordering are unchanged.
  • Rolling-update status continues to override the overall DGD state where applicable.
  • Results are still projected only after successful resource reconciliation.

There are no CRD, rendered-resource, ownership, watch-registration, or persisted-state changes.

Tests

Added or updated coverage for:

  • component-owned managed-rollout selection;
  • managed rollout startup for single-node workloads;
  • unsupported-path behavior for multinode component workloads;
  • Grove unsupported-path failure handling;
  • preservation of the previous program result on rollout errors;
  • preservation of rollout-specific failure reasons;
  • workload adapters independently from the complete program composition;
  • shared resource validation before workload reconciliation.

The existing rolling-update characterization suite remains unchanged apart from transferring the managed-rollout support predicate to componentProgram.

Summary by CodeRabbit

  • Improvements

    • Improved deployment reconciliation reliability and status reporting.
    • Added clearer handling for worker rollout, migration, and resource reconciliation failures.
    • Improved rolling-update tracking and completion behavior.
    • Added support for pathway-specific reconciliation and more consistent readiness reporting.
  • Tests

    • Expanded coverage for reconciliation failures, status preservation, workload selection, and rolling updates.

@julienmancuso
julienmancuso requested a review from a team as a code owner July 24, 2026 21:22
@github-actions github-actions Bot added refactor deployment::k8s Relates to dynamo deployment in kubernetes labels Jul 24, 2026

sttts commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Disclosure: This comment was prepared and posted by OpenAI Codex at @sttts's request after reviewing this PR on top of #12115 and #12120.

I think this PR makes the important architectural move in the right direction: the selected program now owns the order and timing of rollout preparation, shared reconciliation steps, restart resolution, workload reconciliation, and result processing. The outer DGD reconciler selects one program and invokes it once rather than driving provider hooks through a common lifecycle.

So this is not an objection to the composition-first direction. My concern is narrower: before this boundary becomes the contract between separately packaged programs, I would give the program invocation a clearer input/output shape.

The current contract is:

type workloadProgram interface {
    Reconcile(context.Context, *graphReconcileState) error
}

graphReconcileState currently contains several different categories of data:

Current field Actual role Natural home
DGD Mutable primary object and user intent Request.DGD
HasMultinode Resolved fact A typed intermediate result, or immutable request facts if already known
CheckpointInfos Result of shared checkpoint reconciliation A typed intermediate result consumed by later steps
RestartState State derived after earlier reconciliation and validation A local typed value passed to the workload step
RestartStatus Desired DGD status output Result.Status.Restart
Result Program output Returned Result.Status

This makes graphReconcileState simultaneously the request, scratch space, temporal protocol, and result. The ordering is therefore encoded indirectly through field mutation: one operation must populate a field before another operation may consume it. Those temporal preconditions are not visible in the function signatures.

That is especially relevant to the design goal here. The program correctly owns the flow, but the shared mutable state makes part of that flow implicit again.

Suggested program contract

I would follow controller-runtime's Request / Result terminology:

type workloadProgram interface {
    Reconcile(
        context.Context,
        Request,
    ) (Result, error)
}

type Request struct {
    // DGD is the mutable primary object for this reconciliation.
    //
    // The program may mutate and directly persist non-status fields. Status
    // persistence remains owned by the outer DGD reconciler.
    DGD *nvidiacomv1beta1.DynamoGraphDeployment

    // Facts contains immutable observations that were genuinely resolved
    // independently of the selected program. This can initially be empty.
    Facts ResolvedFacts
}

type Result struct {
    // Standard controller-runtime requeue semantics.
    ctrl.Result

    // Status is the complete desired DGD status produced by the program.
    // The outer reconciler persists it once. Nil means that the program did
    // not produce a successful status result.
    Status *nvidiacomv1beta1.DynamoGraphDeploymentStatus
}

I do not think we need a generic PrimaryMutation. The DGD is explicitly the mutable primary object. Program-specific non-status mutations, such as worker-hash annotations, are uncommon and can be written directly by the program that owns them. Finalizer ownership remains with the outer controller.

Status is different because there should be exactly one status writer. The selected program returns the desired DynamoGraphDeploymentStatus; the outer DGD reconciler adds controller-owned fields and conditions such as Ready, ObservedGeneration, and any common condition projection, then performs one Status().Update().

The current code already has this useful property: DGD conditions are accumulated in memory and there is one central DGD status update. The proposed result type makes that ownership part of the program contract rather than an implicit property of the enclosing reconciler.

Conceptually:

programResult, err := program.Reconcile(ctx, Request{
    DGD: dgd,
})
if err != nil {
    return ctrl.Result{}, err
}

if programResult.Status != nil {
    dgd.Status = *programResult.Status
}

// Add controller-owned Ready/ObservedGeneration/common conditions in memory.
// The existing outer status path persists the complete status exactly once.
return programResult.Result, nil

Keep derived state local and typed

I would not move the current reconcileProgramInputs flow back into the outer controller merely to populate Request.Facts. That would give part of the ordering back to the framework shell.

Instead, the concrete program should continue to decide when shared operations run, but those operations should return structured values rather than mutate a universal state blob:

func (p *componentProgram) Reconcile(
    ctx context.Context,
    req Request,
) (Result, error) {
    inputs, err := p.reconcileInputs(ctx, req.DGD, req.Facts)
    if err != nil {
        return Result{}, err
    }

    if inputs.HasMultinode && !p.lwsEnabled {
        return Result{}, fmt.Errorf("no multinode orchestrator available")
    }

    restart := p.resolveRestart(req.DGD, inputs)

    workloads, err := p.reconcileWorkloads(ctx, componentWorkloadRequest{
        DGD:         req.DGD,
        Inputs:      inputs,
        Restart:     restart.State,
        Checkpoints: inputs.Checkpoints,
    })
    if err != nil {
        return Result{}, err
    }

    status := req.DGD.DeepCopy().Status
    applyComponentResult(&status, restart, workloads)

    return Result{
        Status: &status,
    }, nil
}

The exact intermediate types are less important than the direction: each value should state what has been established and what the next operation may rely on. Request is the primary object plus immutable observations; Result is the requeue decision plus the complete DGD status. Derived rollout, restart, checkpoint, and workload state stays local to the concrete program and appears in the signatures that consume it.

Comparison

Current PR Suggested refinement
Program owns complete control flow Unchanged
Outer controller selects and invokes one program Unchanged
Outer controller owns finalization and the single DGD status write Unchanged
Programs directly reconcile child/provider resources Unchanged
Non-status DGD mutations happen in their owning program Unchanged
Mutable graphReconcileState carries input, scratch state, and output Separate Request and returned Result
Later steps rely on fields populated by earlier steps Typed intermediate values make dependencies explicit
ctrl.Result cannot be returned by a program Embedded in the program Result

This should be a focused contract refactor, not an architectural rewrite. The implementation already lands on the composition-first side with respect to flow ownership. I would make this change before extracting Grove and the other pathways into separate packages, while the boundary is still package-private and inexpensive to change. Otherwise graphReconcileState is likely to harden into the long-term provider protocol.

@julienmancuso

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The controller now delegates reconciliation to typed workload programs, prepares shared program inputs, preserves status across failures, and threads deployment status through rolling-update helpers. Component and Grove paths share the new request/result flow, with updated tests covering selection, immutability, rollout, and state transitions.

Changes

Workload program reconciliation

Layer / File(s) Summary
Typed program contract and rollout orchestration
deploy/operator/internal/controller/dynamographdeployment_program.go, deploy/operator/internal/controller/dynamographdeployment_program_test.go, deploy/operator/internal/controller/dynamographdeployment_controller_test.go
Introduces typed workload program requests/results, failure classification, component and Grove reconciliation flows, and dedicated worker-rollout handling with updated tests.
Shared program inputs and controller integration
deploy/operator/internal/controller/dynamographdeployment_controller.go, deploy/operator/internal/controller/dynamographdeployment_controller_test.go
Replaces inline resource reconciliation with program-input preparation, delegates Reconcile to the selected program, propagates status, and classifies program failures.
Status-threaded rolling updates
deploy/operator/internal/controller/dynamographdeployment_rollingupdate.go, deploy/operator/internal/controller/dynamographdeployment_rollingupdate_test.go
Passes deployment status pointers through rolling-update helpers, moves managed support to componentProgram, and updates state-machine tests.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is thorough, but it omits the required Related Issues section and the reviewer-start guidance. Add the required Related Issues section with the issue link or confirmation, and include a Where should the reviewer start? section.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the refactor that moved rollout control into workload programs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
deploy/operator/internal/controller/dynamographdeployment_program.go (3)

338-381: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Inconsistent failure reason for the same desiredWorkerHashes call.

Line 342 classifies a desiredWorkerHashes failure as reasonFailedToInitializeWorkerHash, while the identical call at line 377 reports reasonRollingUpdateFailed. Same operation, same log message, two different reasons surfaced to users.

♻️ Align the reason
 	hashes, err := r.desiredWorkerHashes(dgd)
 	if err != nil {
 		logger.Error(err, "Failed to compute worker hash for unsupported pathway")
-		return failWorkloadProgram(reasonRollingUpdateFailed, err)
+		return failWorkloadProgram(reasonFailedToInitializeWorkerHash, err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/operator/internal/controller/dynamographdeployment_program.go` around
lines 338 - 381, Align the failure reason for the later desiredWorkerHashes call
in the unsupported-pathway rolling-update flow with the earlier initialization
call. Update the return after the second call to use
reasonFailedToInitializeWorkerHash, keeping the existing logging and hash-update
behavior unchanged.

165-174: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Unclassified failure reason for the missing-multinode-orchestrator path.

Every other terminal failure in this program is wrapped with failWorkloadProgram(...), so the controller can surface a precise Reason. This one falls back to the generic failed_to_reconcile_the_resources. If a dedicated reason existed before, this is a status regression; otherwise consider adding one for consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/operator/internal/controller/dynamographdeployment_program.go` around
lines 165 - 174, The missing-multinode-orchestrator branch in the reconciliation
program currently returns a generic error without classifying the failure.
Update this path to return through failWorkloadProgram(...) like the other
terminal failures, using the existing dedicated reason for this condition if
available, or add and use a specific reason for the unavailable multinode
orchestrator. Preserve the current error context and return behavior.

495-526: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Grove and component programs duplicate the shared tail sequence.

Lines 502-525 are a near-verbatim copy of componentProgram.Reconcile lines 158-191 (inputs → checkpoint status → restart → workloads → result → apply), and the worker-hash migration at 495-498 repeats reconcileWorkerRollout's first step. As the provider boundary grows, these will drift. A small shared helper taking a func(ctx, workloadReconcileRequest) (ReconcileResult, error) would keep both pathways in lockstep while leaving rollout selection program-specific.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/operator/internal/controller/dynamographdeployment_program.go` around
lines 495 - 526, Extract the duplicated reconciliation tail from the current
program flow and componentProgram.Reconcile into a shared helper that accepts a
workload reconciliation callback of type func(ctx, workloadReconcileRequest)
(ReconcileResult, error). Have the helper handle reconcileProgramInputs,
checkpoint status assignment, restart resolution, reconcileProgramResult, and
applying the result, while preserving program-specific rollout selection. Also
centralize the repeated worker-hash migration step used by
reconcileWorkerRollout and this flow without changing existing error behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deploy/operator/internal/controller/dynamographdeployment_program_test.go`:
- Around line 301-346: Refactor TestComponentProgram_ReconcileWorkerRollout into
a table-driven test covering single-node and multinode cases, parameterizing the
Multinode spec and expected RollingUpdate behavior. Add t.Log headings before
each setup, action, and assertion block so every test step is clearly
identified, while preserving the existing hash assertions and outcomes.

---

Nitpick comments:
In `@deploy/operator/internal/controller/dynamographdeployment_program.go`:
- Around line 338-381: Align the failure reason for the later
desiredWorkerHashes call in the unsupported-pathway rolling-update flow with the
earlier initialization call. Update the return after the second call to use
reasonFailedToInitializeWorkerHash, keeping the existing logging and hash-update
behavior unchanged.
- Around line 165-174: The missing-multinode-orchestrator branch in the
reconciliation program currently returns a generic error without classifying the
failure. Update this path to return through failWorkloadProgram(...) like the
other terminal failures, using the existing dedicated reason for this condition
if available, or add and use a specific reason for the unavailable multinode
orchestrator. Preserve the current error context and return behavior.
- Around line 495-526: Extract the duplicated reconciliation tail from the
current program flow and componentProgram.Reconcile into a shared helper that
accepts a workload reconciliation callback of type func(ctx,
workloadReconcileRequest) (ReconcileResult, error). Have the helper handle
reconcileProgramInputs, checkpoint status assignment, restart resolution,
reconcileProgramResult, and applying the result, while preserving
program-specific rollout selection. Also centralize the repeated worker-hash
migration step used by reconcileWorkerRollout and this flow without changing
existing error behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 1d15ac70-bfd2-44f4-82a4-f640213237b5

📥 Commits

Reviewing files that changed from the base of the PR and between 9aefb4d and 7bfdae6.

📒 Files selected for processing (6)
  • deploy/operator/internal/controller/dynamographdeployment_controller.go
  • deploy/operator/internal/controller/dynamographdeployment_controller_test.go
  • deploy/operator/internal/controller/dynamographdeployment_program.go
  • deploy/operator/internal/controller/dynamographdeployment_program_test.go
  • deploy/operator/internal/controller/dynamographdeployment_rollingupdate.go
  • deploy/operator/internal/controller/dynamographdeployment_rollingupdate_test.go

@julienmancuso

Copy link
Copy Markdown
Contributor Author

thanks @sttts , that makes sense,
I updated the MR.
there is one intentional difference from the example contract: Result.Status can be non-nil together with an error.
This preserves status progress produced by an earlier successful step.
for example, starting a rolling update or resolving checkpoint status, when a later step encounters a transient error. The request DGD’s status is never mutated directly, the partial status still travels through the result and is persisted once by the high-level reconciler.
On a completely successful reconciliation, Result.Status is the complete desired status. On error, it represents the accumulated status up to the failing step, after which the high-level reconciler adds the failure/Ready condition.
Does that error-result semantic match what you had in mind, or would you prefer the stricter rule that Status must always be nil when returning an error?

@sttts sttts left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One request-contract simplification.

// resolvedFacts contains immutable observations resolved independently of the
// selected workload program. It is intentionally empty until such a fact is
// demonstrated; program-derived values remain typed locals.
type resolvedFacts struct{}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disclosure: This comment was written by Codex as its interpretation of a discussion with Stefan; it is not Stefan’s text.

I would remove both resolvedFacts and the Facts field from workloadProgramRequest for now.

The outer reconciler currently does not resolve any program-independent observations before selecting and invoking the program. All ephemeral computed state is intentionally derived inside the selected program and in sequence: programInputs, programRestart, the component rolling-update context, and Grove render inputs. There is therefore no concrete value that belongs in Facts today.

Keeping an empty placeholder anticipates an abstraction without a demonstrated contract and creates an obvious place for a generic state bag to grow back over time. The honest current boundary seems to be:

type workloadProgramRequest struct {
    // DGD is the mutable primary object. Programs may persist non-status
    // mutations directly; proposed status is returned through Result.
    DGD *nvidiacomv1beta1.DynamoGraphDeployment
}

If a genuinely program-independent observation is later computed outside the selected program, we can add it then with a domain-specific type and name. Until that exists, the minimal request makes the ownership boundary clearer.

Signed-off-by: Julien Mancuso <jmancuso@nvidia.com>
Signed-off-by: Julien Mancuso <jmancuso@nvidia.com>
Signed-off-by: Julien Mancuso <jmancuso@nvidia.com>
@datadog-official

datadog-official Bot commented Jul 31, 2026

Copy link
Copy Markdown

Tests

🔄 Datadog auto-retried 4 jobs - 3 passed on retry View in Datadog

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 34b2418 | Docs | Datadog PR Page | Give us feedback!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deployment::k8s Relates to dynamo deployment in kubernetes refactor size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants