Skip to content

Commit 2fedb1f

Browse files
authored
Support workflow terminate rollback (#14465)
1 parent c782e2a commit 2fedb1f

16 files changed

Lines changed: 816 additions & 97 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+
"The option to trigger rollbacks when terminating the workflow instance.",
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+
* The option to trigger rollbacks when terminating the workflow instance.
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": "The option to trigger rollbacks when terminating the workflow instance."
18131817
}
18141818
}
18151819
}

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

Lines changed: 17 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,10 @@ export async function changeWorkflowInstanceStatus(
909920
break;
910921
}
911922
case "terminate":
912-
await handle.terminate();
923+
// TODO(vaish): remove cast once @cloudflare/workers-types ships terminate options
924+
await (handle as unknown as WorkflowHandle).terminate(
925+
body.rollback === true ? { rollback: true } : undefined
926+
);
913927
break;
914928
}
915929

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

Lines changed: 10 additions & 2 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
import type { WorkflowIntrospectionOperation } from "@cloudflare/workflows-shared/src/types";
67

@@ -104,9 +105,16 @@ class InstanceImpl implements WorkflowInstance {
104105
await instance.resume();
105106
}
106107

107-
public async terminate(): Promise<void> {
108+
public async terminate(
109+
options?: WorkflowInstanceTerminateOptions
110+
): Promise<void> {
108111
using instance = await this.getInstance();
109-
await instance.terminate();
112+
// TODO(vaish): remove cast once @cloudflare/workers-types ships terminate options
113+
await (
114+
instance.terminate as (
115+
options?: WorkflowInstanceTerminateOptions
116+
) => Promise<void>
117+
)(options);
110118
}
111119

112120
public async restart(

packages/workflows-shared/src/binding.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ export interface RestartFromStep {
124124
type?: "do" | "sleep" | "waitForEvent";
125125
}
126126

127+
export interface WorkflowInstanceTerminateOptions {
128+
/**
129+
* If true, run registered rollback handlers before terminating the instance.
130+
*/
131+
rollback?: boolean;
132+
}
133+
127134
export interface WorkflowInstanceRestartOptions {
128135
from?: RestartFromStep;
129136
}
@@ -357,9 +364,11 @@ export class WorkflowHandle extends RpcTarget implements WorkflowInstance {
357364
await this.stub.changeInstanceStatus("resume");
358365
}
359366

360-
public async terminate(): Promise<void> {
367+
public async terminate(
368+
options?: WorkflowInstanceTerminateOptions
369+
): Promise<void> {
361370
try {
362-
await this.stub.changeInstanceStatus("terminate");
371+
await this.stub.changeInstanceStatus("terminate", undefined, options);
363372
} catch (e) {
364373
// terminate causes instance abortion
365374
if (!isUserTriggeredTerminate(e)) {

packages/workflows-shared/src/context.ts

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
import { calcRetryDuration } from "./lib/retries";
1818
import {
1919
parseRollbackOptions,
20-
registerRollbackFn,
2120
ROLLBACK_CACHE_KEY_PREFIX,
2221
} from "./lib/rollback";
2322
import {
@@ -247,7 +246,7 @@ export class Context extends RpcTarget {
247246
const { cacheKey, rollbackFn, stepContext, output, rollbackConfig } =
248247
options;
249248
if (rollbackFn && this.#rollbackStep === undefined) {
250-
registerRollbackFn(this.#engine.rollbackRegistry, {
249+
this.#engine.registerRollbackFn({
251250
cacheKey,
252251
fn: rollbackFn,
253252
stepContext,
@@ -297,6 +296,12 @@ export class Context extends RpcTarget {
297296
const { rollback: rollbackFn, rollbackConfig } = rollbackOptions ?? {};
298297

299298
const isRollback = this.#rollbackStep !== undefined;
299+
if (this.#engine.rollbackPhase === "rollback" && !isRollback) {
300+
throw new WorkflowFatalError(
301+
"Cannot execute steps during rollback phase"
302+
);
303+
}
304+
300305
const events = isRollback
301306
? {
302307
start: InstanceEvent.ROLLBACK_STEP_START,
@@ -454,6 +459,17 @@ export class Context extends RpcTarget {
454459
) as Error | undefined;
455460

456461
if (maybeError) {
462+
const cachedState = maybeMap.get(stepStateKey) as StepState | undefined;
463+
this.#registerRollback({
464+
cacheKey,
465+
rollbackFn,
466+
stepContext: {
467+
step: { name, count },
468+
attempt: cachedState?.attemptedCount ?? 1,
469+
config: cachedConfig ?? config,
470+
},
471+
rollbackConfig,
472+
});
457473
maybeError.isUserError = true;
458474
throw maybeError;
459475
}
@@ -465,6 +481,21 @@ export class Context extends RpcTarget {
465481
config = cachedConfig;
466482
}
467483

484+
if (this.#engine.rollbackPhase === "replay" && !isRollback) {
485+
const cachedState = maybeMap.get(stepStateKey) as StepState | undefined;
486+
this.#registerRollback({
487+
cacheKey,
488+
rollbackFn,
489+
stepContext: {
490+
step: { name, count },
491+
attempt: cachedState?.attemptedCount ?? 1,
492+
config,
493+
},
494+
rollbackConfig,
495+
});
496+
return undefined;
497+
}
498+
468499
const attemptLogs = this.#engine
469500
.readLogsFromStep(cacheKey)
470501
.filter((val) =>
@@ -538,6 +569,7 @@ export class Context extends RpcTarget {
538569
if (stepState.attemptedCount == 0) {
539570
this.#engine.writeLog(events.start, cacheKey, stepNameWithCounter, {
540571
config,
572+
...(!isRollback && rollbackFn ? { hasRollback: true } : {}),
541573
});
542574
} else {
543575
// in case the engine dies while retrying and wakes up before the retry period
@@ -623,6 +655,12 @@ export class Context extends RpcTarget {
623655
}
624656
);
625657
stepState.attemptedCount++;
658+
this.#registerRollback({
659+
cacheKey,
660+
rollbackFn,
661+
stepContext: forwardStepContext(),
662+
rollbackConfig,
663+
});
626664
await this.#state.storage.put(stepStateKey, stepState);
627665
const priorityQueueHash = `${cacheKey}-${stepState.attemptedCount}`;
628666

@@ -937,7 +975,7 @@ export class Context extends RpcTarget {
937975
events.failure,
938976
cacheKey,
939977
stepNameWithCounter,
940-
{}
978+
!isRollback && rollbackFn ? { hasRollback: true } : {}
941979
);
942980
this.#registerRollback({
943981
cacheKey,
@@ -1039,7 +1077,7 @@ export class Context extends RpcTarget {
10391077
events.failure,
10401078
cacheKey,
10411079
stepNameWithCounter,
1042-
{}
1080+
!isRollback && rollbackFn ? { hasRollback: true } : {}
10431081
);
10441082
this.#registerRollback({
10451083
cacheKey,
@@ -1059,6 +1097,7 @@ export class Context extends RpcTarget {
10591097
...(lastStreamMeta && {
10601098
streamOutput: { cacheKey, meta: lastStreamMeta },
10611099
}),
1100+
...(!isRollback && rollbackFn ? { hasRollback: true } : {}),
10621101
});
10631102
this.#registerRollback({
10641103
cacheKey,
@@ -1073,13 +1112,21 @@ export class Context extends RpcTarget {
10731112

10741113
const result = await doWrapper(closure);
10751114

1076-
// Check if a pause was requested while this step was running
1077-
await this.#checkForPendingPause();
1115+
// Check if a pause was requested while this step was running.
1116+
// Rollback steps may run while the instance is paused; don't let stale pause
1117+
// state abort rollback cleanup after the rollback step itself succeeds.
1118+
if (!isRollback) {
1119+
await this.#checkForPendingPause();
1120+
}
10781121

10791122
return result;
10801123
}
10811124

10821125
async sleep(name: string, duration: WorkflowSleepDuration): Promise<void> {
1126+
if (this.#engine.rollbackPhase === "replay") {
1127+
return;
1128+
}
1129+
10831130
if (typeof duration == "string") {
10841131
duration = ms(duration);
10851132
}
@@ -1201,6 +1248,10 @@ export class Context extends RpcTarget {
12011248
}
12021249

12031250
async sleepUntil(name: string, timestamp: Date | number): Promise<void> {
1251+
if (this.#engine.rollbackPhase === "replay") {
1252+
return;
1253+
}
1254+
12041255
if (timestamp instanceof Date) {
12051256
timestamp = timestamp.valueOf();
12061257
}
@@ -1223,6 +1274,12 @@ export class Context extends RpcTarget {
12231274
timeout?: string | number;
12241275
}
12251276
): Promise<WorkflowStepEvent<T>> {
1277+
if (this.#engine.rollbackPhase === "rollback") {
1278+
throw new WorkflowFatalError(
1279+
"Cannot execute steps during rollback phase"
1280+
);
1281+
}
1282+
12261283
if (!options.timeout) {
12271284
options.timeout = "24 hours";
12281285
}
@@ -1242,6 +1299,10 @@ export class Context extends RpcTarget {
12421299

12431300
const maybeResult = await this.#state.storage.get<Event>(waitForEventKey);
12441301

1302+
if (this.#engine.rollbackPhase === "replay") {
1303+
return maybeResult as WorkflowStepEvent<T>;
1304+
}
1305+
12451306
if (maybeResult) {
12461307
const shouldWriteLog =
12471308
(await this.#state.storage.get(waitForEventKey)) == undefined;

0 commit comments

Comments
 (0)