Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions cmd/workload/up/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,11 @@ sends the sizing with the rollout, so the new version comes up with it.

A workload that is not ready to be deployed onto is dealt with rather than
refused. One still starting or stopping is waited out and then re-read, so the
plan is built against where it landed. A stopped one is started and then
reconciled in the same run, which is one command whether the file asks for a
start alone or for a start and a new version.
plan is built against where it landed, and so is one already being rolled onto
a new version, which reports itself running for the whole of the swap. A
stopped one is started and then reconciled in the same run, which is one
command whether the file asks for a start alone or for a start and a new
version.

Examples:
dr workload up
Expand Down
236 changes: 235 additions & 1 deletion internal/workload/up/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"time"

"github.com/datarobot/cli/internal/drapi"
"github.com/datarobot/cli/internal/log"
"github.com/datarobot/cli/internal/workload"
"github.com/datarobot/cli/internal/workload/ignore"
"github.com/datarobot/cli/internal/workload/manifest"
Expand Down Expand Up @@ -54,6 +55,7 @@ var (
listBuildsFn = workload.ListArtifactBuilds
getCredentialFn = workload.GetCredential
findCredentialFn = workload.FindCredentialNamed
activeReplacementFn = workload.GetActiveReplacement
guardReplacementFn = workload.RefuseActiveReplacement
startReplacementFn = workload.StartReplacement
waitReplacementFn = workload.WaitForReplacement
Expand Down Expand Up @@ -327,13 +329,245 @@ func guardRollout(workloadID, consequence string) error {

// lookSettled is the live read a plan is built from: the workload as it is,
// once it has stopped moving.
//
// Two things can be moving, and they are asked about in this order because the
// rollout is the coarser of them. A swap that lands leaves the workload coming
// up, so waiting the rollout out first and the status second settles both in
// one pass; the other order would return with the workload still provisioning
// and hand deployable a state it has to refuse. Neither wait needs the plan,
// which is the point: a deploy arriving mid-transition is early rather than
// wrong.
func lookSettled(workloadID string, opts Options) (Live, error) {
found, err := Look(workloadID)
if err != nil {
return Live{}, err
}

return awaitSteady(found, opts)
replaced, previewed, err := awaitReplaced(found, opts)
if err != nil {
return replaced, err
}

// A preview that has already said a deploy would wait for the rollout says
// nothing further. The settling state it hands back is synthetic — put there
// so an empty plan is not called up to date — and awaitSteady would read it
// as a second transition and print the same sentence about the same wait.
if previewed {
return replaced, nil
}

return awaitSteady(replaced, opts)
}

// awaitReplaced waits out a swap somebody else already started, and hands back
// the workload as it is once that swap has landed.
//
// This is the transition awaitSteady structurally cannot see: a workload being
// replaced reports itself running for the whole of the swap, so the only way to
// know is to ask the replacement route. It used to be asked as a refusal, which
// left the same dead end a settling workload used to have — "wait for it to
// settle before starting another" is work the command can do itself.
//
// The re-read afterwards is not a formality. A rollout that completes moves the
// workload onto a different artifact, so a plan built against the state as it
// was would roll a version the platform had already installed, or report drift
// the swap had just closed.
//
// The guards at the apply sites stay refusals and are not made redundant by
// this. A rollout that appears after this read is somebody deploying
// concurrently, and waiting there would apply a plan built against state that
// has since moved.
//
// A dry run never waits, for the reason it never waits on a settling workload:
// blocking a preview for the poll timeout is the opposite of what a preview is
// for. The bool it returns is that case and only that case: true means the
// preview has already said a deploy would wait here, so the caller stops rather
// than letting awaitSteady say it again about the settling state synthesised
// below. A real run never returns true, because it waits instead of previewing.
func awaitReplaced(live Live, opts Options) (Live, bool, error) {
if !replaceable(live) {
return live, false, nil
}

active, err := activeReplacementFn(live.WorkloadID)
if err != nil {
return live, false, fmt.Errorf(
"cannot tell whether workload %s already has a rollout in progress, so nothing was deployed: %w",
live.WorkloadID, err)
}

if active == nil {

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.

Nit: nothing marks the quiet path (no rollout in flight) in the debug log.

version.go in this same package logs decisions the user wouldn't see otherwise (e.g. log.Debug("artifact repository refused; creating the version in a new one", ...) at line 220) so a --debug run can explain why something took the path it did. awaitReplaced never touches internal/log at all — this branch is the ordinary, silent case (nothing in flight, proceed straight to awaitSteady), so probably fine as-is, but worth a log.Debug("no replacement in flight", "workload_id", live.WorkloadID) if a support engineer ever needs to confirm from a --debug transcript that this route was even checked.

// The ordinary path, and the only one that says nothing to the user.
// Logged so a --debug transcript shows the route was asked at all: a
// deploy that planned against a stale artifact looks the same here
// whether the answer was "nothing in flight" or the question was never
// put, and those have different causes.
log.Debug("no rollout in flight; planning against the workload as read",
"workload_id", live.WorkloadID)

return live, false, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Completed swap skips the re-read

Medium Severity

awaitReplaced returns the original Look when the replacement route already shows a terminal record, so a swap that finishes between those two reads is never re-fetched. The plan is then built against the pre-swap artifact, awaitSteady is skipped because that snapshot still says running, and --lock can make the outgoing version permanent.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 43650a6. Configure here.


// A settled record stays readable for a while after the rollout ends, so
// waiting on one would block every deploy for as long as it lingers. It is
// still re-read rather than returned as it stands: a terminal record means a
// swap landed recently, possibly in the window between the read above and
// this one, and the snapshot from before it names the outgoing artifact.
// Planning against that would roll a version the platform had just
// installed, and a --lock run would make the version being rolled off
// permanent, which cannot be undone.
//
// The residual, stated rather than pretended away: a swap whose record is
// collected inside that same window reads as nil above and is not re-read.
// Closing it means re-reading on every deploy, which doubles the workload
// GET on the quiet path to catch a window narrower than the one this covers.
if workload.IsTerminalReplacementStatus(active.Status) {

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.

This branch is the one truly silent decision in the new code — worth a debug log.

When the replacement record read here is already terminal, the function discards live and does a second, unlogged Look() instead of waiting — the exact narrow race the PR description calls out (a swap landing between the workload read and the replacement read). Nothing is said to the user (no report.say) and nothing goes to internal/log either, so this decision is invisible even under --debug. Compare to version.go's fellBack handling, which is deliberately "said out loud rather than only to the debug log" for the same reason: "nobody goes looking for the cause of that after a run that printed nothing but check marks." Something like:

log.Debug("replacement already settled; re-reading before planning",
    "workload_id", live.WorkloadID, "status", active.Status)

would make this traceable if a plan ever looks like it rolled the wrong artifact.

// The one decision here that is neither waited on nor said out loud, so
// the debug log is the only place it can be seen. It matters after the
// fact: a plan that looks like it rolled the wrong artifact is either
// this re-read having happened or it not having happened.
log.Debug("the rollout already settled; re-reading before planning",
"workload_id", live.WorkloadID, "replacement_id", active.ID, "status", active.Status)

refreshed, err := Look(live.WorkloadID)

return refreshed, false, err
}

report := newReporter(opts.Stderr, opts.Spinner)

report.say(" %s\n", tui.HintStyle.Render(replacingNote(live.WorkloadID, active)))

if opts.DryRun {
report.say(" %s\n", tui.HintStyle.Render(
"A deploy would wait for this rollout to finish and plan against where it lands."))

// The plan below is computed against a workload the platform is already
// moving, so an empty one is not "up to date": the swap decides what
// differs, and it has not landed. StateSettling is what says so, and it
// is the same answer for the same reason a workload halfway through
// stopping gets. Without it the preview prints "Already up to date"
// directly beneath the note above, contradicting it.
//
// Safe to synthesise because it is confined to the preview. Every state
// that decides the shape of a plan already groups settling with running
// (see actsOnState and creates), so only the verdict moves; and a real
// run never arrives here with it, because it waits and re-reads instead.
live.State = StateSettling

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.

Dry-run with a rollout in flight prints the same hint twice.

When --dry-run hits an in-flight rollout, awaitReplaced (line 369 above) prints:

A deploy would wait for this rollout to finish and plan against where it lands.

...then sets live.State = StateSettling and returns. Back in lookSettled, awaitSteady(replaced, opts) runs next, sees live.State == StateSettling, and — since opts.DryRun is also true — prints its own near-identical hint right after:

A deploy would wait for this to finish and plan against where it lands.

Confirmed live by running the TestRun_DryRunWithARolloutInFlightDoesNotWait scenario and dumping stderr — both lines appear back to back:

  Workload 68b0c1d2e3f4a5b6c7d8e9f0 is being replaced, status switching.
  A deploy would wait for this rollout to finish and plan against where it lands.
  A deploy would wait for this to finish and plan against where it lands.

None of the new tests catch this because they only assert.Contains, never checking the message isn't duplicated. Worth either short-circuiting lookSettled after the dry-run synthetic-state assignment, or having awaitSteady skip its own hint when the settling state came from awaitReplaced rather than a real read.


return live, true, nil
}

if opts.Detach {
// --detach is about not waiting for the deploy to serve. This wait is
// before the deploy: what to apply cannot be known until the swap has
// landed. Saying so beats blocking in silence.
report.say(" %s\n", tui.HintStyle.Render(
"Waiting for it to land before planning; --detach applies to the deploy."))
}

var settled *workload.Replacement

err = report.run("Waiting for the rollout already in progress", func() error {
// Seeded with the record just read, which is what lets the wait tell a
// rollout the platform settled before the first poll landed from one
// that was never there at all.
replacement, waitErr := waitReplacementFn(
live.WorkloadID, active, opts.PollInterval, opts.PollTimeout, nil)
settled = replacement

return waitErr
})
if err != nil {
if failure := replacedFailed(live, settled, err); failure != nil {
return live, false, failure
}

report.say(" %s\n", tui.WarnStyle.Render(fmt.Sprintf(
"⚠ That rollout ended as %s, so the workload is still running the version it was.",
settled.Status)))
}

refreshed, err := Look(live.WorkloadID)

return refreshed, false, err
}

// replaceable says whether there is a workload for the replacement route to
// answer about.
//
// A workload the platform does not have cannot be being replaced, and the route
// answers the same 404 for "no such workload" as it does for "nothing in
// flight", so asking about one would be a round trip whose answer could not be
// read either way. A terminated workload is refused by deployable regardless,
// so the question is only cost there too.
func replaceable(live Live) bool {
if live.WorkloadID == "" {
return false
}

switch live.State {
case StateUnbound, StateMissing, StateTerminated:
return false

case StateStopped, StateSettling, StateRunning, StateErrored:
return true

default:
return true
}
}

// replacingNote says what is in flight, carrying only the fields the platform
// filled in.
//
// The artifact is one of them because a settings-only rollout moves the
// workload onto the artifact it is already running and the platform returns no
// candidate for it, so naming one unconditionally would print an empty field
// mid-sentence.
//
// It is said before the wait, not after. Without a terminal there is no spinner
// and a phase prints nothing until it ends, so a CI log would otherwise show
// nothing at all for as long as the swap takes.
func replacingNote(workloadID string, active *workload.Replacement) string {
note := "Workload " + workloadID + " is being replaced"

if active.ArtifactID != "" {
note += " onto artifact " + active.ArtifactID
}

if active.Status != "" {
note += ", status " + active.Status
}

return note + "."
}

// replacedFailed is the verdict on a wait that did not come back clean, and
// returns nil for the one case the run carries on from.
//
// A rollout that ends failed never promotes: the version that was serving is
// still serving, so the workload is deployable and this deploy is the natural
// remedy. Refusing there would hand back the same "run it again" dead end this
// wait exists to remove, so the caller notes what happened and plans against
// what the failure left behind.
//
// Everything else is a wait that told us nothing — a timeout or a failed poll —
// and names where the rollout got to, which is what decides whether to wait
// longer or go and look at the platform.
func replacedFailed(live Live, settled *workload.Replacement, err error) error {
if settled != nil && workload.IsFailedReplacementStatus(settled.Status) {
return nil
}

where := "did not finish rolling out"
if settled != nil && settled.Status != "" {
where = "was still " + settled.Status
}

return fmt.Errorf(
"the rollout of workload %s %s, so nothing was deployed; check 'dr workload status %s': %w",
live.WorkloadID, where, live.WorkloadID, err)
}

// awaitSteady waits out a workload that is still moving, and hands back what
Expand Down
Loading
Loading