Skip to content

feat!: add ability to cancel pending workspace build #18713

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jul 8, 2025

Conversation

kacpersaw
Copy link
Contributor

@kacpersaw kacpersaw commented Jul 2, 2025

Closes #17791

This PR adds ability to cancel workspace builds that are in "pending" status.

Breaking changes:

  • CancelWorkspaceBuild method in codersdk now accepts an optional request parameter

API:

  • Added expect_status query parameter to the cancel workspace build endpoint
  • This parameter ensures the job hasn't changed state before canceling
  • API returns 412 Precondition Failed if the job is not in the expected status
  • Valid values: running or pending
  • Wrapped the entire cancel method in a database transaction

UI:

  • Added confirmation dialog to the Cancel button, since it's a destructive operation
    image
    image

  • Enabled cancel action for pending workspaces (expect_status=pending is sent if workspace is in pending status)
    image

@kacpersaw kacpersaw marked this pull request as ready for review July 2, 2025 13:52
@github-actions github-actions bot added the release/breaking This label is applied to PRs to detect breaking changes as part of the release process label Jul 2, 2025
Copy link
Member

@ethanndickson ethanndickson left a comment

Choose a reason for hiding this comment

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

Looking good, just one comment w/ the transaction

ethanndickson
ethanndickson previously approved these changes Jul 3, 2025
@kacpersaw kacpersaw force-pushed the kacpersaw/cancel-pending-provisioner-jobs branch from 8081f33 to 42170ab Compare July 3, 2025 10:51
@kacpersaw kacpersaw requested a review from BrunoQuaresma July 3, 2025 11:42
Copy link
Member

@deansheather deansheather left a comment

Choose a reason for hiding this comment

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

BE only review

code := http.StatusOK
resp := codersdk.Response{}
err = api.Database.InTx(func(store database.Store) error {
valid, err := verifyUserCanCancelWorkspaceBuilds(ctx, store, httpmw.APIKey(r).UserID, workspace.TemplateID)
Copy link
Member

Choose a reason for hiding this comment

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

We often just use the variable name db in these transaction closures

Comment on lines 610 to 615
if !valid {
code = http.StatusForbidden
resp.Message = "User is not allowed to cancel workspace builds. Owner role is required."

return xerrors.Errorf("user is not allowed to cancel workspace builds")
}
Copy link
Member

Choose a reason for hiding this comment

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

I think we should still allow for cancellations even if the template forbids them as long as the user specified expect_status=pending.

return xerrors.Errorf("user is not allowed to cancel workspace builds")
}

job, err := store.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
Copy link
Member

Choose a reason for hiding this comment

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

This should probably be a clone of this query with a SELECT ... FOR UPDATE query instead, otherwise there's nothing preventing this job from being picked up during the cancel operation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, I forgot to change that query 🙅

code = http.StatusBadRequest
resp.Message = "Job has already completed!"

return xerrors.Errorf("job has already completed")
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return xerrors.Errorf("job has already completed")
return xerrors.New("job has already completed")

code = http.StatusBadRequest
resp.Message = "Job has already been marked as canceled!"

return xerrors.Errorf("job has already been marked as canceled")
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return xerrors.Errorf("job has already been marked as canceled")
return xerrors.New("job has already been marked as canceled")

code = http.StatusBadRequest
resp.Message = "Invalid expect_status. Only 'running' or 'pending' are allowed."

return xerrors.Errorf("invalid expect_status")
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return xerrors.Errorf("invalid expect_status")
return xerrors.Errorf("invalid expect_status %q", expectStatus)

if expectStatus != "" {
if expectStatus != "running" && expectStatus != "pending" {
code = http.StatusBadRequest
resp.Message = "Invalid expect_status. Only 'running' or 'pending' are allowed."
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
resp.Message = "Invalid expect_status. Only 'running' or 'pending' are allowed."
resp.Message = fmt.Sprintf("Invalid expect_status %q. Only 'running' or 'pending' are allowed.", expectStatus)

code = http.StatusPreconditionFailed
resp.Message = "Job is not in the expected state."

return xerrors.Errorf("job is not in the expected state")
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return xerrors.Errorf("job is not in the expected state")
return xerrors.Errorf("job is not in the expected state: expected %q, got %q", expectStatus, job.JobStatus)

CancelWorkspaceBuildStatusPending CancelWorkspaceBuildStatus = "pending"
)

type CancelWorkspaceBuildRequest struct {
Copy link
Member

Choose a reason for hiding this comment

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

Maybe call this Params instead of Request since it's not the request body

Copy link
Collaborator

@BrunoQuaresma BrunoQuaresma left a comment

Choose a reason for hiding this comment

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

I left a few comments related to the FE.

Note: When submitting FE related code, it is always nice to include a screenshot or a short video demo showing off the changes. It makes easier for us to visualize things and make faster reviews.

@@ -1277,9 +1277,14 @@ class ApiMethods {

cancelWorkspaceBuild = async (
workspaceBuildId: TypesGen.WorkspaceBuild["id"],
request?: TypesGen.CancelWorkspaceBuildParams,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since the type has the "Params" sufix, I would name this variable as params instead of request.

): Promise<TypesGen.Response> => {
const params = request?.expect_status
Copy link
Collaborator

Choose a reason for hiding this comment

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

You can do:

const params = new URLSearchParams(params)

const response = await this.axios.patch(
`/api/v2/workspacebuilds/${workspaceBuildId}/cancel`,
`/api/v2/workspacebuilds/${workspaceBuildId}/cancel${params ? `?${params}` : ""}`,
Copy link
Collaborator

Choose a reason for hiding this comment

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

And just use it directly:

`/api/v2/workspacebuilds/${workspaceBuildId}/cancel?${params}`

@@ -266,6 +266,12 @@ export const startWorkspace = (
export const cancelBuild = (workspace: Workspace, queryClient: QueryClient) => {
return {
mutationFn: () => {
// If workspace status is pending, include expect_status parameter
Copy link
Collaborator

Choose a reason for hiding this comment

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

The comment is just saying what the code is doing below. I would rather not having this comment at all, since I can see it in the code, or explaining the why we need to set the expect_status to pending.

@@ -352,6 +354,21 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
}
/>

{/* Cancel confirmation dialog */}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think we need this comment. The code below is pretty easy to understand.

@@ -497,6 +497,8 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({

// State for stop confirmation dialog
const [isStopConfirmOpen, setIsStopConfirmOpen] = useState(false);
// State for cancel confirmation dialog
Copy link
Collaborator

Choose a reason for hiding this comment

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

The same here, even seeing we do this for the state above.

@@ -292,6 +292,19 @@ export const BypassRatelimitHeader = "X-Coder-Bypass-Ratelimit";
// From codersdk/client.go
export const CLITelemetryHeader = "Coder-CLI-Telemetry";

// From codersdk/workspacebuilds.go
export interface CancelWorkspaceBuildParams {
readonly expect_status?: CancelWorkspaceBuildStatus;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I can be wrong, but I think when we add a comment to the struct, or its props in codersdk package, it gets included in the TS types as well. I think lefting a comment in the CancelWorkspaceBuildParams.ExpectStatus could be helpful to understand when and why use it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I left a comment in codersdk, but unfortunately it doesn't get passed to the TS types 😞

@kacpersaw kacpersaw force-pushed the kacpersaw/cancel-pending-provisioner-jobs branch from 11d8c0a to c5cb203 Compare July 6, 2025 09:48
@kacpersaw kacpersaw requested a review from deansheather July 7, 2025 05:51
Copy link
Member

@deansheather deansheather left a comment

Choose a reason for hiding this comment

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

BG looks good, just some minor comments

@@ -581,10 +581,12 @@ func (api *API) notifyWorkspaceUpdated(
// @Produce json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID"
// @Param expect_status query string false "Expected status of the job" Enums(running, pending)
Copy link
Member

Choose a reason for hiding this comment

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

It might pay to have a description field (idk if our API generation has something like that, you might need to check other routes) that explains in more detail what this actually does:

If expect_status is supplied, the request will be rejected with 499 Precondition Failed if the job doesn't match the state when performing the cancellation.

Comment on lines 639 to 644
if expectStatus != "running" && expectStatus != "pending" {
code = http.StatusBadRequest
resp.Message = fmt.Sprintf("Invalid expect_status %q. Only 'running' or 'pending' are allowed.", expectStatus)

return xerrors.Errorf("invalid expect_status %q", expectStatus)
}
Copy link
Member

Choose a reason for hiding this comment

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

This status type checking stuff should probably happen outside of the TX at the top of the route since it doesn't depend on the job properties.

})
return
}
code := http.StatusOK
Copy link
Member

Choose a reason for hiding this comment

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

This should probably have a default status code of 500 and a generic error message, otherwise if the tx returns an error below (because the tx couldn't start) you'll be returning an empty 200 response.

@@ -659,8 +688,13 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
})
}

func (api *API) verifyUserCanCancelWorkspaceBuilds(ctx context.Context, userID uuid.UUID, templateID uuid.UUID) (bool, error) {
template, err := api.Database.GetTemplateByID(ctx, templateID)
func verifyUserCanCancelWorkspaceBuilds(ctx context.Context, store database.Store, userID uuid.UUID, templateID uuid.UUID, expectStatus string) (bool, error) {
Copy link
Member

Choose a reason for hiding this comment

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

If you move the parsing/checking of the expectStatus param to the top of the route, this could just directly accept the database enum type instead of the string.

return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning
}, testutil.WaitShort, testutil.IntervalFast)

// When: a cancel request is made with expect_state=pending
Copy link
Member

Choose a reason for hiding this comment

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

Does this have a chance to race against the job succeeding? The code for checking if the build is already done is before the code that checks the expect_status param

Copy link
Contributor Author

Choose a reason for hiding this comment

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

From my understanding - this job should stay in running state until cancel, because the test doesn't include ApplyComplete in ProvisionApply.
https://github.com/coder/coder/blob/main/provisioner/echo/serve.go#L194


type CancelWorkspaceBuildParams struct {
// ExpectStatus ensures the build is in the expected status before canceling.
ExpectStatus CancelWorkspaceBuildStatus `json:"expect_status,omitempty"`
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
ExpectStatus CancelWorkspaceBuildStatus `json:"expect_status,omitempty"`
ExpectStatus CancelWorkspaceBuildStatus `json:"-"`

Since it's never used as JSON

Copy link
Member

Choose a reason for hiding this comment

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

Or just omit the json tag entirely perhaps

Copy link
Contributor Author

Choose a reason for hiding this comment

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

json tag is required to generate correct field names for TS - without it, you'll get ExpectStatus instead of the required expect_status param.

@ethanndickson ethanndickson dismissed their stale review July 7, 2025 10:05

outdated

Copy link
Member

@deansheather deansheather left a comment

Choose a reason for hiding this comment

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

Backend looks great!

<ConfirmDialog
open={isCancelConfirmOpen}
title="Cancel workspace build"
description={`Are you sure you want to cancel the build for workspace "${workspace.name}"? This will stop the current build process.`}
Copy link
Member

Choose a reason for hiding this comment

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

Would be nice if this could differ based on whether it thinks it's going to cancel a pending or running build.

e.g. This will remove the current build from the build queue.

Copy link
Member

Choose a reason for hiding this comment

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

Same with the Dialog on the other page too, maybe it should be made into a component?

kacpersaw and others added 2 commits July 7, 2025 14:30
@kacpersaw kacpersaw requested a review from BrunoQuaresma July 7, 2025 14:06
Copy link
Collaborator

@BrunoQuaresma BrunoQuaresma left a comment

Choose a reason for hiding this comment

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

Just left one minor question, but it should not block the PR. 👍

status === "pending" || status === "running"
? { expect_status: status }
: undefined;
return API.cancelWorkspaceBuild(workspace.latest_build.id, params);
Copy link
Collaborator

@BrunoQuaresma BrunoQuaresma Jul 7, 2025

Choose a reason for hiding this comment

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

Double checking... can cancelWorkspaceBuild being called in any status? I see the params can be undefined, but it would still call the cancelWorkspaceBuild 🤔. This part of the code is quite hard for me to understand why the params are only set when the status is pending or running.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

expect_status is an optional parameter for cancel that ensures the job is in a specific state before proceeding - it only supports pending and running. So, cancel can be called without it.

@kacpersaw kacpersaw merged commit 8202514 into main Jul 8, 2025
63 of 64 checks passed
@kacpersaw kacpersaw deleted the kacpersaw/cancel-pending-provisioner-jobs branch July 8, 2025 09:03
@github-actions github-actions bot locked and limited conversation to collaborators Jul 8, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
release/breaking This label is applied to PRs to detect breaking changes as part of the release process
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Ability to cancel Pending provisioner jobs in the UI
4 participants