Add .rollback() api for workflow step promises#6330
Add .rollback() api for workflow step promises#6330vaishnav-mk wants to merge 11 commits intocloudflare:mainfrom
Conversation
src/cloudflare/internal/test/workflows/workflows-step-promise-test.js
Outdated
Show resolved
Hide resolved
src/cloudflare/internal/test/workflows/workflows-step-promise-test.js
Outdated
Show resolved
Hide resolved
|
/bigbonk review this pr? |
|
/bonk review this pr |
|
the overlord hates me |
|
/bigbonk review this pr |
There was a problem hiding this comment.
Review of StepPromise rollback API.
This review was generated by an AI assistant and may contain inaccuracies.
Summary of findings:
StepPromiseis not a realPromise— it's a thenable. The d.ts declaresStepPromise<T> extends Promise<T>which is misleading and will causeinstanceof Promiseto returnfalse.- The rollback function return type in
rpc.d.tssaysPromise<void>but the test returns a value from the rollback fn (and verifies it). One of these needs to match the other. - Constructor-time prototype patching runs on every instantiation, even though it only patches once. This works but adds overhead on every
newcall for thehasOwnProperty+typeof+ symbol check. - The
generated-snapshot/types were not regenerated — CI will likely fail the diff check. - Copyright year on new files should be 2026 (current year), not 2024.
| export interface StepPromise<T> extends Promise<T> { | ||
| rollback( | ||
| fn: (ctx: RollbackContext<T>) => Promise<void>, | ||
| ): Promise<T>; | ||
| rollback( | ||
| config: WorkflowStepConfig, | ||
| fn: (ctx: RollbackContext<T>) => Promise<void>, | ||
| ): Promise<T>; | ||
| } |
There was a problem hiding this comment.
StepPromise<T> extends Promise<T> is inaccurate. The runtime StepPromise class is a thenable (it has .then(), .catch(), .finally()) but does NOT extend Promise — it's a plain class. This means stepPromise instanceof Promise will be false at runtime, which contradicts this declaration.
Two options:
- Make the runtime
StepPromisea proper subclass ofPromise(complex, but accurate). - Change the d.ts to declare
StepPromise<T>as aPromiseLike<T>with the extra.rollback()method — this more accurately describes the runtime behavior.
Option 2 is simpler and more honest:
| export interface StepPromise<T> extends Promise<T> { | |
| rollback( | |
| fn: (ctx: RollbackContext<T>) => Promise<void>, | |
| ): Promise<T>; | |
| rollback( | |
| config: WorkflowStepConfig, | |
| fn: (ctx: RollbackContext<T>) => Promise<void>, | |
| ): Promise<T>; | |
| } | |
| export interface StepPromise<T> extends PromiseLike<T> { | |
| rollback( | |
| fn: (ctx: RollbackContext<T>) => Promise<void>, | |
| ): StepPromise<T>; | |
| rollback( | |
| config: WorkflowStepConfig, | |
| fn: (ctx: RollbackContext<T>) => Promise<void>, | |
| ): StepPromise<T>; | |
| } |
Note: I also changed the return type of .rollback() from Promise<T> to StepPromise<T> since the runtime returns this. Returning Promise<T> prevents TypeScript from seeing .rollback() on the return value, which is fine for the "only once" semantics but makes the chained .rollback().rollback() error test impossible to type-check.
types/defines/rpc.d.ts
Outdated
|
|
||
| export interface StepPromise<T> extends Promise<T> { | ||
| rollback( | ||
| fn: (ctx: RollbackContext<T>) => Promise<void>, |
There was a problem hiding this comment.
The rollback callback return type is Promise<void>, but the test RollbackCallableWorkflow returns a non-void value from its rollback fn:
.rollback(async ({ error, output, stepName }) => {
return { receivedError: error, receivedOutput: output, receivedStepName: stepName };
});And the mock verifies this returned value (mock.rollbackResult). Either the d.ts should allow a return value (Promise<unknown>) or the test is testing unsupported behavior.
Which is the intended contract? If the engine will use the return value, Promise<void> is wrong.
src/cloudflare/workers.ts
Outdated
| }; | ||
| } | ||
|
|
||
| class WorkflowEntrypointWrapper extends BaseWorkflowEntrypoint { |
There was a problem hiding this comment.
Prototype patching in the constructor runs the hasOwnProperty + typeof + symbol check on every new UserWorkflow() instantiation, even though it only patches once (the first time). This is fine for correctness (the kStepWrapped guard prevents double-patching), but it's worth noting that in a hot path with many instantiations, this is unnecessary repeated work.
An alternative would be to patch once per class (e.g., via a WeakSet of already-patched prototypes, or patching at class definition time instead of construction time). Not blocking, but worth considering.
| export const ServiceStub = entrypoints.ServiceStub; | ||
| export const WorkflowEntrypoint = entrypoints.WorkflowEntrypoint; | ||
|
|
||
| type RollbackFn = ((...args: unknown[]) => Promise<void>) | null; |
There was a problem hiding this comment.
The RollbackFn type says it returns Promise<void>, matching the d.ts. But as noted in the rpc.d.ts comment, the test actually returns a value from the rollback fn. This type should be consistent with the actual contract.
If the engine can use the return value:
| type RollbackFn = ((...args: unknown[]) => Promise<void>) | null; | |
| type RollbackFn = ((...args: unknown[]) => Promise<unknown>) | null; |
src/cloudflare/workers.ts
Outdated
| // to classify entrypoints (workflowClasses vs actorClasses vs statelessClasses). A Proxy breaks | ||
| // identity comparison: `Proxy !== target`. A JS subclass preserves the chain: | ||
| // UserWorkflow -> our JS class -> C++ WorkflowEntrypoint (matched by runtime) | ||
| const BaseWorkflowEntrypoint = |
There was a problem hiding this comment.
The cast as unknown as new (...args: unknown[]) => Record<string, unknown> discards all type information from the C++ entrypoint. This is understandable given the boundary between the internal module and TypeScript, but worth a comment explaining that this is intentional and that the type is deliberately widened because we only need the constructor chain for runtime classification.
Also: if the C++ WorkflowEntrypoint constructor signature ever changes (e.g., adds required parameters), this cast will silently hide the mismatch. Consider adding a brief note about this risk.
| @@ -0,0 +1,100 @@ | |||
| // Copyright (c) 2024 Cloudflare, Inc. | |||
There was a problem hiding this comment.
Copyright year should be 2026, not 2024.
| modules = [ | ||
| (name = "worker", esModule = embed "workflows-step-promise-test.js"), | ||
| ], | ||
| compatibilityFlags = ["nodejs_compat", "experimental"], |
There was a problem hiding this comment.
Both the test worker and the workflows worker use "experimental" compat flag. Is there an intent to gate the rollback feature behind a specific compat flag? If this is a new API surface, it probably should be gated rather than always available. If it's intentionally not gated (because the wrapper is purely additive and doesn't change existing behavior), a comment explaining this decision would be helpful.
src/cloudflare/workers.ts
Outdated
| #promise: Promise<unknown> | null = null; | ||
| #launched = false; | ||
|
|
||
| get [Symbol.toStringTag](): string { |
There was a problem hiding this comment.
Symbol.toStringTag returns 'StepPromise' which means Object.prototype.toString.call(sp) gives [object StepPromise]. This is a nice touch, but it further highlights that this is NOT a real Promise — Object.prototype.toString.call(new Promise(() => {})) gives [object Promise]. Users who check the string tag to distinguish promises from other objects will get unexpected results. This is probably fine for the workflow context, but worth documenting in the class comment.
src/cloudflare/workers.ts
Outdated
|
|
||
| // Wraps the step RPC stub so that step.do() and step.waitForEvent() return StepPromise instances. | ||
| // sleep() and sleepUntil() are passed through unchanged (they return Promise<void>, no rollback). | ||
| function wrapStep(jsStep: StepRpcStub): unknown { |
There was a problem hiding this comment.
The return type is unknown. Since this object is passed to user code as step in run(event, step), the user sees untyped methods. The type safety is provided by the d.ts declarations in rpc.d.ts, so this is fine at runtime, but it means the wrapper layer has zero internal type safety — any method name typo in this object literal (e.g., sleepuntil instead of sleepUntil) would be silently accepted by TypeScript. Consider typing the return value as matching the StepRpcStub interface (or a similar shape) to catch mistakes.
src/cloudflare/workers.ts
Outdated
| proto && | ||
| Object.prototype.hasOwnProperty.call(proto, 'run') && | ||
| typeof proto.run === 'function' && | ||
| !proto[kStepWrapped] |
There was a problem hiding this comment.
Minor: proto[kStepWrapped] is set to true (a boolean), but the check is !proto[kStepWrapped] which relies on truthiness. This is fine, but since the symbol is on a prototype shared by all instances, it's worth noting that this is visible to user code via Object.getOwnPropertySymbols(). Users could theoretically observe or tamper with it. Using a WeakSet<object> instead of a symbol property would hide this implementation detail from user introspection.
|
Review posted successfully. Here's a summary of the key findings: High priority:
Medium priority: Low priority: |
|
hey @anonrig, i made a couple of changes based on bonk's review that i missed. specifically, apart from that, i've gated behind |
adds steppromise wrapper with .rollback() api for workflow saga rollbacks. step.do() and step.waitforevent() now return steppromise which lets users register a compensation function before the step rpc fires.