Skip to content

Commit 4ccbf75

Browse files
committed
Support workflow terminate rollback
1 parent 1cea129 commit 4ccbf75

13 files changed

Lines changed: 209 additions & 31 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 for remote instances via `wrangler workflows instances terminate --rollback`.
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
@@ -1146,6 +1146,11 @@ const config = {
11461146
description:
11471147
"The action to perform on the workflow instance.",
11481148
},
1149+
rollback: {
1150+
type: "boolean",
1151+
description:
1152+
"Run rollback before terminating. Only valid when action is terminate.",
1153+
},
11491154
from: {
11501155
type: "object",
11511156
description:

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1730,6 +1730,10 @@ export type WorkflowsChangeInstanceStatusData = {
17301730
* The action to perform on the workflow instance.
17311731
*/
17321732
action: "pause" | "resume" | "restart" | "terminate";
1733+
/**
1734+
* Run rollback before terminating. Only valid when action is terminate.
1735+
*/
1736+
rollback?: boolean;
17331737
/**
17341738
* The step to restart the instance from. Only valid when action is restart.
17351739
*/

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,7 @@ export const zWorkflowsGetInstanceDetailsResponse =
10831083
export const zWorkflowsChangeInstanceStatusData = z.object({
10841084
body: z.object({
10851085
action: z.enum(["pause", "resume", "restart", "terminate"]),
1086+
rollback: z.boolean().optional(),
10861087
from: z
10871088
.object({
10881089
name: z.string(),

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,6 +1790,10 @@
17901790
"enum": ["pause", "resume", "restart", "terminate"],
17911791
"description": "The action to perform on the workflow instance."
17921792
},
1793+
"rollback": {
1794+
"type": "boolean",
1795+
"description": "Run rollback before terminating. Only valid when action is terminate."
1796+
},
17931797
"from": {
17941798
"type": "object",
17951799
"description": "The step to restart the instance from. Only valid when action is restart.",

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

Lines changed: 29 additions & 10 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,11 +38,17 @@ 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
}
4245

46+
/** Local Workflow proxy binding. Current workers-types may lag local features. */
47+
interface LocalWorkflowBinding {
48+
create(options?: { id?: string; params?: unknown }): Promise<{ id: string }>;
49+
get(id: string): Promise<WorkflowHandle>;
50+
}
51+
4352
/** RPC methods exposed by the Engine Durable Object. */
4453
interface EngineStub {
4554
getInstanceMetadata(): Promise<{
@@ -173,12 +182,15 @@ function getEngineNamespace(
173182
* Get the Workflow proxy binding for a workflow.
174183
* Used for operations that go through the standard Workflow interface (create).
175184
*/
176-
function getWorkflowBinding(env: Env, workflowName: string): Workflow | null {
185+
function getWorkflowBinding(
186+
env: Env,
187+
workflowName: string
188+
): LocalWorkflowBinding | null {
177189
const info = env.LOCAL_EXPLORER_BINDING_MAP.workflows[workflowName];
178190
if (!info) {
179191
return null;
180192
}
181-
return env[info.binding] as Workflow;
193+
return env[info.binding] as unknown as LocalWorkflowBinding;
182194
}
183195

184196
function getLocalWorkflows(env: Env): WorkflowsWorkflow[] {
@@ -886,6 +898,14 @@ export async function changeWorkflowInstanceStatus(
886898
);
887899
}
888900

901+
if (body.rollback !== undefined && action !== "terminate") {
902+
return errorResponse(
903+
400,
904+
10001,
905+
"'rollback' is only valid when terminating."
906+
);
907+
}
908+
889909
const handle = await workflow.get(instanceId);
890910

891911
switch (action) {
@@ -904,12 +924,13 @@ export async function changeWorkflowInstanceStatus(
904924
);
905925
}
906926
const opts = body.from ? { from: body.from } : undefined;
907-
// TODO(vaish): remove cast once @cloudflare/workers-types ships restart options
908-
await (handle as unknown as WorkflowHandle).restart(opts);
927+
await handle.restart(opts);
909928
break;
910929
}
911930
case "terminate":
912-
await handle.terminate();
931+
await handle.terminate(
932+
body.rollback === true ? { rollback: true } : undefined
933+
);
913934
break;
914935
}
915936

@@ -1044,9 +1065,7 @@ export async function sendWorkflowInstanceEvent(
10441065
// Empty body is fine — payload is optional
10451066
}
10461067

1047-
const handle = (await workflow.get(
1048-
instanceId
1049-
)) as unknown as WorkflowHandle;
1068+
const handle = await workflow.get(instanceId);
10501069
await handle.sendEvent({ payload, type: eventType });
10511070

10521071
return c.json(wrapResponse({ success: true }));

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type {
22
WorkflowBinding,
3+
WorkflowHandle,
34
WorkflowInstanceRestartOptions,
5+
WorkflowInstanceTerminateOptions,
46
} from "@cloudflare/workflows-shared/src/binding";
57

68
class WorkflowImpl implements Workflow {
@@ -70,8 +72,8 @@ class InstanceImpl implements WorkflowInstance {
7072
private binding: WorkflowBinding
7173
) {}
7274

73-
private async getInstance(): Promise<WorkflowInstance & Disposable> {
74-
return (await this.binding.get(this.id)) as WorkflowInstance & Disposable;
75+
private async getInstance(): Promise<WorkflowHandle & Disposable> {
76+
return (await this.binding.get(this.id)) as WorkflowHandle & Disposable;
7577
}
7678

7779
public async pause(): Promise<void> {
@@ -84,9 +86,11 @@ class InstanceImpl implements WorkflowInstance {
8486
await instance.resume();
8587
}
8688

87-
public async terminate(): Promise<void> {
89+
public async terminate(
90+
options?: WorkflowInstanceTerminateOptions
91+
): Promise<void> {
8892
using instance = await this.getInstance();
89-
await instance.terminate();
93+
await instance.terminate(options);
9094
}
9195

9296
public async restart(
@@ -98,7 +102,8 @@ class InstanceImpl implements WorkflowInstance {
98102

99103
public async status(): Promise<InstanceStatus> {
100104
using instance = await this.getInstance();
101-
using res = (await instance.status()) as InstanceStatus & Disposable;
105+
using res = (await instance.status()) as unknown as InstanceStatus &
106+
Disposable;
102107
return structuredClone(res);
103108
}
104109

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)