From 92623d4e778e1cc0537139eaa6452235c1ed9035 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:56:23 +0000 Subject: [PATCH] fix: use toLowerCase() instead of toLocaleLowerCase() for event name matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitForExternalEvent() used toLocaleLowerCase() to normalize event names, while handleEventRaised() in the orchestration executor used toLowerCase(). In locales where these produce different results (e.g., Turkish 'I' -> dotless 'ı' vs 'i'), event names registered by waitForExternalEvent would not match events delivered by the executor, causing orchestrations to hang indefinitely. This also violates the determinism invariant: the same orchestration code running on machines with different locale settings would produce different event name keys, potentially breaking replay. The fix changes toLocaleLowerCase() to toLowerCase() in waitForExternalEvent() to be consistent with the executor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../worker/runtime-orchestration-context.ts | 2 +- .../test/orchestration_executor.spec.ts | 82 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index 7d77dd3..b90b07b 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -364,7 +364,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { // arrives. If there are multiple events with the same name, we return // them in the order they were received. const externalEventTask = new CompletableTask(); - const eventName = name.toLocaleLowerCase(); + const eventName = name.toLowerCase(); const eventList = this._receivedEvents[eventName]; if (eventList?.length) { diff --git a/packages/durabletask-js/test/orchestration_executor.spec.ts b/packages/durabletask-js/test/orchestration_executor.spec.ts index 2783750..05afe0f 100644 --- a/packages/durabletask-js/test/orchestration_executor.spec.ts +++ b/packages/durabletask-js/test/orchestration_executor.spec.ts @@ -695,6 +695,88 @@ describe("Orchestration Executor", () => { expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify("terminated!")); }); + it("should match external event names case-insensitively across waitForExternalEvent and handleEventRaised", async () => { + // Regression test: waitForExternalEvent must use toLowerCase() (not toLocaleLowerCase()) + // to stay consistent with handleEventRaised, which uses toLowerCase(). + // Using toLocaleLowerCase() causes mismatches in locales where the two differ (e.g., Turkish). + const testCases = [ + { waitName: "MY_EVENT", raiseName: "my_event" }, + { waitName: "my_event", raiseName: "MY_EVENT" }, + { waitName: "My_Event", raiseName: "MY_EVENT" }, + { waitName: "ITEM_UPDATED", raiseName: "item_updated" }, + ]; + + for (const { waitName, raiseName } of testCases) { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, _: any): any { + const res = yield ctx.waitForExternalEvent(waitName); + return res; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + + let oldEvents: any[] = []; + let newEvents = [newOrchestratorStartedEvent(), newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID)]; + + // First execution: orchestration waits for the event + let executor = new OrchestrationExecutor(registry, testLogger); + let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + expect(result.actions.length).toBe(0); + + // Second execution: send event with different casing — should still match + oldEvents = newEvents; + newEvents = [newEventRaisedEvent(raiseName, '"data"')]; + executor = new OrchestrationExecutor(registry, testLogger); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED, + ); + expect(completeAction?.getResult()?.getValue()).toEqual('"data"'); + } + }); + + it("should match buffered external events case-insensitively when event arrives before waitForExternalEvent", async () => { + // When an event is buffered (arrives before waitForExternalEvent), the executor stores it + // using toLowerCase(). waitForExternalEvent must also use toLowerCase() to find it. + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, _: any): any { + yield ctx.createTimer(new Date(ctx.currentUtcDateTime.getTime() + 24 * 60 * 60 * 1000)); + const res = yield ctx.waitForExternalEvent("MY_EVENT"); + return res; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + + let oldEvents: any[] = []; + let newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID), + newEventRaisedEvent("my_event", '"buffered_value"'), + ]; + + // First execution: event arrives early and is buffered; orchestration waits for timer + let executor = new OrchestrationExecutor(registry, testLogger); + let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + expect(result.actions.length).toBe(1); + expect(result.actions[0].hasCreatetimer()).toBeTruthy(); + + // Complete the timer — orchestration should find the buffered event despite different casing + const timerDueTime = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000); + newEvents.push(newTimerCreatedEvent(1, timerDueTime)); + oldEvents = newEvents; + newEvents = [newTimerFiredEvent(1, timerDueTime)]; + executor = new OrchestrationExecutor(registry, testLogger); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED, + ); + expect(completeAction?.getResult()?.getValue()).toEqual('"buffered_value"'); + }); + it("should be able to continue-as-new", async () => { for (const saveEvent of [true, false]) { const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, input: number): any {