From 070e37302585157ca0760e1a5d03f14e7377ed68 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:46:05 +0000 Subject: [PATCH] [copilot-finds] Bug: InMemoryOrchestrationBackend crashes on SENDENTITYMESSAGE and TERMINATEORCHESTRATION action types Add handling for SENDENTITYMESSAGE (case 8) and TERMINATEORCHESTRATION (case 7) action types in InMemoryOrchestrationBackend.processAction(). Previously, orchestrations using entity signals or terminate-orchestration actions would crash with 'Unexpected action type' when run against the in-memory testing backend. - SENDENTITYMESSAGE: No-op (entity signals are fire-and-forget; in-memory backend does not simulate entity workers) - TERMINATEORCHESTRATION: Extracts target instance ID and delegates to existing terminate() method - Adds 3 unit tests verifying entity signal orchestrations complete without crashing Fixes #209 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/testing/in-memory-backend.ts | 34 +++++++++ .../test/in-memory-backend.spec.ts | 71 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/packages/durabletask-js/src/testing/in-memory-backend.ts b/packages/durabletask-js/src/testing/in-memory-backend.ts index 2c96acb1..5ccd1768 100644 --- a/packages/durabletask-js/src/testing/in-memory-backend.ts +++ b/packages/durabletask-js/src/testing/in-memory-backend.ts @@ -455,6 +455,20 @@ export class InMemoryOrchestrationBackend { case pb.OrchestratorAction.OrchestratoractiontypeCase.SENDEVENT: this.processSendEventAction(action.getSendevent()!); break; + case pb.OrchestratorAction.OrchestratoractiontypeCase.SENDENTITYMESSAGE: + // Entity message actions (signal, call, lock, unlock) are produced by + // orchestrations that interact with entities. The in-memory backend does + // not currently include a full entity processing runtime, so these + // actions are acknowledged but not executed. Signal (fire-and-forget) + // operations silently succeed; call/lock operations will cause the + // orchestration to wait indefinitely for a response that never arrives, + // which matches the expected behavior when no entity worker is available. + break; + case pb.OrchestratorAction.OrchestratoractiontypeCase.TERMINATEORCHESTRATION: + // Terminate-orchestration actions are used for recursive termination of + // sub-orchestrations. Process by terminating the target instance. + this.processTerminateOrchestrationAction(action); + break; default: throw new Error( `Unknown orchestrator action type '${actionType}' for orchestration '${instance.instanceId}'. ` + @@ -624,6 +638,26 @@ export class InMemoryOrchestrationBackend { }); } + private processTerminateOrchestrationAction(action: pb.OrchestratorAction): void { + const terminateAction = action.getTerminateorchestration(); + if (!terminateAction) { + return; + } + + const targetInstanceId = terminateAction.getInstanceid(); + if (!targetInstanceId) { + return; + } + + const output = terminateAction.getReason()?.getValue(); + + try { + this.terminate(targetInstanceId, output); + } catch { + // Target instance may not exist or already terminated - ignore + } + } + private processSendEventAction(sendEvent: pb.SendEventAction): void { const targetInstanceId = sendEvent.getInstance()?.getInstanceid(); const eventName = sendEvent.getName(); diff --git a/packages/durabletask-js/test/in-memory-backend.spec.ts b/packages/durabletask-js/test/in-memory-backend.spec.ts index 66fe6a96..c6a70ffa 100644 --- a/packages/durabletask-js/test/in-memory-backend.spec.ts +++ b/packages/durabletask-js/test/in-memory-backend.spec.ts @@ -12,6 +12,7 @@ import { OrchestrationContext, Task, TOrchestrator, + EntityInstanceId, } from "../src"; describe("In-Memory Backend", () => { @@ -377,4 +378,74 @@ describe("In-Memory Backend", () => { expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); expect(state?.serializedOutput).toEqual(JSON.stringify(42)); }); + + it("should not crash when orchestration signals an entity (fire-and-forget)", async () => { + const entityId = new EntityInstanceId("counter", "mykey"); + + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext): Promise => { + // Signal an entity (fire-and-forget). The in-memory backend doesn't + // process entities, but it must not crash on the SENDENTITYMESSAGE action. + ctx.entities.signalEntity(entityId, "add", 1); + return "signaled"; + }; + + worker.addOrchestrator(orchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(orchestrator); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify("signaled")); + }); + + it("should not crash when orchestration signals multiple entities", async () => { + const entity1 = new EntityInstanceId("counter", "key1"); + const entity2 = new EntityInstanceId("counter", "key2"); + + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext): Promise => { + ctx.entities.signalEntity(entity1, "add", 1); + ctx.entities.signalEntity(entity2, "add", 2); + return "done"; + }; + + worker.addOrchestrator(orchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(orchestrator); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify("done")); + }); + + it("should not crash when orchestration signals entity then calls activity", async () => { + const entityId = new EntityInstanceId("counter", "mykey"); + + const doubleIt = async (_: ActivityContext, input: number) => { + return input * 2; + }; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + // Signal an entity, then call an activity. The backend must handle + // the SENDENTITYMESSAGE action without crashing before the activity + // result can be processed. + ctx.entities.signalEntity(entityId, "add", 5); + const result: number = yield ctx.callActivity(doubleIt, 21); + return result; + }; + + worker.addOrchestrator(orchestrator); + worker.addActivity(doubleIt); + await worker.start(); + + const id = await client.scheduleNewOrchestration(orchestrator); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify(42)); + }); });