Skip to content

Commit 9ed9edf

Browse files
committed
Support workflow terminate rollback
1 parent 1cea129 commit 9ed9edf

14 files changed

Lines changed: 222 additions & 25 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@cloudflare/workflows-shared": minor
3+
"wrangler": minor
4+
"miniflare": minor
5+
---
6+
7+
Add rollback support when terminating Workflow instances
8+
9+
`WorkflowInstance.terminate({ rollback: true })` now runs registered rollback handlers before marking a local Workflow instance as terminated. Wrangler also supports this via `wrangler workflows instances terminate --rollback`, including local mode.
10+
11+
The rollback option is only sent for terminate operations and is rejected by the Local Explorer API for pause, resume, and restart actions.

packages/miniflare/scripts/openapi-filter-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,11 @@ const config = {
11691169
},
11701170
},
11711171
},
1172+
rollback: {
1173+
type: "boolean",
1174+
description:
1175+
"Run rollback before terminating. Only valid when action is terminate.",
1176+
},
11721177
},
11731178
},
11741179
},

packages/miniflare/src/workers/local-explorer/generated/types.gen.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,6 +1747,10 @@ export type WorkflowsChangeInstanceStatusData = {
17471747
*/
17481748
type?: "do" | "sleep" | "waitForEvent";
17491749
};
1750+
/**
1751+
* Run rollback before terminating. Only valid when action is terminate.
1752+
*/
1753+
rollback?: boolean;
17501754
};
17511755
path: {
17521756
workflow_name: WorkflowsWorkflowName;

packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,7 @@ export const zWorkflowsChangeInstanceStatusData = z.object({
10901090
type: z.enum(["do", "sleep", "waitForEvent"]).optional(),
10911091
})
10921092
.optional(),
1093+
rollback: z.boolean().optional(),
10931094
}),
10941095
path: z.object({
10951096
workflow_name: zWorkflowsWorkflowName,

packages/miniflare/src/workers/local-explorer/openapi.local.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,6 +1810,10 @@
18101810
"description": "The step type. Defaults to do."
18111811
}
18121812
}
1813+
},
1814+
"rollback": {
1815+
"type": "boolean",
1816+
"description": "Run rollback before terminating. Only valid when action is terminate."
18131817
}
18141818
}
18151819
}

packages/miniflare/src/workers/local-explorer/resources/workflows.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import type {
1111
WorkflowsWorkflow,
1212
} from "../generated";
1313
import type { zWorkflowsListInstancesData } from "../generated/zod.gen";
14-
import type { RestartFromStep } from "@cloudflare/workflows-shared/src/binding";
14+
import type {
15+
RestartFromStep,
16+
WorkflowInstanceTerminateOptions,
17+
} from "@cloudflare/workflows-shared/src/binding";
1518
import type { z } from "zod";
1619

1720
// ============================================================================
@@ -35,7 +38,7 @@ interface WorkflowHandle {
3538
pause(): Promise<void>;
3639
resume(): Promise<void>;
3740
restart(options?: { from?: RestartFromStep }): Promise<void>;
38-
terminate(): Promise<void>;
41+
terminate(options?: WorkflowInstanceTerminateOptions): Promise<void>;
3942
sendEvent(args: { payload: unknown; type: string }): Promise<void>;
4043
status(): Promise<{ status: string; output?: unknown; error?: unknown }>;
4144
}
@@ -886,6 +889,14 @@ export async function changeWorkflowInstanceStatus(
886889
);
887890
}
888891

892+
if (body.rollback !== undefined && action !== "terminate") {
893+
return errorResponse(
894+
400,
895+
10001,
896+
"'rollback' is only valid when terminating."
897+
);
898+
}
899+
889900
const handle = await workflow.get(instanceId);
890901

891902
switch (action) {
@@ -909,7 +920,9 @@ export async function changeWorkflowInstanceStatus(
909920
break;
910921
}
911922
case "terminate":
912-
await handle.terminate();
923+
await (handle as unknown as WorkflowHandle).terminate(
924+
body.rollback === true ? { rollback: true } : undefined
925+
);
913926
break;
914927
}
915928

packages/miniflare/src/workers/workflows/wrapped-binding.worker.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
WorkflowBinding,
33
WorkflowInstanceRestartOptions,
4+
WorkflowInstanceTerminateOptions,
45
} from "@cloudflare/workflows-shared/src/binding";
56

67
class WorkflowImpl implements Workflow {
@@ -84,9 +85,14 @@ class InstanceImpl implements WorkflowInstance {
8485
await instance.resume();
8586
}
8687

87-
public async terminate(): Promise<void> {
88-
using instance = await this.getInstance();
89-
await instance.terminate();
88+
public async terminate(
89+
options?: WorkflowInstanceTerminateOptions
90+
): Promise<void> {
91+
using instance = (await this.binding.get(this.id)) as Awaited<
92+
ReturnType<WorkflowBinding["get"]>
93+
> &
94+
Disposable;
95+
await instance.terminate(options);
9096
}
9197

9298
public async restart(

packages/workflows-shared/src/binding.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export interface RestartFromStep {
2929
type?: "do" | "sleep" | "waitForEvent";
3030
}
3131

32+
export interface WorkflowInstanceTerminateOptions {
33+
/**
34+
* If true, run registered rollback handlers before terminating the instance.
35+
*/
36+
rollback?: boolean;
37+
}
38+
3239
export interface WorkflowInstanceRestartOptions {
3340
from?: RestartFromStep;
3441
}
@@ -92,7 +99,7 @@ export class WorkflowBinding extends WorkerEntrypoint<Env> {
9299
};
93100
}
94101

95-
public async get(id: string): Promise<WorkflowInstance> {
102+
public async get(id: string): Promise<WorkflowHandle> {
96103
const stubId = this.env.ENGINE.idFromName(id);
97104
const stub = this.env.ENGINE.get(stubId);
98105

@@ -210,9 +217,11 @@ export class WorkflowHandle extends RpcTarget implements WorkflowInstance {
210217
await this.stub.changeInstanceStatus("resume");
211218
}
212219

213-
public async terminate(): Promise<void> {
220+
public async terminate(
221+
options?: WorkflowInstanceTerminateOptions
222+
): Promise<void> {
214223
try {
215-
await this.stub.changeInstanceStatus("terminate");
224+
await this.stub.changeInstanceStatus("terminate", undefined, options);
216225
} catch (e) {
217226
// terminate causes instance abortion
218227
if (!isUserTriggeredTerminate(e)) {

packages/workflows-shared/src/engine.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ import {
3838
} from "./lib/streams";
3939
import { TimePriorityQueue } from "./lib/timePriorityQueue";
4040
import { MODIFIER_KEYS, WorkflowInstanceModifier } from "./modifier";
41-
import type { RestartFromStep } from "./binding";
41+
import type {
42+
RestartFromStep,
43+
WorkflowInstanceTerminateOptions,
44+
} from "./binding";
4245
import type { Event } from "./context";
4346
import type { InstanceMetadata, RawInstanceLog } from "./instance";
4447
import type { RollbackRegistryEntry } from "./lib/rollback";
@@ -804,8 +807,9 @@ export class Engine extends DurableObject<Env> {
804807

805808
async changeInstanceStatus(
806809
newStatus: "resume" | "pause" | "terminate" | "restart",
807-
from?: RestartFromStep
808-
) {
810+
from?: RestartFromStep,
811+
terminateOptions?: WorkflowInstanceTerminateOptions
812+
): Promise<void> {
809813
const metadata =
810814
await this.ctx.storage.get<InstanceMetadata>(INSTANCE_METADATA);
811815

@@ -849,7 +853,7 @@ export class Engine extends DurableObject<Env> {
849853
"instance.cannot_terminate"
850854
);
851855
}
852-
await this.userTriggeredTerminate();
856+
await this.userTriggeredTerminate(terminateOptions);
853857
break;
854858
}
855859
case "restart":
@@ -864,7 +868,7 @@ export class Engine extends DurableObject<Env> {
864868
}
865869
}
866870

867-
async userTriggeredTerminate() {
871+
async userTriggeredTerminate(options?: WorkflowInstanceTerminateOptions) {
868872
const metadata =
869873
await this.ctx.storage.get<InstanceMetadata>(INSTANCE_METADATA);
870874

@@ -875,6 +879,16 @@ export class Engine extends DurableObject<Env> {
875879
);
876880
}
877881

882+
if (options?.rollback === true && this.rollbackRegistry.size > 0) {
883+
const error = new Error("Instance terminated during rollback");
884+
error.name = "Terminated";
885+
try {
886+
await executeRollbacks(this, error);
887+
} catch (rollbackErr) {
888+
console.error("Rollback execution failed:", rollbackErr);
889+
}
890+
}
891+
878892
this.writeLog(InstanceEvent.WORKFLOW_TERMINATED, null, null, {
879893
trigger: {
880894
source: InstanceTrigger.API,

packages/workflows-shared/tests/engine.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1673,6 +1673,52 @@ describe("Rollback", () => {
16731673
expect(countOf(logs, InstanceEvent.ROLLBACK_COMPLETE)).toBe(0);
16741674
});
16751675

1676+
it("runs rollback when terminate requests it", async ({ expect }) => {
1677+
const instanceId = "RB-TERMINATE";
1678+
const engineStub = await runWorkflow(instanceId, async (_e, step) => {
1679+
await doWithRollback(
1680+
step,
1681+
"setup-resource",
1682+
async () => "resource-id",
1683+
rollbackOptions()
1684+
);
1685+
await step.sleep("wait-forever", "1 hour");
1686+
});
1687+
1688+
await readLogsAfter(engineStub, (logs) =>
1689+
logs.logs.some(
1690+
(log) =>
1691+
log.event === InstanceEvent.STEP_SUCCESS &&
1692+
log.target === "setup-resource-1"
1693+
)
1694+
);
1695+
1696+
try {
1697+
await runInDurableObject(engineStub, async (engine) => {
1698+
await engine.changeInstanceStatus("terminate", undefined, {
1699+
rollback: true,
1700+
});
1701+
});
1702+
} catch (error) {
1703+
if (
1704+
!(error instanceof Error) ||
1705+
!error.message.startsWith("Aborting engine:")
1706+
) {
1707+
throw error;
1708+
}
1709+
}
1710+
1711+
const logs = await readLogsAfter(
1712+
env.ENGINE.get(env.ENGINE.idFromName(instanceId)),
1713+
(logs) =>
1714+
logs.logs.some((log) => log.event === InstanceEvent.WORKFLOW_TERMINATED)
1715+
);
1716+
expect(targetsOf(logs, InstanceEvent.ROLLBACK_STEP_SUCCESS)).toEqual([
1717+
"setup-resource-1",
1718+
]);
1719+
expect(countOf(logs, InstanceEvent.ROLLBACK_COMPLETE)).toBe(1);
1720+
});
1721+
16761722
it("does not run rollback when workflow succeeds", async ({ expect }) => {
16771723
const stub = await runWorkflowAndAwait("RB-NOOP", async (_e, step) => {
16781724
await doWithRollback(step, "a", async () => "ok", rollbackOptions());

0 commit comments

Comments
 (0)