diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index 7d77dd3..157e036 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -48,7 +48,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { _pendingEvents: Record[]>; _newInput?: any; _saveEvents: boolean; - _customStatus?: any; + _customStatus?: string; _entityFeature: RuntimeOrchestrationEntityFeature; constructor(instanceId: string) { @@ -402,20 +402,35 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { /** * Sets a custom status value for the current orchestration instance. + * + * The value is serialized eagerly via JSON.stringify so that serialization + * errors surface inside the orchestrator execution (where they are caught + * by the executor's try-catch) rather than after execution completes. */ setCustomStatus(customStatus: any): void { - this._customStatus = customStatus; + if (customStatus === undefined || customStatus === null) { + this._customStatus = undefined; + return; + } + + try { + this._customStatus = JSON.stringify(customStatus); + } catch (e) { + throw new Error( + `Custom status value is not JSON-serializable: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, + ); + } } /** * Gets the encoded custom status value for the current orchestration instance. * This is used internally when building the orchestrator response. + * + * Returns the pre-serialized JSON string set by setCustomStatus(). */ getCustomStatus(): string | undefined { - if (this._customStatus === undefined || this._customStatus === null) { - return undefined; - } - return JSON.stringify(this._customStatus); + return this._customStatus; } /** diff --git a/packages/durabletask-js/test/orchestration_context_methods.spec.ts b/packages/durabletask-js/test/orchestration_context_methods.spec.ts index 9eea1f5..e878e38 100644 --- a/packages/durabletask-js/test/orchestration_context_methods.spec.ts +++ b/packages/durabletask-js/test/orchestration_context_methods.spec.ts @@ -151,6 +151,106 @@ describe("OrchestrationContext.setCustomStatus", () => { // The last set value should be returned expect(result.customStatus).toEqual('"step3"'); }); + + it("should fail the orchestration when custom status contains a circular reference", async () => { + const circular: Record = { key: "value" }; + circular.self = circular; + + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext) => { + ctx.setCustomStatus(circular); + return "done"; + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // The orchestration should fail gracefully instead of crashing the executor + const completeAction = result.actions.find((a) => a.getCompleteorchestration()); + expect(completeAction).toBeDefined(); + expect(completeAction?.getCompleteorchestration()?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + const failureDetails = completeAction?.getCompleteorchestration()?.getFailuredetails(); + expect(failureDetails?.getErrormessage()).toContain("not JSON-serializable"); + }); + + it("should fail the orchestration when custom status contains a BigInt", async () => { + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext) => { + ctx.setCustomStatus({ count: BigInt(42) }); + return "done"; + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // The orchestration should fail gracefully instead of crashing the executor + const completeAction = result.actions.find((a) => a.getCompleteorchestration()); + expect(completeAction).toBeDefined(); + expect(completeAction?.getCompleteorchestration()?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + const failureDetails = completeAction?.getCompleteorchestration()?.getFailuredetails(); + expect(failureDetails?.getErrormessage()).toContain("not JSON-serializable"); + }); + + it("should allow clearing custom status by setting it to null", async () => { + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext) => { + ctx.setCustomStatus("initial"); + ctx.setCustomStatus(null); + return "done"; + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + expect(result.customStatus).toBeUndefined(); + }); + + it("should preserve orchestration result when non-serializable status is set after a valid one", async () => { + const circular: Record = { key: "value" }; + circular.self = circular; + + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext) => { + ctx.setCustomStatus("good status"); + ctx.setCustomStatus(circular); // This should throw + return "done"; + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // The orchestration should fail (not crash) — the serialization error + // is thrown inside orchestrator code and caught by the executor + const completeAction = result.actions.find((a) => a.getCompleteorchestration()); + expect(completeAction).toBeDefined(); + expect(completeAction?.getCompleteorchestration()?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + }); }); describe("OrchestrationContext.sendEvent", () => {