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 1/3] [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)); + }); }); From 9c9712f7c694cb1a064691e70332c986057a3aa9 Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 27 Jul 2026 12:43:07 -0700 Subject: [PATCH 2/3] Fail fast on unsupported entity messages instead of hanging SENDENTITYMESSAGE is a four-way oneof, but it was handled with a single blanket no-op. signalEntity and the critical-section unlock are fire-and-forget, so dropping them is correct. callEntity and lockEntities await an EntityOperationCompleted / EntityLockGranted response that this backend has no entity worker to produce, so the no-op turned a fast 'unknown action type' failure into a silent hang until the caller's timeout. Now branches on EntitymessagetypeCase: signal and unlock stay no-ops, call and lock throw an explanatory error naming the operation and pointing at a real backend or the emulator, and an unrecognized case throws like the surrounding action switch does. The worker already converts a throw here into a FAILED orchestration with the message in failureDetails. Added tests for both paths; without the fix they hang until the Jest timeout (5002 ms), with it they fail in 6 ms. --- .../src/testing/in-memory-backend.ts | 44 ++++++++++++++++--- .../test/in-memory-backend.spec.ts | 42 ++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/packages/durabletask-js/src/testing/in-memory-backend.ts b/packages/durabletask-js/src/testing/in-memory-backend.ts index 6dfe2b8c..cb0b91e9 100644 --- a/packages/durabletask-js/src/testing/in-memory-backend.ts +++ b/packages/durabletask-js/src/testing/in-memory-backend.ts @@ -540,13 +540,7 @@ export class InMemoryOrchestrationBackend { 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. + this.processSendEntityMessageAction(instance, action.getSendentitymessage()!); break; case pb.OrchestratorAction.OrchestratoractiontypeCase.TERMINATEORCHESTRATION: // Terminate-orchestration actions are used for recursive termination of @@ -734,6 +728,42 @@ export class InMemoryOrchestrationBackend { }); } + /** + * Handles an entity message emitted by an orchestration. + * + * This backend has no entity worker, so only fire-and-forget messages can be honoured: + * `signalEntity` and the unlock sent when a critical section ends expect no reply and are + * dropped. `callEntity` and `lockEntities` block on a response that would never arrive, so + * they fail fast with an explanatory error rather than hanging the orchestration forever. + */ + private processSendEntityMessageAction( + instance: OrchestrationInstance, + entityMessage: pb.SendEntityMessageAction, + ): void { + const messageType = entityMessage.getEntitymessagetypeCase(); + const messageTypeCase = pb.SendEntityMessageAction.EntitymessagetypeCase; + + switch (messageType) { + case messageTypeCase.ENTITYOPERATIONSIGNALED: + case messageTypeCase.ENTITYUNLOCKSENT: + break; + case messageTypeCase.ENTITYOPERATIONCALLED: + case messageTypeCase.ENTITYLOCKREQUESTED: + throw new Error( + `Orchestration '${instance.instanceId}' used ` + + `${messageType === messageTypeCase.ENTITYOPERATIONCALLED ? "callEntity" : "lockEntities"}, which ` + + `the in-memory test backend does not support because it has no entity worker to produce a response. ` + + `Only fire-and-forget entity messages (signalEntity and the unlock that ends a critical section) ` + + `are supported here; use a real backend or the emulator to test entity calls and locks.`, + ); + default: + throw new Error( + `Unknown entity message type '${messageType}' for orchestration '${instance.instanceId}'. ` + + `This likely means the in-memory backend needs to be updated to handle a newly introduced entity message type.`, + ); + } + } + private processTerminateOrchestrationAction(action: pb.OrchestratorAction): void { const terminateAction = action.getTerminateorchestration(); if (!terminateAction) { diff --git a/packages/durabletask-js/test/in-memory-backend.spec.ts b/packages/durabletask-js/test/in-memory-backend.spec.ts index 4793c778..a186d899 100644 --- a/packages/durabletask-js/test/in-memory-backend.spec.ts +++ b/packages/durabletask-js/test/in-memory-backend.spec.ts @@ -931,4 +931,46 @@ describe("In-Memory Backend", () => { expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); expect(state?.serializedOutput).toEqual(JSON.stringify(42)); }); + + it("should fail with an explanatory error when an orchestration calls an entity", async () => { + // callEntity waits for a response that no entity worker will ever send here, so the + // backend must fail the orchestration rather than let it hang until the test times out. + const entityId = new EntityInstanceId("counter", "mykey"); + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + return yield ctx.entities.callEntity(entityId, "get"); + }; + + 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.FAILED); + expect(state?.failureDetails?.message).toContain("callEntity"); + expect(state?.failureDetails?.message).toContain("does not support"); + }); + + it("should fail with an explanatory error when an orchestration locks entities", async () => { + // lockEntities waits for an EntityLockGranted response, which this backend cannot produce. + const entityId = new EntityInstanceId("counter", "mykey"); + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + yield ctx.entities.lockEntities(entityId); + return "locked"; + }; + + 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.FAILED); + expect(state?.failureDetails?.message).toContain("lockEntities"); + expect(state?.failureDetails?.message).toContain("does not support"); + }); }); From 45040d0fd86d392253f8eb7b6197c7b0a2598a12 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 28 Jul 2026 16:46:26 -0700 Subject: [PATCH 3/3] refactor: drop unreachable TerminateOrchestration handling The JS SDK never emits a TerminateOrchestrationAction, so the handler added alongside the entity-message support could not be reached: - No construction site exists anywhere in src/ (outside generated proto). pb-helper.util.ts has builders for complete, timer, scheduleTask, subOrchestration, sendEvent and the four entity messages, but none for terminate. - OrchestrationContext exposes no terminate API; termination is client-side. - The only non-proto reference is getActionSummary() in worker/index.ts, which merely maps action types to display names for logging. By contrast REWINDORCHESTRATION, which the backend also handles, is genuinely constructed at worker/rewind.ts:123 - so handling exactly what is reachable is the existing convention here. The removed handler was also untested and swallowed all errors from terminate(). Full unit suite is unchanged at 1181 passing across 67 suites, confirming nothing exercised this path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5aaa70df-fae3-4570-aabe-0fc96cb869bc --- .../src/testing/in-memory-backend.ts | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/packages/durabletask-js/src/testing/in-memory-backend.ts b/packages/durabletask-js/src/testing/in-memory-backend.ts index 6cda485d..dce1de28 100644 --- a/packages/durabletask-js/src/testing/in-memory-backend.ts +++ b/packages/durabletask-js/src/testing/in-memory-backend.ts @@ -554,11 +554,6 @@ export class InMemoryOrchestrationBackend { case pb.OrchestratorAction.OrchestratoractiontypeCase.SENDENTITYMESSAGE: this.processSendEntityMessageAction(instance, action.getSendentitymessage()!); 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; case pb.OrchestratorAction.OrchestratoractiontypeCase.REWINDORCHESTRATION: this.processRewindOrchestrationAction(instance, action.getRewindorchestration()!); break; @@ -783,26 +778,6 @@ 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 prepareRewind(instance: OrchestrationInstance, reason?: string): void { // Reset instance state so it can be re-processed. instance.status = pb.OrchestrationStatus.ORCHESTRATION_STATUS_RUNNING;