diff --git a/packages/durabletask-js/src/worker/activity-executor.ts b/packages/durabletask-js/src/worker/activity-executor.ts index 079b9a0..10ba9a9 100644 --- a/packages/durabletask-js/src/worker/activity-executor.ts +++ b/packages/durabletask-js/src/worker/activity-executor.ts @@ -44,7 +44,7 @@ export class ActivityExecutor { } // Return the output - const encodedOutput = activityOutput ? JSON.stringify(activityOutput) : undefined; + const encodedOutput = activityOutput !== undefined ? JSON.stringify(activityOutput) : undefined; // Log activity completion (EventId 604) WorkerLogs.activityCompleted(this._logger, orchestrationId, name); diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index c01e19d..57aedd1 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -206,7 +206,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { let resultJson; - if (result) { + if (result !== undefined) { resultJson = isResultEncoded ? result : JSON.stringify(result); } @@ -252,7 +252,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { // replayed when the new instance starts for (const [eventName, values] of Object.entries(this._receivedEvents)) { for (const eventValue of values) { - const encodedValue = eventValue ? JSON.stringify(eventValue) : undefined; + const encodedValue = eventValue !== undefined ? JSON.stringify(eventValue) : undefined; carryoverEvents.push(ph.newEventRaisedEvent(eventName, encodedValue)); } } @@ -261,7 +261,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { const action = ph.newCompleteOrchestrationAction( this.nextSequenceNumber(), pb.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW, - this._newInput ? JSON.stringify(this._newInput) : undefined, + this._newInput !== undefined ? JSON.stringify(this._newInput) : undefined, undefined, carryoverEvents, ); @@ -308,7 +308,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { ): Task { const id = this.nextSequenceNumber(); const name = typeof activity === "string" ? activity : getName(activity); - const encodedInput = input ? JSON.stringify(input) : undefined; + const encodedInput = input !== undefined ? JSON.stringify(input) : undefined; const action = ph.newScheduleTaskAction(id, name, encodedInput, options?.tags, options?.version); this._pendingActions[action.getId()] = action; @@ -339,7 +339,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { instanceId = `${this._instanceId}:${instanceIdSuffix}`; } - const encodedInput = input ? JSON.stringify(input) : undefined; + const encodedInput = input !== undefined ? JSON.stringify(input) : undefined; const action = ph.newCreateSubOrchestrationAction(id, name, instanceId, encodedInput, options?.tags, options?.version); this._pendingActions[action.getId()] = action; diff --git a/packages/durabletask-js/test/falsy-input-serialization.spec.ts b/packages/durabletask-js/test/falsy-input-serialization.spec.ts new file mode 100644 index 0000000..9fea017 --- /dev/null +++ b/packages/durabletask-js/test/falsy-input-serialization.spec.ts @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { OrchestrationContext } from "../src/task/context/orchestration-context"; +import { + newExecutionStartedEvent, + newOrchestratorStartedEvent, +} from "../src/utils/pb-helper.util"; +import { OrchestrationExecutor } from "../src/worker/orchestration-executor"; +import * as pb from "../src/proto/orchestrator_service_pb"; +import { Registry } from "../src/worker/registry"; +import { TOrchestrator } from "../src/types/orchestrator.type"; +import { NoOpLogger } from "../src/types/logger.type"; +import { ActivityContext } from "../src/task/context/activity-context"; +import { ActivityExecutor } from "../src/worker/activity-executor"; + +const testLogger = new NoOpLogger(); +const TEST_INSTANCE_ID = "falsy-test-instance"; + +describe("Falsy input serialization", () => { + describe("callActivity with falsy inputs", () => { + it.each([ + { input: 0, label: "zero" }, + { input: "", label: "empty string" }, + { input: false, label: "false" }, + { input: null, label: "null" }, + ])("should correctly serialize $label as activity input", async ({ input }) => { + const myActivity = async (_ctx: ActivityContext, actInput: any) => actInput; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const result = yield ctx.callActivity(myActivity, input as any); + return result; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + registry.addActivity(myActivity); + + const newEvents = [ + newOrchestratorStartedEvent(new Date()), + newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID), + ]; + + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // Should have a ScheduleTask action with the serialized input + const scheduleAction = result.actions.find((a) => a.hasScheduletask()); + expect(scheduleAction).toBeDefined(); + const inputValue = scheduleAction!.getScheduletask()!.getInput(); + expect(inputValue).toBeDefined(); + expect(inputValue!.getValue()).toEqual(JSON.stringify(input)); + }); + }); + + describe("callSubOrchestrator with falsy inputs", () => { + it.each([ + { input: 0, label: "zero" }, + { input: "", label: "empty string" }, + { input: false, label: "false" }, + { input: null, label: "null" }, + ])("should correctly serialize $label as sub-orchestration input", async ({ input }) => { + const subOrchestrator: TOrchestrator = async (_ctx: OrchestrationContext, subInput: any) => { + return subInput; + }; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const result = yield ctx.callSubOrchestrator(subOrchestrator, input as any); + return result; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + registry.addOrchestrator(subOrchestrator); + + const newEvents = [ + newOrchestratorStartedEvent(new Date()), + newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID), + ]; + + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // Should have a CreateSubOrchestration action with the serialized input + const subOrchAction = result.actions.find((a) => a.hasCreatesuborchestration()); + expect(subOrchAction).toBeDefined(); + const inputValue = subOrchAction!.getCreatesuborchestration()!.getInput(); + expect(inputValue).toBeDefined(); + expect(inputValue!.getValue()).toEqual(JSON.stringify(input)); + }); + }); + + describe("orchestration completion with falsy results", () => { + it.each([ + { result: 0, label: "zero" }, + { result: "", label: "empty string" }, + { result: false, label: "false" }, + { result: null, label: "null" }, + ])("should correctly serialize $label as orchestration result", async ({ result }) => { + const orchestrator: TOrchestrator = async (_ctx: OrchestrationContext) => { + return result; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + + const newEvents = [ + newOrchestratorStartedEvent(new Date()), + newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID), + ]; + + const executor = new OrchestrationExecutor(registry, testLogger); + const execResult = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + expect(execResult.actions.length).toEqual(1); + const completeAction = execResult.actions[0].getCompleteorchestration(); + expect(completeAction).toBeDefined(); + expect(completeAction!.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED, + ); + const resultValue = completeAction!.getResult(); + expect(resultValue).toBeDefined(); + expect(resultValue!.getValue()).toEqual(JSON.stringify(result)); + }); + }); + + describe("continueAsNew with falsy input", () => { + it("should correctly serialize zero as continue-as-new input", async () => { + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext, input: any): Promise => { + if (input === 0) { + ctx.continueAsNew(0, false); + return; + } + return input; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + + const newEvents = [ + newOrchestratorStartedEvent(new Date()), + newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID, JSON.stringify(0)), + ]; + + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + expect(result.actions.length).toEqual(1); + const completeAction = result.actions[0].getCompleteorchestration(); + expect(completeAction).toBeDefined(); + expect(completeAction!.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW, + ); + const resultValue = completeAction!.getResult(); + expect(resultValue).toBeDefined(); + expect(resultValue!.getValue()).toEqual(JSON.stringify(0)); + }); + }); + + describe("undefined inputs are still treated as no input", () => { + it("should not set input when activity input is undefined", async () => { + const myActivity = async (_ctx: ActivityContext) => "done"; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const result = yield ctx.callActivity(myActivity); + return result; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + registry.addActivity(myActivity); + + const newEvents = [ + newOrchestratorStartedEvent(new Date()), + newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID), + ]; + + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + const scheduleAction = result.actions.find((a) => a.hasScheduletask()); + expect(scheduleAction).toBeDefined(); + // Input should be undefined when not provided + const inputValue = scheduleAction!.getScheduletask()!.getInput(); + expect(inputValue).toBeUndefined(); + }); + }); + + describe("activity output with falsy values", () => { + it.each([ + { output: 0, label: "zero" }, + { output: "", label: "empty string" }, + { output: false, label: "false" }, + { output: null, label: "null" }, + ])("should correctly serialize $label as activity output", async ({ output }) => { + const myActivity = async (_ctx: ActivityContext) => output; + + const registry = new Registry(); + registry.addActivity(myActivity); + + const executor = new ActivityExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, "myActivity", 1); + + expect(result).toEqual(JSON.stringify(output)); + }); + + it("should return undefined when activity output is undefined", async () => { + const myActivity = async (_ctx: ActivityContext) => undefined; + + const registry = new Registry(); + registry.addActivity(myActivity); + + const executor = new ActivityExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, "myActivity", 1); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/test/e2e-azuremanaged/orchestration.spec.ts b/test/e2e-azuremanaged/orchestration.spec.ts index 40d7cc3..a11428a 100644 --- a/test/e2e-azuremanaged/orchestration.spec.ts +++ b/test/e2e-azuremanaged/orchestration.spec.ts @@ -1373,4 +1373,180 @@ describe("Durable Task Scheduler (DTS) E2E Tests", () => { expect(parentStateAfterPurge).toBeUndefined(); }, 60000); }); + + // PR #138: Fix falsy values (0, "", false, null) silently dropped during serialization + describe("falsy value serialization", () => { + it("should pass zero (0) through activity round-trip", async () => { + const echo = async (_: ActivityContext, input: number) => input; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, input: number): any { + const result = yield ctx.callActivity(echo, input); + return result; + }; + + taskHubWorker.addOrchestrator(orchestrator); + taskHubWorker.addActivity(echo); + await taskHubWorker.start(); + + const id = await taskHubClient.scheduleNewOrchestration(orchestrator, 0); + const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(state?.serializedInput).toEqual(JSON.stringify(0)); + expect(state?.serializedOutput).toEqual(JSON.stringify(0)); + }, 31000); + + it("should pass empty string through activity round-trip", async () => { + const echo = async (_: ActivityContext, input: string) => input; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, input: string): any { + const result = yield ctx.callActivity(echo, input); + return result; + }; + + taskHubWorker.addOrchestrator(orchestrator); + taskHubWorker.addActivity(echo); + await taskHubWorker.start(); + + const id = await taskHubClient.scheduleNewOrchestration(orchestrator, ""); + const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(state?.serializedInput).toEqual(JSON.stringify("")); + expect(state?.serializedOutput).toEqual(JSON.stringify("")); + }, 31000); + + it("should pass false through activity round-trip", async () => { + const echo = async (_: ActivityContext, input: boolean) => input; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, input: boolean): any { + const result = yield ctx.callActivity(echo, input); + return result; + }; + + taskHubWorker.addOrchestrator(orchestrator); + taskHubWorker.addActivity(echo); + await taskHubWorker.start(); + + const id = await taskHubClient.scheduleNewOrchestration(orchestrator, false); + const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(state?.serializedInput).toEqual(JSON.stringify(false)); + expect(state?.serializedOutput).toEqual(JSON.stringify(false)); + }, 31000); + + it("should pass null through activity round-trip", async () => { + const echo = async (_: ActivityContext, input: any) => input; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, input: any): any { + const result = yield ctx.callActivity(echo, input); + return result; + }; + + taskHubWorker.addOrchestrator(orchestrator); + taskHubWorker.addActivity(echo); + await taskHubWorker.start(); + + const id = await taskHubClient.scheduleNewOrchestration(orchestrator, null); + const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify(null)); + }, 31000); + + it("should pass zero through sub-orchestration round-trip", async () => { + const child: TOrchestrator = async (_ctx: OrchestrationContext, input: number) => { + return input; + }; + + const parent: TOrchestrator = async function* (ctx: OrchestrationContext, input: number): any { + const result = yield ctx.callSubOrchestrator(child, input); + return result; + }; + + taskHubWorker.addOrchestrator(parent); + taskHubWorker.addOrchestrator(child); + await taskHubWorker.start(); + + const id = await taskHubClient.scheduleNewOrchestration(parent, 0); + const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify(0)); + }, 31000); + + it("should return zero as orchestration result", async () => { + const orchestrator: TOrchestrator = async (_ctx: OrchestrationContext) => { + return 0; + }; + + taskHubWorker.addOrchestrator(orchestrator); + await taskHubWorker.start(); + + const id = await taskHubClient.scheduleNewOrchestration(orchestrator); + const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify(0)); + }, 31000); + + it("should return false as orchestration result", async () => { + const orchestrator: TOrchestrator = async (_ctx: OrchestrationContext) => { + return false; + }; + + taskHubWorker.addOrchestrator(orchestrator); + await taskHubWorker.start(); + + const id = await taskHubClient.scheduleNewOrchestration(orchestrator); + const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify(false)); + }, 31000); + + it("should return empty string as orchestration result", async () => { + const orchestrator: TOrchestrator = async (_ctx: OrchestrationContext) => { + return ""; + }; + + taskHubWorker.addOrchestrator(orchestrator); + await taskHubWorker.start(); + + const id = await taskHubClient.scheduleNewOrchestration(orchestrator); + const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify("")); + }, 31000); + + it("should continue-as-new with zero input", async () => { + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext, input: number) => { + if (input === 0) { + ctx.continueAsNew(1, false); + return; + } + return input; + }; + + taskHubWorker.addOrchestrator(orchestrator); + await taskHubWorker.start(); + + const id = await taskHubClient.scheduleNewOrchestration(orchestrator, 0); + const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify(1)); + }, 31000); + }); });