From 6d58e1b7b5613330826a1f75431b523b2f78007a Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:15:36 -0800 Subject: [PATCH 01/12] feat: Add AzureLoggerAdapter for integrating Azure SDK logging - Implemented AzureLoggerAdapter to integrate with Azure SDK logging infrastructure. - Created createAzureLogger function for customizable logging namespaces. - Added unit tests for AzureLoggerAdapter and createAzureLogger. feat: Introduce backoff utility for retry scenarios - Implemented ExponentialBackoff class with configurable options for retries. - Added utility functions sleep and withTimeout for handling delays and timeouts. - Created unit tests for ExponentialBackoff and related utilities. feat: Add ConsoleLogger and NoOpLogger implementations - Implemented ConsoleLogger for default logging to the console. - Added NoOpLogger for silent logging, useful in testing scenarios. - Created unit tests for both ConsoleLogger and NoOpLogger to ensure compliance with Logger interface. chore: Define Logger interface for Durable Task SDK - Created Logger interface to standardize logging across the SDK. - Added documentation for Logger interface and its default implementations. --- .gitignore | 2 +- examples/azure-managed-dts.ts | 62 ++-- examples/azure-managed/index.ts | 62 ++-- examples/hello-world/activity-sequence.ts | 40 ++- examples/hello-world/fanout-fanin.ts | 38 +- examples/hello-world/human_interaction.ts | 38 +- package-lock.json | 17 +- .../durabletask-js-azuremanaged/package.json | 5 +- .../src/azure-logger-adapter.ts | 78 +++++ .../src/client-builder.ts | 17 +- .../durabletask-js-azuremanaged/src/index.ts | 6 + .../src/worker-builder.ts | 39 ++- .../test/unit/azure-logger-adapter.spec.ts | 133 +++++++ packages/durabletask-js/src/client/client.ts | 37 +- packages/durabletask-js/src/index.ts | 3 + .../durabletask-js/src/types/logger.type.ts | 84 +++++ .../durabletask-js/src/utils/backoff.util.ts | 227 ++++++++++++ .../src/worker/activity-executor.ts | 7 +- packages/durabletask-js/src/worker/index.ts | 1 - .../src/worker/orchestration-executor.ts | 55 +-- .../src/worker/task-hub-grpc-worker.ts | 153 +++++++-- .../test/activity_executor.spec.ts | 8 +- packages/durabletask-js/test/backoff.spec.ts | 195 +++++++++++ .../test/console-logger.spec.ts | 97 ++++++ .../durabletask-js/test/noop-logger.spec.ts | 96 ++++++ .../test/orchestration_executor.spec.ts | 324 +++++++++--------- submodules/durabletask-protobuf | 1 + 27 files changed, 1480 insertions(+), 345 deletions(-) create mode 100644 packages/durabletask-js-azuremanaged/src/azure-logger-adapter.ts create mode 100644 packages/durabletask-js-azuremanaged/test/unit/azure-logger-adapter.spec.ts create mode 100644 packages/durabletask-js/src/types/logger.type.ts create mode 100644 packages/durabletask-js/src/utils/backoff.util.ts create mode 100644 packages/durabletask-js/test/backoff.spec.ts create mode 100644 packages/durabletask-js/test/console-logger.spec.ts create mode 100644 packages/durabletask-js/test/noop-logger.spec.ts create mode 160000 submodules/durabletask-protobuf diff --git a/.gitignore b/.gitignore index fa99ca6..9aa2ade 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ !.vscode/launch.json !.vscode/extensions.json *.code-workspace - +.DS_Store ### DotnetCore ### # .NET Core build folders bin/ diff --git a/examples/azure-managed-dts.ts b/examples/azure-managed-dts.ts index 13c640e..305b5fc 100644 --- a/examples/azure-managed-dts.ts +++ b/examples/azure-managed-dts.ts @@ -14,6 +14,10 @@ import { DefaultAzureCredential } from "@azure/identity"; import { createAzureManagedClient, createAzureManagedWorkerBuilder, + // Logger types are re-exported for convenience + // ConsoleLogger is used by default + // Use createAzureLogger to integrate with @azure/logger + createAzureLogger, } from "../extensions/durabletask-js-azuremanaged/build"; import { ActivityContext } from "../src/task/context/activity-context"; import { OrchestrationContext } from "../src/task/context/orchestration-context"; @@ -23,6 +27,10 @@ import { Task } from "../src/task/task"; // Wrap the entire code in an immediately-invoked async function (async () => { + // Create a logger for this example + // This uses Azure SDK's logging infrastructure - set AZURE_LOG_LEVEL=verbose to see all logs + const logger = createAzureLogger("example"); + // Configuration for Azure Managed DTS // These values should be set as environment variables const endpoint = process.env.AZURE_DTS_ENDPOINT; @@ -31,31 +39,31 @@ import { Task } from "../src/task/task"; // Validate configuration if (!connectionString && (!endpoint || !taskHubName)) { - console.error( + logger.error( "Error: Either AZURE_DTS_CONNECTION_STRING or both AZURE_DTS_ENDPOINT and AZURE_DTS_TASKHUB must be set.", ); - console.log("\nUsage:"); - console.log(" Option 1: Create a .env file in the examples directory (recommended):"); - console.log( + logger.info("\nUsage:"); + logger.info(" Option 1: Create a .env file in the examples directory (recommended):"); + logger.info( " AZURE_DTS_CONNECTION_STRING=Endpoint=https://myservice.durabletask.io;Authentication=DefaultAzure;TaskHub=myTaskHub", ); - console.log(" or"); - console.log(" AZURE_DTS_ENDPOINT=https://myservice.durabletask.io"); - console.log(" AZURE_DTS_TASKHUB=myTaskHub"); - console.log("\n Option 2: Set environment variables directly"); - console.log(" export AZURE_DTS_CONNECTION_STRING=..."); + logger.info(" or"); + logger.info(" AZURE_DTS_ENDPOINT=https://myservice.durabletask.io"); + logger.info(" AZURE_DTS_TASKHUB=myTaskHub"); + logger.info("\n Option 2: Set environment variables directly"); + logger.info(" export AZURE_DTS_CONNECTION_STRING=..."); process.exit(1); } // Define an activity function that greets a city const greetCity = async (_: ActivityContext, city: string): Promise => { - console.log(`Activity executing: greeting ${city}`); + logger.info(`Activity executing: greeting ${city}`); return `Hello, ${city}!`; }; // Define an activity function that processes work items const processWorkItem = async (_: ActivityContext, item: string): Promise => { - console.log(`Activity executing: processing ${item}`); + logger.info(`Activity executing: processing ${item}`); // Simulate some processing time await new Promise((resolve) => setTimeout(resolve, 500)); return item.length; @@ -98,7 +106,7 @@ import { Task } from "../src/task/task"; try { // Create client and worker using connection string or explicit parameters if (connectionString) { - console.log("Using connection string authentication..."); + logger.info("Using connection string authentication..."); client = createAzureManagedClient(connectionString); worker = createAzureManagedWorkerBuilder(connectionString) .addOrchestrator(sequenceOrchestrator) @@ -107,7 +115,7 @@ import { Task } from "../src/task/task"; .addActivity(processWorkItem) .build(); } else { - console.log("Using DefaultAzureCredential authentication..."); + logger.info("Using DefaultAzureCredential authentication..."); const credential = new DefaultAzureCredential(); client = createAzureManagedClient(endpoint!, taskHubName!, credential); worker = createAzureManagedWorkerBuilder(endpoint!, taskHubName!, credential) @@ -119,42 +127,42 @@ import { Task } from "../src/task/task"; } // Start the worker - console.log("Starting worker..."); + logger.info("Starting worker..."); await worker.start(); - console.log("Worker started successfully!"); + logger.info("Worker started successfully!"); // Run the sequence orchestrator - console.log("\n--- Running Sequence Orchestrator ---"); + logger.info("\n--- Running Sequence Orchestrator ---"); const sequenceId = await client.scheduleNewOrchestration(sequenceOrchestrator); - console.log(`Orchestration scheduled with ID: ${sequenceId}`); + logger.info(`Orchestration scheduled with ID: ${sequenceId}`); const sequenceState = await client.waitForOrchestrationCompletion(sequenceId, undefined, 60); - console.log(`Sequence orchestration completed!`); - console.log(`Result: ${sequenceState?.serializedOutput}`); + logger.info(`Sequence orchestration completed!`); + logger.info(`Result: ${sequenceState?.serializedOutput}`); // Run the fan-out/fan-in orchestrator - console.log("\n--- Running Fan-Out/Fan-In Orchestrator ---"); + logger.info("\n--- Running Fan-Out/Fan-In Orchestrator ---"); const fanOutId = await client.scheduleNewOrchestration(fanOutFanInOrchestrator); - console.log(`Orchestration scheduled with ID: ${fanOutId}`); + logger.info(`Orchestration scheduled with ID: ${fanOutId}`); const fanOutState = await client.waitForOrchestrationCompletion(fanOutId, undefined, 60); - console.log(`Fan-out/fan-in orchestration completed!`); - console.log(`Result: ${fanOutState?.serializedOutput}`); + logger.info(`Fan-out/fan-in orchestration completed!`); + logger.info(`Result: ${fanOutState?.serializedOutput}`); - console.log("\n--- All orchestrations completed successfully! ---"); + logger.info("\n--- All orchestrations completed successfully! ---"); } catch (error) { - console.error("Error:", error); + logger.error("Error:", error); process.exit(1); } finally { // Cleanup: stop worker and client - console.log("\nStopping worker and client..."); + logger.info("\nStopping worker and client..."); if (worker) { await worker.stop(); } if (client) { await client.stop(); } - console.log("Cleanup complete."); + logger.info("Cleanup complete."); process.exit(0); } })(); diff --git a/examples/azure-managed/index.ts b/examples/azure-managed/index.ts index 630edbe..abd19d5 100644 --- a/examples/azure-managed/index.ts +++ b/examples/azure-managed/index.ts @@ -14,6 +14,10 @@ import { DefaultAzureCredential } from "@azure/identity"; import { createAzureManagedClient, createAzureManagedWorkerBuilder, + // Logger types are re-exported for convenience + // ConsoleLogger is used by default + // Use createAzureLogger to integrate with @azure/logger + createAzureLogger, } from "@microsoft/durabletask-js-azuremanaged"; import { ActivityContext } from "@microsoft/durabletask-js/dist/task/context/activity-context"; import { OrchestrationContext } from "@microsoft/durabletask-js/dist/task/context/orchestration-context"; @@ -23,6 +27,10 @@ import { Task } from "@microsoft/durabletask-js/dist/task/task"; // Wrap the entire code in an immediately-invoked async function (async () => { + // Create a logger for this example + // This uses Azure SDK's logging infrastructure - set AZURE_LOG_LEVEL=verbose to see all logs + const logger = createAzureLogger("example"); + // Configuration for Azure Managed DTS // These values should be set as environment variables const endpoint = process.env.AZURE_DTS_ENDPOINT; @@ -31,31 +39,31 @@ import { Task } from "@microsoft/durabletask-js/dist/task/task"; // Validate configuration if (!connectionString && (!endpoint || !taskHubName)) { - console.error( + logger.error( "Error: Either AZURE_DTS_CONNECTION_STRING or both AZURE_DTS_ENDPOINT and AZURE_DTS_TASKHUB must be set.", ); - console.log("\nUsage:"); - console.log(" Option 1: Create a .env file in the examples directory (recommended):"); - console.log( + logger.info("\nUsage:"); + logger.info(" Option 1: Create a .env file in the examples directory (recommended):"); + logger.info( " AZURE_DTS_CONNECTION_STRING=Endpoint=https://myservice.durabletask.io;Authentication=DefaultAzure;TaskHub=myTaskHub", ); - console.log(" or"); - console.log(" AZURE_DTS_ENDPOINT=https://myservice.durabletask.io"); - console.log(" AZURE_DTS_TASKHUB=myTaskHub"); - console.log("\n Option 2: Set environment variables directly"); - console.log(" export AZURE_DTS_CONNECTION_STRING=..."); + logger.info(" or"); + logger.info(" AZURE_DTS_ENDPOINT=https://myservice.durabletask.io"); + logger.info(" AZURE_DTS_TASKHUB=myTaskHub"); + logger.info("\n Option 2: Set environment variables directly"); + logger.info(" export AZURE_DTS_CONNECTION_STRING=..."); process.exit(1); } // Define an activity function that greets a city const greetCity = async (_: ActivityContext, city: string): Promise => { - console.log(`Activity executing: greeting ${city}`); + logger.info(`Activity executing: greeting ${city}`); return `Hello, ${city}!`; }; // Define an activity function that processes work items const processWorkItem = async (_: ActivityContext, item: string): Promise => { - console.log(`Activity executing: processing ${item}`); + logger.info(`Activity executing: processing ${item}`); // Simulate some processing time await new Promise((resolve) => setTimeout(resolve, 500)); return item.length; @@ -98,7 +106,7 @@ import { Task } from "@microsoft/durabletask-js/dist/task/task"; try { // Create client and worker using connection string or explicit parameters if (connectionString) { - console.log("Using connection string authentication..."); + logger.info("Using connection string authentication..."); client = createAzureManagedClient(connectionString); worker = createAzureManagedWorkerBuilder(connectionString) .addOrchestrator(sequenceOrchestrator) @@ -107,7 +115,7 @@ import { Task } from "@microsoft/durabletask-js/dist/task/task"; .addActivity(processWorkItem) .build(); } else { - console.log("Using DefaultAzureCredential authentication..."); + logger.info("Using DefaultAzureCredential authentication..."); const credential = new DefaultAzureCredential(); client = createAzureManagedClient(endpoint!, taskHubName!, credential); worker = createAzureManagedWorkerBuilder(endpoint!, taskHubName!, credential) @@ -119,42 +127,42 @@ import { Task } from "@microsoft/durabletask-js/dist/task/task"; } // Start the worker - console.log("Starting worker..."); + logger.info("Starting worker..."); await worker.start(); - console.log("Worker started successfully!"); + logger.info("Worker started successfully!"); // Run the sequence orchestrator - console.log("\n--- Running Sequence Orchestrator ---"); + logger.info("\n--- Running Sequence Orchestrator ---"); const sequenceId = await client.scheduleNewOrchestration(sequenceOrchestrator); - console.log(`Orchestration scheduled with ID: ${sequenceId}`); + logger.info(`Orchestration scheduled with ID: ${sequenceId}`); const sequenceState = await client.waitForOrchestrationCompletion(sequenceId, undefined, 60); - console.log(`Sequence orchestration completed!`); - console.log(`Result: ${sequenceState?.serializedOutput}`); + logger.info(`Sequence orchestration completed!`); + logger.info(`Result: ${sequenceState?.serializedOutput}`); // Run the fan-out/fan-in orchestrator - console.log("\n--- Running Fan-Out/Fan-In Orchestrator ---"); + logger.info("\n--- Running Fan-Out/Fan-In Orchestrator ---"); const fanOutId = await client.scheduleNewOrchestration(fanOutFanInOrchestrator); - console.log(`Orchestration scheduled with ID: ${fanOutId}`); + logger.info(`Orchestration scheduled with ID: ${fanOutId}`); const fanOutState = await client.waitForOrchestrationCompletion(fanOutId, undefined, 60); - console.log(`Fan-out/fan-in orchestration completed!`); - console.log(`Result: ${fanOutState?.serializedOutput}`); + logger.info(`Fan-out/fan-in orchestration completed!`); + logger.info(`Result: ${fanOutState?.serializedOutput}`); - console.log("\n--- All orchestrations completed successfully! ---"); + logger.info("\n--- All orchestrations completed successfully! ---"); } catch (error) { - console.error("Error:", error); + logger.error("Error:", error); process.exit(1); } finally { // Cleanup: stop worker and client - console.log("\nStopping worker and client..."); + logger.info("\nStopping worker and client..."); if (worker) { await worker.stop(); } if (client) { await client.stop(); } - console.log("Cleanup complete."); + logger.info("Cleanup complete."); process.exit(0); } })(); diff --git a/examples/hello-world/activity-sequence.ts b/examples/hello-world/activity-sequence.ts index 99ab6d0..bb45b08 100644 --- a/examples/hello-world/activity-sequence.ts +++ b/examples/hello-world/activity-sequence.ts @@ -7,14 +7,40 @@ import { ActivityContext, OrchestrationContext, TOrchestrator, + // Logger types for custom logging + // ConsoleLogger (default) - logs to console + // NoOpLogger - silent mode, useful for testing + // You can also implement your own Logger interface + ConsoleLogger, } from "@microsoft/durabletask-js"; // Wrap the entire code in an immediately-invoked async function (async () => { // Update the gRPC client and worker to use a local address and port const grpcServerAddress = "localhost:4001"; - const taskHubClient: TaskHubGrpcClient = new TaskHubGrpcClient(grpcServerAddress); - const taskHubWorker: TaskHubGrpcWorker = new TaskHubGrpcWorker(grpcServerAddress); + + // Optional: Create a custom logger (defaults to ConsoleLogger if not provided) + // You can implement your own Logger interface to integrate with Winston, Pino, etc. + const logger = new ConsoleLogger(); + + // Pass the logger as the 6th parameter (after metadataGenerator) + // Parameters: hostAddress, options, useTLS, credentials, metadataGenerator, logger + const taskHubClient: TaskHubGrpcClient = new TaskHubGrpcClient( + grpcServerAddress, + undefined, + undefined, + undefined, + undefined, + logger, + ); + const taskHubWorker: TaskHubGrpcWorker = new TaskHubGrpcWorker( + grpcServerAddress, + undefined, + undefined, + undefined, + undefined, + logger, + ); const hello = async (_: ActivityContext, name: string) => { return `Hello ${name}!`; @@ -39,22 +65,22 @@ import { // Wrap the worker startup in a try-catch block to handle any errors during startup try { await taskHubWorker.start(); - console.log("Worker started successfully"); + logger.info("Worker started successfully"); } catch (error) { - console.error("Error starting worker:", error); + logger.error("Error starting worker:", error); } // Schedule a new orchestration try { const id = await taskHubClient.scheduleNewOrchestration(sequence); - console.log(`Orchestration scheduled with ID: ${id}`); + logger.info(`Orchestration scheduled with ID: ${id}`); // Wait for orchestration completion const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); - console.log(`Orchestration completed! Result: ${state?.serializedOutput}`); + logger.info(`Orchestration completed! Result: ${state?.serializedOutput}`); } catch (error) { - console.error("Error scheduling or waiting for orchestration:", error); + logger.error("Error scheduling or waiting for orchestration:", error); } // stop worker and client diff --git a/examples/hello-world/fanout-fanin.ts b/examples/hello-world/fanout-fanin.ts index 5c85255..8696d4c 100644 --- a/examples/hello-world/fanout-fanin.ts +++ b/examples/hello-world/fanout-fanin.ts @@ -9,14 +9,34 @@ import { Task, TOrchestrator, whenAll, + // Logger types: ConsoleLogger (default), NoOpLogger (silent) + ConsoleLogger, } from "@microsoft/durabletask-js"; // Wrap the entire code in an immediately-invoked async function (async () => { // Update the gRPC client and worker to use a local address and port const grpcServerAddress = "localhost:4001"; - const taskHubClient: TaskHubGrpcClient = new TaskHubGrpcClient(grpcServerAddress); - const taskHubWorker: TaskHubGrpcWorker = new TaskHubGrpcWorker(grpcServerAddress); + + // Optional: Use a custom logger (ConsoleLogger is the default) + const logger = new ConsoleLogger(); + + const taskHubClient: TaskHubGrpcClient = new TaskHubGrpcClient( + grpcServerAddress, + undefined, + undefined, + undefined, + undefined, + logger, + ); + const taskHubWorker: TaskHubGrpcWorker = new TaskHubGrpcWorker( + grpcServerAddress, + undefined, + undefined, + undefined, + undefined, + logger, + ); function getRandomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; @@ -24,7 +44,7 @@ import { async function getWorkItemsActivity(_: ActivityContext): Promise { const count: number = getRandomInt(2, 10); - console.log(`generating ${count} work items...`); + logger.info(`generating ${count} work items...`); const workItems: string[] = Array.from({ length: count }, (_, i) => `work item ${i}`); return workItems; @@ -35,7 +55,7 @@ import { } async function processWorkItemActivity(context: ActivityContext, item: string): Promise { - console.log(`processing work item: ${item}`); + logger.info(`processing work item: ${item}`); // Simulate some work that takes a variable amount of time const sleepTime = Math.random() * 5000; @@ -63,22 +83,22 @@ import { // Wrap the worker startup in a try-catch block to handle any errors during startup try { await taskHubWorker.start(); - console.log("Worker started successfully"); + logger.info("Worker started successfully"); } catch (error) { - console.error("Error starting worker:", error); + logger.error("Error starting worker:", error); } // Schedule a new orchestration try { const id = await taskHubClient.scheduleNewOrchestration(orchestrator); - console.log(`Orchestration scheduled with ID: ${id}`); + logger.info(`Orchestration scheduled with ID: ${id}`); // Wait for orchestration completion const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); - console.log(`Orchestration completed! Result: ${state?.serializedOutput}`); + logger.info(`Orchestration completed! Result: ${state?.serializedOutput}`); } catch (error) { - console.error("Error scheduling or waiting for orchestration:", error); + logger.error("Error scheduling or waiting for orchestration:", error); } // stop worker and client diff --git a/examples/hello-world/human_interaction.ts b/examples/hello-world/human_interaction.ts index e7280f9..042be06 100644 --- a/examples/hello-world/human_interaction.ts +++ b/examples/hello-world/human_interaction.ts @@ -9,6 +9,8 @@ import { Task, TOrchestrator, whenAny, + // Logger types: ConsoleLogger (default), NoOpLogger (silent) + ConsoleLogger, } from "@microsoft/durabletask-js"; import * as readlineSync from "readline-sync"; @@ -31,19 +33,37 @@ import * as readlineSync from "readline-sync"; // Update the gRPC client and worker to use a local address and port const grpcServerAddress = "localhost:4001"; - const taskHubClient: TaskHubGrpcClient = new TaskHubGrpcClient(grpcServerAddress); - const taskHubWorker: TaskHubGrpcWorker = new TaskHubGrpcWorker(grpcServerAddress); + + // Optional: Use a custom logger (ConsoleLogger is the default) + const logger = new ConsoleLogger(); + + const taskHubClient: TaskHubGrpcClient = new TaskHubGrpcClient( + grpcServerAddress, + undefined, + undefined, + undefined, + undefined, + logger, + ); + const taskHubWorker: TaskHubGrpcWorker = new TaskHubGrpcWorker( + grpcServerAddress, + undefined, + undefined, + undefined, + undefined, + logger, + ); //Activity function that sends an approval request to the manager const sendApprovalRequest = async (_: ActivityContext, order: Order) => { // Simulate some work that takes an amount of time await sleep(3000); - console.log(`Sending approval request for order: ${order.product}`); + logger.info(`Sending approval request for order: ${order.product}`); }; // Activity function that places an order const placeOrder = async (_: ActivityContext, order: Order) => { - console.log(`Placing order: ${order.product}`); + logger.info(`Placing order: ${order.product}`); }; // Orchestrator function that represents a purchase order workflow @@ -80,9 +100,9 @@ import * as readlineSync from "readline-sync"; // Wrap the worker startup in a try-catch block to handle any errors during startup try { await taskHubWorker.start(); - console.log("Worker started successfully"); + logger.info("Worker started successfully"); } catch (error) { - console.error("Error starting worker:", error); + logger.error("Error starting worker:", error); } // Schedule a new orchestration @@ -92,7 +112,7 @@ import * as readlineSync from "readline-sync"; const timeout = readlineSync.question("Timeout for your order in seconds:"); const order = new Order(cost, "MyProduct", 1); const id = await taskHubClient.scheduleNewOrchestration(purchaseOrderWorkflow, order); - console.log(`Orchestration scheduled with ID: ${id}`); + logger.info(`Orchestration scheduled with ID: ${id}`); if (readlineSync.keyInYN("Press [Y] to approve the order... Y/yes, N/no")) { const approvalEvent = { approver: approver }; @@ -104,9 +124,9 @@ import * as readlineSync from "readline-sync"; // Wait for orchestration completion const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, timeout + 2); - console.log(`Orchestration completed! Result: ${state?.serializedOutput}`); + logger.info(`Orchestration completed! Result: ${state?.serializedOutput}`); } catch (error) { - console.error("Error scheduling or waiting for orchestration:", error); + logger.error("Error scheduling or waiting for orchestration:", error); } // stop worker and client diff --git a/package-lock.json b/package-lock.json index a42012e..b1da2bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -226,7 +226,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -898,7 +897,6 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -1600,7 +1598,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -1816,7 +1813,6 @@ "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -1978,7 +1974,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -2052,7 +2047,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2294,7 +2288,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2619,7 +2612,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3267,7 +3259,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4183,7 +4174,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5930,7 +5920,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6655,7 +6644,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6807,7 +6795,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -6894,7 +6881,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7319,7 +7305,8 @@ "version": "0.1.0-alpha.1", "license": "MIT", "dependencies": { - "@azure/identity": "^4.0.0" + "@azure/identity": "^4.0.0", + "@azure/logger": "^1.0.0" }, "devDependencies": { "@types/jest": "^29.5.1", diff --git a/packages/durabletask-js-azuremanaged/package.json b/packages/durabletask-js-azuremanaged/package.json index eed0740..696a92a 100644 --- a/packages/durabletask-js-azuremanaged/package.json +++ b/packages/durabletask-js-azuremanaged/package.json @@ -47,7 +47,8 @@ "node": ">=22.0.0" }, "dependencies": { - "@azure/identity": "^4.0.0" + "@azure/identity": "^4.0.0", + "@azure/logger": "^1.0.0" }, "peerDependencies": { "@grpc/grpc-js": "^1.8.14", @@ -60,4 +61,4 @@ "ts-jest": "^29.1.0", "typescript": "^5.0.4" } -} +} \ No newline at end of file diff --git a/packages/durabletask-js-azuremanaged/src/azure-logger-adapter.ts b/packages/durabletask-js-azuremanaged/src/azure-logger-adapter.ts new file mode 100644 index 0000000..216dbfa --- /dev/null +++ b/packages/durabletask-js-azuremanaged/src/azure-logger-adapter.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { createClientLogger, AzureLogger } from "@azure/logger"; +import { Logger } from "@microsoft/durabletask-js"; + +/** + * Pre-configured logger adapter that uses the default "durabletask" namespace. + * + * This adapter integrates with the Azure SDK logging infrastructure, allowing + * log output to be controlled via the `AZURE_LOG_LEVEL` environment variable. + * + * Supported log levels (via AZURE_LOG_LEVEL): + * - error: Only show errors + * - warning: Show warnings and errors + * - info: Show info, warnings, and errors + * - verbose: Show all logs including debug messages + * + * @example + * ```typescript + * // Enable verbose logging via environment variable + * process.env.AZURE_LOG_LEVEL = "verbose"; + * + * // Use with the client builder + * const client = new DurableTaskAzureManagedClientBuilder() + * .connectionString(connectionString) + * .logger(AzureLoggerAdapter) + * .build(); + * ``` + */ +export const AzureLoggerAdapter: Logger = createAzureLogger(); + +/** + * Creates a Logger instance that integrates with Azure SDK's logging infrastructure. + * + * The created logger uses `@azure/logger` under the hood, which means: + * - Log output can be controlled via the `AZURE_LOG_LEVEL` environment variable + * - Log output can be redirected using `setLogLevel()` from `@azure/logger` + * - Logs are prefixed with the namespace for easy filtering + * + * @param namespace - Optional sub-namespace to append to "durabletask". + * For example, "client" results in "durabletask:client". + * @returns A Logger instance configured for the specified namespace. + * + * @example + * ```typescript + * // Create a logger for client operations + * const clientLogger = createAzureLogger("client"); + * // Logs will be prefixed with "durabletask:client" + * + * // Create a logger for worker operations + * const workerLogger = createAzureLogger("worker"); + * // Logs will be prefixed with "durabletask:worker" + * + * // Create a logger with default namespace + * const defaultLogger = createAzureLogger(); + * // Logs will be prefixed with "durabletask" + * ``` + */ +export function createAzureLogger(namespace?: string): Logger { + const fullNamespace = namespace ? `durabletask:${namespace}` : "durabletask"; + const azureLogger: AzureLogger = createClientLogger(fullNamespace); + + return { + error: (message: string, ...args: unknown[]): void => { + azureLogger.error(message, ...args); + }, + warn: (message: string, ...args: unknown[]): void => { + azureLogger.warning(message, ...args); + }, + info: (message: string, ...args: unknown[]): void => { + azureLogger.info(message, ...args); + }, + debug: (message: string, ...args: unknown[]): void => { + azureLogger.verbose(message, ...args); + }, + }; +} diff --git a/packages/durabletask-js-azuremanaged/src/client-builder.ts b/packages/durabletask-js-azuremanaged/src/client-builder.ts index 829727e..4a41cdf 100644 --- a/packages/durabletask-js-azuremanaged/src/client-builder.ts +++ b/packages/durabletask-js-azuremanaged/src/client-builder.ts @@ -5,7 +5,7 @@ import { TokenCredential } from "@azure/identity"; import * as grpc from "@grpc/grpc-js"; import { DurableTaskAzureManagedClientOptions } from "./options"; import { ClientRetryOptions } from "./retry-policy"; -import { TaskHubGrpcClient } from "@microsoft/durabletask-js"; +import { TaskHubGrpcClient, Logger, ConsoleLogger } from "@microsoft/durabletask-js"; /** * Builder for creating DurableTaskClient instances that connect to Azure-managed Durable Task service. @@ -14,6 +14,7 @@ import { TaskHubGrpcClient } from "@microsoft/durabletask-js"; export class DurableTaskAzureManagedClientBuilder { private _options: DurableTaskAzureManagedClientOptions; private _grpcChannelOptions: grpc.ChannelOptions = {}; + private _logger: Logger = new ConsoleLogger(); /** * Creates a new instance of DurableTaskAzureManagedClientBuilder. @@ -123,6 +124,18 @@ export class DurableTaskAzureManagedClientBuilder { return this; } + /** + * Sets the logger to use for logging. + * Defaults to ConsoleLogger. + * + * @param logger The logger instance. + * @returns This builder instance. + */ + logger(logger: Logger): DurableTaskAzureManagedClientBuilder { + this._logger = logger; + return this; + } + /** * Builds and returns a configured TaskHubGrpcClient. * @@ -147,7 +160,7 @@ export class DurableTaskAzureManagedClientBuilder { // Use the core TaskHubGrpcClient with custom credentials and metadata generator // For insecure connections, metadata is passed via the metadataGenerator parameter // For secure connections, metadata is included in the channel credentials - return new TaskHubGrpcClient(hostAddress, combinedOptions, true, channelCredentials, metadataGenerator); + return new TaskHubGrpcClient(hostAddress, combinedOptions, true, channelCredentials, metadataGenerator, this._logger); } } diff --git a/packages/durabletask-js-azuremanaged/src/index.ts b/packages/durabletask-js-azuremanaged/src/index.ts index de5483e..0f2f75f 100644 --- a/packages/durabletask-js-azuremanaged/src/index.ts +++ b/packages/durabletask-js-azuremanaged/src/index.ts @@ -17,3 +17,9 @@ export { } from "./retry-policy"; export { DurableTaskAzureManagedClientBuilder, createAzureManagedClient } from "./client-builder"; export { DurableTaskAzureManagedWorkerBuilder, createAzureManagedWorkerBuilder } from "./worker-builder"; + +// Logger exports - re-export from core package for convenience +export { Logger, ConsoleLogger, NoOpLogger } from "@microsoft/durabletask-js"; + +// Azure-specific logger adapter +export { AzureLoggerAdapter, createAzureLogger } from "./azure-logger-adapter"; diff --git a/packages/durabletask-js-azuremanaged/src/worker-builder.ts b/packages/durabletask-js-azuremanaged/src/worker-builder.ts index 693c42d..310b1cf 100644 --- a/packages/durabletask-js-azuremanaged/src/worker-builder.ts +++ b/packages/durabletask-js-azuremanaged/src/worker-builder.ts @@ -4,7 +4,7 @@ import { TokenCredential } from "@azure/identity"; import * as grpc from "@grpc/grpc-js"; import { DurableTaskAzureManagedWorkerOptions } from "./options"; -import { TaskHubGrpcWorker, TOrchestrator, TActivity, TInput, TOutput } from "@microsoft/durabletask-js"; +import { TaskHubGrpcWorker, TOrchestrator, TActivity, TInput, TOutput, Logger, ConsoleLogger } from "@microsoft/durabletask-js"; /** * Builder for creating DurableTaskWorker instances that connect to Azure-managed Durable Task service. @@ -15,6 +15,8 @@ export class DurableTaskAzureManagedWorkerBuilder { private _grpcChannelOptions: grpc.ChannelOptions = {}; private _orchestrators: { name?: string; fn: TOrchestrator }[] = []; private _activities: { name?: string; fn: TActivity }[] = []; + private _logger: Logger = new ConsoleLogger(); + private _shutdownTimeoutMs?: number; /** * Creates a new instance of DurableTaskAzureManagedWorkerBuilder. @@ -171,6 +173,31 @@ export class DurableTaskAzureManagedWorkerBuilder { return this; } + /** + * Sets the logger to use for logging. + * Defaults to ConsoleLogger. + * + * @param logger The logger instance. + * @returns This builder instance. + */ + logger(logger: Logger): DurableTaskAzureManagedWorkerBuilder { + this._logger = logger; + return this; + } + + /** + * Sets the shutdown timeout in milliseconds. + * This is the maximum time to wait for pending work items to complete during shutdown. + * Defaults to 30000 (30 seconds). + * + * @param timeoutMs The shutdown timeout in milliseconds. + * @returns This builder instance. + */ + shutdownTimeout(timeoutMs: number): DurableTaskAzureManagedWorkerBuilder { + this._shutdownTimeoutMs = timeoutMs; + return this; + } + /** * Builds and returns a configured TaskHubGrpcWorker. * @@ -195,7 +222,15 @@ export class DurableTaskAzureManagedWorkerBuilder { // Use the core TaskHubGrpcWorker with custom credentials and metadata generator // For insecure connections, metadata is passed via the metadataGenerator parameter // For secure connections, metadata is included in the channel credentials - const worker = new TaskHubGrpcWorker(hostAddress, combinedOptions, true, channelCredentials, metadataGenerator); + const worker = new TaskHubGrpcWorker( + hostAddress, + combinedOptions, + true, + channelCredentials, + metadataGenerator, + this._logger, + this._shutdownTimeoutMs, + ); // Register all orchestrators for (const { name, fn } of this._orchestrators) { diff --git a/packages/durabletask-js-azuremanaged/test/unit/azure-logger-adapter.spec.ts b/packages/durabletask-js-azuremanaged/test/unit/azure-logger-adapter.spec.ts new file mode 100644 index 0000000..4746747 --- /dev/null +++ b/packages/durabletask-js-azuremanaged/test/unit/azure-logger-adapter.spec.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Define Logger interface locally for testing (matches @microsoft/durabletask-js Logger) +interface Logger { + error(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + debug(message: string, ...args: unknown[]): void; +} + +// Mock @azure/logger +jest.mock("@azure/logger", () => { + const mockLogger = { + error: jest.fn(), + warning: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + }; + + return { + createClientLogger: jest.fn().mockReturnValue(mockLogger), + AzureLogger: jest.fn(), + }; +}); + +// Require after jest.mock to ensure the mocked module is used. +const { createClientLogger } = require("@azure/logger") as typeof import("@azure/logger"); +const { AzureLoggerAdapter, createAzureLogger } = + require("../../src/azure-logger-adapter") as typeof import("../../src/azure-logger-adapter"); + +describe("AzureLoggerAdapter", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("AzureLoggerAdapter constant", () => { + it("should be a valid Logger instance", () => { + expect(AzureLoggerAdapter).toBeDefined(); + expect(typeof AzureLoggerAdapter.error).toBe("function"); + expect(typeof AzureLoggerAdapter.warn).toBe("function"); + expect(typeof AzureLoggerAdapter.info).toBe("function"); + expect(typeof AzureLoggerAdapter.debug).toBe("function"); + }); + + it("should implement Logger interface", () => { + const logger: Logger = AzureLoggerAdapter; + expect(logger).toBe(AzureLoggerAdapter); + }); + }); + + describe("createAzureLogger", () => { + it("should create a logger with default namespace", () => { + const logger = createAzureLogger(); + + expect(createClientLogger).toHaveBeenCalledWith("durabletask"); + expect(logger).toBeDefined(); + expect(typeof logger.error).toBe("function"); + expect(typeof logger.warn).toBe("function"); + expect(typeof logger.info).toBe("function"); + expect(typeof logger.debug).toBe("function"); + }); + + it("should create a logger with custom namespace", () => { + createAzureLogger("client"); + + expect(createClientLogger).toHaveBeenCalledWith("durabletask:client"); + }); + + it("should create a logger with worker namespace", () => { + createAzureLogger("worker"); + + expect(createClientLogger).toHaveBeenCalledWith("durabletask:worker"); + }); + }); + + describe("method mapping", () => { + let mockAzureLogger: { + error: jest.Mock; + warning: jest.Mock; + info: jest.Mock; + verbose: jest.Mock; + }; + + beforeEach(() => { + mockAzureLogger = { + error: jest.fn(), + warning: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + }; + (createClientLogger as jest.Mock).mockReturnValue(mockAzureLogger); + }); + + it("should map error() to azureLogger.error()", () => { + const logger = createAzureLogger(); + logger.error("test error", { data: "value" }); + + expect(mockAzureLogger.error).toHaveBeenCalledWith("test error", { data: "value" }); + }); + + it("should map warn() to azureLogger.warning()", () => { + const logger = createAzureLogger(); + logger.warn("test warning", 42); + + expect(mockAzureLogger.warning).toHaveBeenCalledWith("test warning", 42); + }); + + it("should map info() to azureLogger.info()", () => { + const logger = createAzureLogger(); + logger.info("test info"); + + expect(mockAzureLogger.info).toHaveBeenCalledWith("test info"); + }); + + it("should map debug() to azureLogger.verbose()", () => { + const logger = createAzureLogger(); + logger.debug("test debug", ["array"]); + + expect(mockAzureLogger.verbose).toHaveBeenCalledWith("test debug", ["array"]); + }); + }); + + describe("Logger interface compliance", () => { + it("should be usable as Logger type", () => { + const loggerFromAdapter: Logger = AzureLoggerAdapter; + const loggerFromFactory: Logger = createAzureLogger(); + + expect(loggerFromAdapter).toBeDefined(); + expect(loggerFromFactory).toBeDefined(); + }); + }); +}); diff --git a/packages/durabletask-js/src/client/client.ts b/packages/durabletask-js/src/client/client.ts index 5e48f11..70328b0 100644 --- a/packages/durabletask-js/src/client/client.ts +++ b/packages/durabletask-js/src/client/client.ts @@ -21,6 +21,7 @@ import { callWithMetadata, MetadataGenerator } from "../utils/grpc-helper.util"; import { OrchestrationQuery, ListInstanceIdsOptions, DEFAULT_PAGE_SIZE } from "../orchestration/orchestration-query"; import { Page, AsyncPageable, createAsyncPageable } from "../orchestration/page"; import { FailureDetails } from "../task/failure-details"; +import { Logger, ConsoleLogger } from "../types/logger.type"; // Re-export MetadataGenerator for backward compatibility export { MetadataGenerator } from "../utils/grpc-helper.util"; @@ -28,6 +29,7 @@ export { MetadataGenerator } from "../utils/grpc-helper.util"; export class TaskHubGrpcClient { private _stub: stubs.TaskHubSidecarServiceClient; private _metadataGenerator?: MetadataGenerator; + private _logger: Logger; /** * Creates a new TaskHubGrpcClient instance. @@ -37,6 +39,7 @@ export class TaskHubGrpcClient { * @param useTLS Whether to use TLS. Defaults to false. * @param credentials Optional pre-configured channel credentials. If provided, useTLS is ignored. * @param metadataGenerator Optional function to generate per-call metadata (for taskhub, auth tokens, etc.). + * @param logger Optional logger instance. Defaults to ConsoleLogger. */ constructor( hostAddress?: string, @@ -44,17 +47,19 @@ export class TaskHubGrpcClient { useTLS?: boolean, credentials?: grpc.ChannelCredentials, metadataGenerator?: MetadataGenerator, + logger?: Logger, ) { this._stub = new GrpcClient(hostAddress, options, useTLS, credentials).stub; this._metadataGenerator = metadataGenerator; + this._logger = logger ?? new ConsoleLogger(); } async stop(): Promise { - await this._stub.close(); + this._stub.close(); - // Wait a bit to let the async operations finish + // Brief pause to allow gRPC cleanup - this is a known issue with grpc-node // https://github.com/grpc/grpc-node/issues/1563#issuecomment-829483711 - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 500)); } /** @@ -88,7 +93,7 @@ export class TaskHubGrpcClient { req.setInput(i); req.setScheduledstarttimestamp(ts); - console.log(`Starting new ${name} instance with ID = ${req.getInstanceid()}`); + this._logger.info(`Starting new ${name} instance with ID = ${req.getInstanceid()}`); const res = await callWithMetadata( this._stub.startInstance.bind(this._stub), @@ -192,7 +197,7 @@ export class TaskHubGrpcClient { req.setInstanceid(instanceId); req.setGetinputsandoutputs(fetchPayloads); - console.info(`Waiting ${timeout} seconds for instance ${instanceId} to complete...`); + this._logger.info(`Waiting ${timeout} seconds for instance ${instanceId} to complete...`); const callPromise = callWithMetadata( this._stub.waitForInstanceCompletion.bind(this._stub), @@ -216,11 +221,11 @@ export class TaskHubGrpcClient { if (state.runtimeStatus === OrchestrationStatus.FAILED && state.failureDetails) { details = state.failureDetails; - console.info(`Instance ${instanceId} failed: [${details.errorType}] ${details.message}`); + this._logger.info(`Instance ${instanceId} failed: [${details.errorType}] ${details.message}`); } else if (state.runtimeStatus === OrchestrationStatus.TERMINATED) { - console.info(`Instance ${instanceId} was terminated`); + this._logger.info(`Instance ${instanceId} was terminated`); } else if (state.runtimeStatus === OrchestrationStatus.COMPLETED) { - console.info(`Instance ${instanceId} completed`); + this._logger.info(`Instance ${instanceId} completed`); } return state; @@ -246,7 +251,7 @@ export class TaskHubGrpcClient { req.setInput(i); - console.log(`Raising event '${eventName}' for instance '${instanceId}'`); + this._logger.info(`Raising event '${eventName}' for instance '${instanceId}'`); await callWithMetadata( this._stub.raiseEvent.bind(this._stub), @@ -270,7 +275,7 @@ export class TaskHubGrpcClient { req.setOutput(i); - console.log(`Terminating '${instanceId}'`); + this._logger.info(`Terminating '${instanceId}'`); await callWithMetadata( this._stub.terminateInstance.bind(this._stub), @@ -283,7 +288,7 @@ export class TaskHubGrpcClient { const req = new pb.SuspendRequest(); req.setInstanceid(instanceId); - console.log(`Suspending '${instanceId}'`); + this._logger.info(`Suspending '${instanceId}'`); await callWithMetadata( this._stub.suspendInstance.bind(this._stub), @@ -296,7 +301,7 @@ export class TaskHubGrpcClient { const req = new pb.ResumeRequest(); req.setInstanceid(instanceId); - console.log(`Resuming '${instanceId}'`); + this._logger.info(`Resuming '${instanceId}'`); await callWithMetadata( this._stub.resumeInstance.bind(this._stub), @@ -334,7 +339,7 @@ export class TaskHubGrpcClient { req.setReason(reasonValue); } - console.log(`Rewinding '${instanceId}' with reason: ${reason}`); + this._logger.info(`Rewinding '${instanceId}' with reason: ${reason}`); try { await callWithMetadata( @@ -391,7 +396,7 @@ export class TaskHubGrpcClient { req.setInstanceid(instanceId); req.setRestartwithnewinstanceid(restartWithNewInstanceId); - console.log(`Restarting '${instanceId}' with restartWithNewInstanceId=${restartWithNewInstanceId}`); + this._logger.info(`Restarting '${instanceId}' with restartWithNewInstanceId=${restartWithNewInstanceId}`); try { const res = await callWithMetadata( @@ -441,7 +446,7 @@ export class TaskHubGrpcClient { const req = new pb.PurgeInstancesRequest(); req.setInstanceid(instanceId); - console.log(`Purging Instance '${instanceId}'`); + this._logger.info(`Purging Instance '${instanceId}'`); res = await callWithMetadata( this._stub.purgeInstances.bind(this._stub), @@ -471,7 +476,7 @@ export class TaskHubGrpcClient { req.setPurgeinstancefilter(filter); const timeout = purgeInstanceCriteria.getTimeout(); - console.log("Purging Instance using purging criteria"); + this._logger.info("Purging Instance using purging criteria"); const callPromise = callWithMetadata( this._stub.purgeInstances.bind(this._stub), diff --git a/packages/durabletask-js/src/index.ts b/packages/durabletask-js/src/index.ts index 535fba9..1f65971 100644 --- a/packages/durabletask-js/src/index.ts +++ b/packages/durabletask-js/src/index.ts @@ -34,3 +34,6 @@ export { TOrchestrator } from "./types/orchestrator.type"; export { TActivity } from "./types/activity.type"; export { TInput } from "./types/input.type"; export { TOutput } from "./types/output.type"; + +// Logger +export { Logger, ConsoleLogger, NoOpLogger } from "./types/logger.type"; diff --git a/packages/durabletask-js/src/types/logger.type.ts b/packages/durabletask-js/src/types/logger.type.ts new file mode 100644 index 0000000..7b6690c --- /dev/null +++ b/packages/durabletask-js/src/types/logger.type.ts @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Logger interface for the Durable Task SDK. + * + * Users can implement this interface to integrate their own logging framework + * (e.g., Winston, Pino, Azure Logger) with the SDK. + */ +export interface Logger { + /** + * Logs an error message. + * @param message - The error message to log. + * @param args - Additional arguments to include in the log. + */ + error(message: string, ...args: unknown[]): void; + + /** + * Logs a warning message. + * @param message - The warning message to log. + * @param args - Additional arguments to include in the log. + */ + warn(message: string, ...args: unknown[]): void; + + /** + * Logs an informational message. + * @param message - The informational message to log. + * @param args - Additional arguments to include in the log. + */ + info(message: string, ...args: unknown[]): void; + + /** + * Logs a debug message. + * @param message - The debug message to log. + * @param args - Additional arguments to include in the log. + */ + debug(message: string, ...args: unknown[]): void; +} + +/** + * Default logger implementation that delegates to the console. + * + * This is the default logger used by the SDK when no custom logger is provided. + */ +export class ConsoleLogger implements Logger { + error(message: string, ...args: unknown[]): void { + console.error(message, ...args); + } + + warn(message: string, ...args: unknown[]): void { + console.warn(message, ...args); + } + + info(message: string, ...args: unknown[]): void { + console.info(message, ...args); + } + + debug(message: string, ...args: unknown[]): void { + console.debug(message, ...args); + } +} + +/** + * A no-op logger that silently discards all log messages. + * + * Useful for testing or when logging should be disabled. + */ +export class NoOpLogger implements Logger { + error(_message: string, ..._args: unknown[]): void { + // No-op + } + + warn(_message: string, ..._args: unknown[]): void { + // No-op + } + + info(_message: string, ..._args: unknown[]): void { + // No-op + } + + debug(_message: string, ..._args: unknown[]): void { + // No-op + } +} diff --git a/packages/durabletask-js/src/utils/backoff.util.ts b/packages/durabletask-js/src/utils/backoff.util.ts new file mode 100644 index 0000000..1f5c639 --- /dev/null +++ b/packages/durabletask-js/src/utils/backoff.util.ts @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Configuration options for exponential backoff. + */ +export interface BackoffOptions { + /** + * Initial delay in milliseconds before the first retry. + * @default 1000 + */ + initialDelayMs?: number; + + /** + * Maximum delay in milliseconds between retries. + * @default 30000 + */ + maxDelayMs?: number; + + /** + * Multiplier applied to the delay after each retry. + * @default 2 + */ + multiplier?: number; + + /** + * Maximum number of retry attempts. Use -1 for unlimited. + * @default -1 + */ + maxAttempts?: number; + + /** + * Optional jitter factor (0-1) to add randomness to delays. + * @default 0.1 + */ + jitterFactor?: number; +} + +/** + * Default backoff configuration values. + */ +export const DEFAULT_BACKOFF_OPTIONS: Required = { + initialDelayMs: 1000, + maxDelayMs: 30000, + multiplier: 2, + maxAttempts: -1, + jitterFactor: 0.1, +}; + +/** + * Implements exponential backoff with jitter for retry scenarios. + * + * @example + * ```typescript + * const backoff = new ExponentialBackoff({ initialDelayMs: 1000, maxDelayMs: 30000 }); + * + * while (shouldRetry) { + * try { + * await doSomething(); + * backoff.reset(); + * break; + * } catch (err) { + * if (!backoff.canRetry()) throw err; + * await backoff.wait(); + * } + * } + * ``` + */ +export class ExponentialBackoff { + private readonly _initialDelayMs: number; + private readonly _maxDelayMs: number; + private readonly _multiplier: number; + private readonly _maxAttempts: number; + private readonly _jitterFactor: number; + + private _currentDelayMs: number; + private _attemptCount: number; + + constructor(options?: BackoffOptions) { + const opts = { ...DEFAULT_BACKOFF_OPTIONS, ...options }; + + this._initialDelayMs = opts.initialDelayMs; + this._maxDelayMs = opts.maxDelayMs; + this._multiplier = opts.multiplier; + this._maxAttempts = opts.maxAttempts; + this._jitterFactor = opts.jitterFactor; + + this._currentDelayMs = this._initialDelayMs; + this._attemptCount = 0; + } + + /** + * Gets the current attempt count. + */ + get attemptCount(): number { + return this._attemptCount; + } + + /** + * Gets the current delay in milliseconds (before jitter is applied). + */ + get currentDelayMs(): number { + return this._currentDelayMs; + } + + /** + * Checks if another retry attempt is allowed. + */ + canRetry(): boolean { + if (this._maxAttempts === -1) { + return true; + } + return this._attemptCount < this._maxAttempts; + } + + /** + * Calculates the next delay with optional jitter. + */ + private _calculateDelayWithJitter(): number { + if (this._jitterFactor <= 0) { + return this._currentDelayMs; + } + + const jitterRange = this._currentDelayMs * this._jitterFactor; + const jitter = Math.random() * jitterRange * 2 - jitterRange; + return Math.max(0, Math.floor(this._currentDelayMs + jitter)); + } + + /** + * Waits for the current backoff delay, then increments the attempt count + * and calculates the next delay. + * + * @returns Promise that resolves after the delay. + */ + async wait(): Promise { + const delay = this._calculateDelayWithJitter(); + + await new Promise((resolve) => setTimeout(resolve, delay)); + + this._attemptCount++; + this._currentDelayMs = Math.min( + this._currentDelayMs * this._multiplier, + this._maxDelayMs, + ); + } + + /** + * Gets the next delay without waiting or incrementing the counter. + */ + peekNextDelay(): number { + return this._calculateDelayWithJitter(); + } + + /** + * Resets the backoff state to initial values. + * Call this after a successful operation. + */ + reset(): void { + this._currentDelayMs = this._initialDelayMs; + this._attemptCount = 0; + } +} + +/** + * Creates a promise that resolves after the specified delay. + * + * @param ms Delay in milliseconds. + * @returns Promise that resolves after the delay. + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Creates a promise that resolves after the specified delay, + * but can be cancelled via an AbortSignal. + * + * @param ms Delay in milliseconds. + * @param signal Optional AbortSignal to cancel the sleep. + * @returns Promise that resolves after the delay or rejects if aborted. + */ +export function sleepWithAbort(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Sleep aborted")); + return; + } + + const onAbort = () => { + clearTimeout(timeoutId); + reject(new Error("Sleep aborted")); + }; + + const timeoutId = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Waits for a promise to resolve with a timeout. + * + * @param promise The promise to wait for. + * @param timeoutMs Maximum time to wait in milliseconds. + * @param timeoutMessage Optional message for the timeout error. + * @returns The resolved value or rejects with a timeout error. + */ +export async function withTimeout( + promise: Promise, + timeoutMs: number, + timeoutMessage = "Operation timed out", +): Promise { + let timeoutId: ReturnType; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutId!); + } +} diff --git a/packages/durabletask-js/src/worker/activity-executor.ts b/packages/durabletask-js/src/worker/activity-executor.ts index 2eb2c87..399ca94 100644 --- a/packages/durabletask-js/src/worker/activity-executor.ts +++ b/packages/durabletask-js/src/worker/activity-executor.ts @@ -3,14 +3,17 @@ import { isPromise } from "util/types"; import { ActivityContext } from "../task/context/activity-context"; +import { Logger, ConsoleLogger } from "../types/logger.type"; import { ActivityNotRegisteredError } from "./exception/activity-not-registered-error"; import { Registry } from "./registry"; export class ActivityExecutor { private _registry: Registry; + private _logger: Logger; - constructor(registry: Registry) { + constructor(registry: Registry, logger?: Logger) { this._registry = registry; + this._logger = logger ?? new ConsoleLogger(); } public async execute( @@ -38,7 +41,7 @@ export class ActivityExecutor { // Return the output const encodedOutput = activityOutput ? JSON.stringify(activityOutput) : undefined; const chars = encodedOutput ? encodedOutput.length : 0; - console.log(`Activity ${name} completed (${chars} chars)`); + this._logger.info(`Activity ${name} completed (${chars} chars)`); return encodedOutput; } diff --git a/packages/durabletask-js/src/worker/index.ts b/packages/durabletask-js/src/worker/index.ts index 8289718..36f8c0b 100644 --- a/packages/durabletask-js/src/worker/index.ts +++ b/packages/durabletask-js/src/worker/index.ts @@ -17,7 +17,6 @@ export function getWrongActionTypeError( action: pb.OrchestratorAction, ): NonDeterminismError { const unexpectedMethodName = getMethodNameForAction(action); - console.log("getWrongActionTypeError"); return new NonDeterminismError( `Failed to restore orchestration state due to a history mismatch: A previous execution called ${expectedMethodName} with ID=${taskId}, but the current execution is instead trying to call ${unexpectedMethodName} as part of rebuilding it's history. This kind of mismatch can happen if an orchestration has non-deterministic logic or if the code was changed after an instance of this orchestration already started running.`, ); diff --git a/packages/durabletask-js/src/worker/orchestration-executor.ts b/packages/durabletask-js/src/worker/orchestration-executor.ts index c4b99b1..f0aa183 100644 --- a/packages/durabletask-js/src/worker/orchestration-executor.ts +++ b/packages/durabletask-js/src/worker/orchestration-executor.ts @@ -9,6 +9,7 @@ import { isSuspendable, } from "."; import * as pb from "../proto/orchestrator_service_pb"; +import { Logger, ConsoleLogger } from "../types/logger.type"; import { getName } from "../task"; import { OrchestrationStateError } from "../task/exception/orchestration-state-error"; import { RetryableTask } from "../task/retryable-task"; @@ -30,16 +31,18 @@ export interface OrchestrationExecutionResult { } export class OrchestrationExecutor { - _generator?: TOrchestrator; - _registry: Registry; - _isSuspended: boolean; - _suspendedEvents: pb.HistoryEvent[]; + private _generator?: TOrchestrator; + private _registry: Registry; + private _isSuspended: boolean; + private _suspendedEvents: pb.HistoryEvent[]; + private _logger: Logger; - constructor(registry: Registry) { + constructor(registry: Registry, logger?: Logger) { this._registry = registry; this._generator = undefined; this._isSuspended = false; this._suspendedEvents = []; + this._logger = logger ?? new ConsoleLogger(); } async execute( @@ -55,7 +58,7 @@ export class OrchestrationExecutor { try { // Rebuild the local state by replaying the history events into the orchestrator function - console.info(`${instanceId}: Rebuilding local state with ${oldEvents.length} history event...`); + this._logger.info(`${instanceId}: Rebuilding local state with ${oldEvents.length} history event...`); ctx._isReplaying = true; for (const oldEvent of oldEvents) { @@ -64,7 +67,7 @@ export class OrchestrationExecutor { // Get new actions by executing newly received events into the orchestrator function const summary = getNewEventSummary(newEvents); - console.info(`${instanceId}: Processing ${newEvents.length} new history event(s): ${summary}`); + this._logger.info(`${instanceId}: Processing ${newEvents.length} new history event(s): ${summary}`); ctx._isReplaying = false; for (const newEvent of newEvents) { @@ -77,17 +80,17 @@ export class OrchestrationExecutor { if (!ctx._isComplete) { const taskCount = Object.keys(ctx._pendingTasks).length; const eventCount = Object.keys(ctx._pendingEvents).length; - console.log(`${instanceId}: Waiting for ${taskCount} task(s) and ${eventCount} event(s) to complete...`); + this._logger.info(`${instanceId}: Waiting for ${taskCount} task(s) and ${eventCount} event(s) to complete...`); } else if ( ctx._completionStatus && ctx._completionStatus !== pb.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW ) { const completionStatusStr = getOrchestrationStatusStr(ctx._completionStatus); - console.log(`${instanceId}: Orchestration completed with status ${completionStatusStr}`); + this._logger.info(`${instanceId}: Orchestration completed with status ${completionStatusStr}`); } const actions = ctx.getActions(); - console.log(`${instanceId}: Returning ${actions.length} action(s)`); + this._logger.info(`${instanceId}: Returning ${actions.length} action(s)`); return { actions, @@ -98,7 +101,7 @@ export class OrchestrationExecutor { private async processEvent(ctx: RuntimeOrchestrationContext, event: pb.HistoryEvent): Promise { // Check if we are suspended to see if we need to buffer the event until we are resumed if (this._isSuspended && isSuspendable(event)) { - console.log("Suspended, buffering event"); + this._logger.info("Suspended, buffering event"); this._suspendedEvents.push(event); return; } @@ -106,7 +109,7 @@ export class OrchestrationExecutor { const eventType = event.getEventtypeCase(); const eventTypeName = enumValueToKey(pb.HistoryEvent.EventtypeCase, eventType); - // console.debug(`DEBUG - Processing event type ${eventTypeName} (${event.getEventtypeCase()})`); + this._logger.debug(`Processing event type ${eventTypeName} (${event.getEventtypeCase()})`); // Process the event type try { @@ -143,7 +146,7 @@ export class OrchestrationExecutor { await ctx.run(result); } else { const resultType = Object.prototype.toString.call(result); - console.log(`An orchestrator was returned that doesn't schedule any tasks (type = ${resultType})`); + this._logger.info(`An orchestrator was returned that doesn't schedule any tasks (type = ${resultType})`); // This is an orchestrator that doesn't schedule any tasks ctx.setComplete(result, pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); @@ -176,7 +179,7 @@ export class OrchestrationExecutor { if (timerId === undefined) { if (!ctx._isReplaying) { - console.warn(`${ctx._instanceId}: Ignoring timerFired event with undefined ID`); + this._logger.warn(`${ctx._instanceId}: Ignoring timerFired event with undefined ID`); } return; } @@ -187,7 +190,7 @@ export class OrchestrationExecutor { if (!timerTask) { // TODO: Should this be an error? When would it ever happen? if (!ctx._isReplaying) { - console.warn(`${ctx._instanceId}: Ignoring unexpected timerFired event with ID = ${timerId}`); + this._logger.warn(`${ctx._instanceId}: Ignoring unexpected timerFired event with ID = ${timerId}`); } return; } @@ -247,7 +250,7 @@ export class OrchestrationExecutor { if (!activityTask) { // TODO: Should this be an error? When would it ever happen? if (!ctx._isReplaying) { - console.warn(`${ctx._instanceId}: Ignoring unexpected taskCompleted event with ID = ${taskId}`); + this._logger.warn(`${ctx._instanceId}: Ignoring unexpected taskCompleted event with ID = ${taskId}`); } return; @@ -270,7 +273,7 @@ export class OrchestrationExecutor { if (taskId === undefined) { if (!ctx._isReplaying) { - console.warn(`${ctx._instanceId}: Ignoring taskFailed event with undefined ID`); + this._logger.warn(`${ctx._instanceId}: Ignoring taskFailed event with undefined ID`); } return; } @@ -283,7 +286,7 @@ export class OrchestrationExecutor { if (!activityTask) { if (!ctx._isReplaying) { - console.warn(`${ctx._instanceId}: Ignoring unexpected taskFailed event with ID = ${taskId}`); + this._logger.warn(`${ctx._instanceId}: Ignoring unexpected taskFailed event with ID = ${taskId}`); } return; } @@ -376,7 +379,7 @@ export class OrchestrationExecutor { if (taskId === undefined) { if (!ctx._isReplaying) { - console.warn(`${ctx._instanceId}: Ignoring subOrchestrationInstanceFailed event with undefined ID`); + this._logger.warn(`${ctx._instanceId}: Ignoring subOrchestrationInstanceFailed event with undefined ID`); } return; } @@ -389,7 +392,7 @@ export class OrchestrationExecutor { if (!subOrchTask) { if (!ctx._isReplaying) { - console.warn( + this._logger.warn( `${ctx._instanceId}: Ignoring unexpected subOrchestrationInstanceFailed event with ID = ${taskId}`, ); } @@ -428,7 +431,7 @@ export class OrchestrationExecutor { const eventName = event.getEventraised()?.getName()?.toLowerCase(); if (!ctx._isReplaying) { - console.log(`${ctx._instanceId}: Event raised: ${eventName}`); + this._logger.info(`${ctx._instanceId}: Event raised: ${eventName}`); } let taskList; @@ -475,7 +478,7 @@ export class OrchestrationExecutor { eventList?.push(decodedResult); if (!ctx._isReplaying) { - console.log( + this._logger.info( `${ctx._instanceId}: Event ${eventName} has been buffered as there are no tasks waiting for it.`, ); } @@ -485,7 +488,7 @@ export class OrchestrationExecutor { case pb.HistoryEvent.EventtypeCase.EXECUTIONSUSPENDED: { if (!this._isSuspended && !ctx._isReplaying) { - console.log(`${ctx._instanceId}: Execution suspended`); + this._logger.info(`${ctx._instanceId}: Execution suspended`); } this._isSuspended = true; @@ -506,7 +509,7 @@ export class OrchestrationExecutor { break; case pb.HistoryEvent.EventtypeCase.EXECUTIONTERMINATED: { if (!ctx._isReplaying) { - console.log(`${ctx._instanceId}: Execution terminated`); + this._logger.info(`${ctx._instanceId}: Execution terminated`); } let encodedOutput; @@ -519,7 +522,7 @@ export class OrchestrationExecutor { break; } default: - console.info(`Unknown history event type: ${eventTypeName} (value: ${eventType}), skipping...`); + this._logger.info(`Unknown history event type: ${eventTypeName} (value: ${eventType}), skipping...`); // throw new OrchestrationStateError(`Unknown history event type: ${eventTypeName} (value: ${eventType})`); } } catch (e: any) { @@ -531,7 +534,7 @@ export class OrchestrationExecutor { // For the rest we don't do anything // Else we throw it upwards - console.error(`Could not process the event ${eventTypeName} due to error ${e}`); + this._logger.error(`Could not process the event ${eventTypeName} due to error ${e}`); throw e; } } diff --git a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts index dc6578f..a98ebd5 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -16,6 +16,11 @@ import { callWithMetadata, MetadataGenerator } from "../utils/grpc-helper.util"; import { OrchestrationExecutor } from "./orchestration-executor"; import { ActivityExecutor } from "./activity-executor"; import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; +import { Logger, ConsoleLogger } from "../types/logger.type"; +import { ExponentialBackoff, sleep, withTimeout } from "../utils/backoff.util"; + +/** Default timeout in milliseconds for graceful shutdown. */ +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30000; export class TaskHubGrpcWorker { private _responseStream: grpc.ClientReadableStream | null; @@ -28,6 +33,10 @@ export class TaskHubGrpcWorker { private _isRunning: boolean; private _stopWorker: boolean; private _stub: stubs.TaskHubSidecarServiceClient | null; + private _logger: Logger; + private _pendingWorkItems: Set>; + private _shutdownTimeoutMs: number; + private _backoff: ExponentialBackoff; /** * Creates a new TaskHubGrpcWorker instance. @@ -37,6 +46,8 @@ export class TaskHubGrpcWorker { * @param useTLS Whether to use TLS. Defaults to false. * @param credentials Optional pre-configured channel credentials. If provided, useTLS is ignored. * @param metadataGenerator Optional function to generate per-call metadata (for taskhub, auth tokens, etc.). + * @param logger Optional logger instance. Defaults to ConsoleLogger. + * @param shutdownTimeoutMs Optional timeout in milliseconds for graceful shutdown. Defaults to 30000. */ constructor( hostAddress?: string, @@ -44,6 +55,8 @@ export class TaskHubGrpcWorker { useTLS?: boolean, credentials?: grpc.ChannelCredentials, metadataGenerator?: MetadataGenerator, + logger?: Logger, + shutdownTimeoutMs?: number, ) { this._registry = new Registry(); this._hostAddress = hostAddress; @@ -55,6 +68,14 @@ export class TaskHubGrpcWorker { this._isRunning = false; this._stopWorker = false; this._stub = null; + this._logger = logger ?? new ConsoleLogger(); + this._pendingWorkItems = new Set(); + this._shutdownTimeoutMs = shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS; + this._backoff = new ExponentialBackoff({ + initialDelayMs: 1000, + maxDelayMs: 30000, + multiplier: 2, + }); } /** @@ -148,46 +169,58 @@ export class TaskHubGrpcWorker { // send a "Hello" message to the sidecar to ensure that it's listening await callWithMetadata(client.stub.hello.bind(client.stub), new Empty(), this._metadataGenerator); + // Reset backoff on successful connection + this._backoff.reset(); + // Stream work items from the sidecar (pass metadata for insecure connections) const metadata = await this._getMetadata(); const stream = client.stub.getWorkItems(new pb.GetWorkItemsRequest(), metadata); this._responseStream = stream; - console.log(`Successfully connected to ${this._hostAddress}. Waiting for work items...`); + this._logger.info(`Successfully connected to ${this._hostAddress}. Waiting for work items...`); // Wait for a work item to be received stream.on("data", (workItem: pb.WorkItem) => { const completionToken = workItem.getCompletiontoken(); if (workItem.hasOrchestratorrequest()) { - console.log( + this._logger.info( `Received "Orchestrator Request" work item with instance id '${workItem ?.getOrchestratorrequest() ?.getInstanceid()}'`, ); this._executeOrchestrator(workItem.getOrchestratorrequest() as any, completionToken, client.stub); } else if (workItem.hasActivityrequest()) { - console.log(`Received "Activity Request" work item`); + this._logger.info(`Received "Activity Request" work item`); this._executeActivity(workItem.getActivityrequest() as any, completionToken, client.stub); } else if (workItem.hasHealthping()) { // Health ping - no-op, just a keep-alive message from the server } else { - console.log(`Received unknown type of work item `); + this._logger.info(`Received unknown type of work item `); } }); // Wait for the stream to end or error stream.on("end", async () => { + // Clean up event listeners to prevent memory leaks + stream.removeAllListeners(); stream.cancel(); stream.destroy(); if (this._stopWorker) { - console.log("Stream ended"); + this._logger.info("Stream ended"); return; } - console.log("Stream abruptly closed, will retry the connection..."); - // TODO consider exponential backoff - await sleep(5000); + this._logger.info(`Stream abruptly closed, will retry in ${this._backoff.peekNextDelay()}ms...`); + await this._backoff.wait(); + // Create a new client for the retry to avoid stale channel issues + const newClient = new GrpcClient( + this._hostAddress, + this._grpcChannelOptions, + this._tls, + this._grpcChannelCredentials, + ); + this._stub = newClient.stub; // do not await - this.internalRunWorker(client, true); + this.internalRunWorker(newClient, true); }); stream.on("error", (err: Error) => { @@ -195,27 +228,35 @@ export class TaskHubGrpcWorker { if (this._stopWorker) { return; } - console.log("Stream error", err); + this._logger.info("Stream error", err); }); } catch (err) { if (this._stopWorker) { // ignoring the error because the worker has been stopped return; } - console.log(`Error on grpc stream: ${err}`); + this._logger.error(`Error on grpc stream: ${err}`); if (!isRetry) { throw err; } - console.log("Connection will be retried..."); - // TODO consider exponential backoff - await sleep(5000); - this.internalRunWorker(client, true); + this._logger.info(`Connection will be retried in ${this._backoff.peekNextDelay()}ms...`); + await this._backoff.wait(); + // Create a new client for the retry + const newClient = new GrpcClient( + this._hostAddress, + this._grpcChannelOptions, + this._tls, + this._grpcChannelCredentials, + ); + this._stub = newClient.stub; + this.internalRunWorker(newClient, true); return; } } /** - * Stop the worker and wait for any pending work items to complete + * Stop the worker and wait for any pending work items to complete. + * Uses a configurable timeout (default 30s) to wait for in-flight work. */ async stop(): Promise { if (!this._isRunning) { @@ -224,22 +265,53 @@ export class TaskHubGrpcWorker { this._stopWorker = true; + // Clean up stream listeners to prevent memory leaks + this._responseStream?.removeAllListeners(); this._responseStream?.cancel(); this._responseStream?.destroy(); - this._stub?.close(); + // Wait for pending work items to complete with timeout + if (this._pendingWorkItems.size > 0) { + this._logger.info(`Waiting for ${this._pendingWorkItems.size} pending work item(s) to complete...`); + try { + await withTimeout( + Promise.all(this._pendingWorkItems), + this._shutdownTimeoutMs, + `Shutdown timed out after ${this._shutdownTimeoutMs}ms waiting for pending work items`, + ); + this._logger.info("All pending work items completed."); + } catch (e) { + this._logger.warn(`${(e as Error).message}. Forcing shutdown.`); + } + } + this._stub?.close(); this._isRunning = false; - // Wait a bit to let the async operations finish + // Brief pause to allow gRPC cleanup // https://github.com/grpc/grpc-node/issues/1563#issuecomment-829483711 - await sleep(1000); + await sleep(500); } /** - * + * Executes an orchestrator request and tracks it as a pending work item. */ - private async _executeOrchestrator( + private _executeOrchestrator( + req: pb.OrchestratorRequest, + completionToken: string, + stub: stubs.TaskHubSidecarServiceClient, + ): void { + const workPromise = this._executeOrchestratorInternal(req, completionToken, stub); + this._pendingWorkItems.add(workPromise); + workPromise.finally(() => { + this._pendingWorkItems.delete(workPromise); + }); + } + + /** + * Internal implementation of orchestrator execution. + */ + private async _executeOrchestratorInternal( req: pb.OrchestratorRequest, completionToken: string, stub: stubs.TaskHubSidecarServiceClient, @@ -253,7 +325,7 @@ export class TaskHubGrpcWorker { let res; try { - const executor = new OrchestrationExecutor(this._registry); + const executor = new OrchestrationExecutor(this._registry, this._logger); const result = await executor.execute(req.getInstanceid(), req.getPasteventsList(), req.getNeweventsList()); res = new pb.OrchestratorResponse(); @@ -264,8 +336,8 @@ export class TaskHubGrpcWorker { res.setCustomstatus(pbh.getStringValue(result.customStatus)); } } catch (e: any) { - console.error(e); - console.log(`An error occurred while trying to execute instance '${req.getInstanceid()}': ${e.message}`); + this._logger.error(e); + this._logger.info(`An error occurred while trying to execute instance '${req.getInstanceid()}': ${e.message}`); const failureDetails = pbh.newFailureDetails(e); @@ -286,14 +358,29 @@ export class TaskHubGrpcWorker { try { await callWithMetadata(stub.completeOrchestratorTask.bind(stub), res, this._metadataGenerator); } catch (e: any) { - console.error(`An error occurred while trying to complete instance '${req.getInstanceid()}': ${e?.message}`); + this._logger.error(`An error occurred while trying to complete instance '${req.getInstanceid()}': ${e?.message}`); } } /** - * + * Executes an activity request and tracks it as a pending work item. + */ + private _executeActivity( + req: pb.ActivityRequest, + completionToken: string, + stub: stubs.TaskHubSidecarServiceClient, + ): void { + const workPromise = this._executeActivityInternal(req, completionToken, stub); + this._pendingWorkItems.add(workPromise); + workPromise.finally(() => { + this._pendingWorkItems.delete(workPromise); + }); + } + + /** + * Internal implementation of activity execution. */ - private async _executeActivity( + private async _executeActivityInternal( req: pb.ActivityRequest, completionToken: string, stub: stubs.TaskHubSidecarServiceClient, @@ -307,7 +394,7 @@ export class TaskHubGrpcWorker { let res; try { - const executor = new ActivityExecutor(this._registry); + const executor = new ActivityExecutor(this._registry, this._logger); const result = await executor.execute( instanceId, req.getName(), @@ -324,8 +411,8 @@ export class TaskHubGrpcWorker { res.setCompletiontoken(completionToken); res.setResult(s); } catch (e: any) { - console.error(e); - console.log(`An error occurred while trying to execute activity '${req.getName()}': ${e.message}`); + this._logger.error(e); + this._logger.info(`An error occurred while trying to execute activity '${req.getName()}': ${e.message}`); const failureDetails = pbh.newFailureDetails(e); @@ -338,7 +425,7 @@ export class TaskHubGrpcWorker { try { await callWithMetadata(stub.completeActivityTask.bind(stub), res, this._metadataGenerator); } catch (e: any) { - console.error( + this._logger.error( `Failed to deliver activity response for '${req.getName()}#${req.getTaskid()}' of orchestration ID '${instanceId}' to sidecar: ${ e?.message }`, @@ -346,7 +433,3 @@ export class TaskHubGrpcWorker { } } } - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/packages/durabletask-js/test/activity_executor.spec.ts b/packages/durabletask-js/test/activity_executor.spec.ts index 3e9c400..cbf8236 100644 --- a/packages/durabletask-js/test/activity_executor.spec.ts +++ b/packages/durabletask-js/test/activity_executor.spec.ts @@ -3,10 +3,14 @@ import { ActivityContext } from "../src/task/context/activity-context"; import { TActivity } from "../src/types/activity.type"; +import { NoOpLogger } from "../src/types/logger.type"; import { ActivityExecutor } from "../src/worker/activity-executor"; import { ActivityNotRegisteredError } from "../src/worker/exception/activity-not-registered-error"; import { Registry } from "../src/worker/registry"; +// Use NoOpLogger to suppress log output during tests +const testLogger = new NoOpLogger(); + // const TEST_LOGGER = shared.get_logger(); const TEST_INSTANCE_ID = "abc123"; const TEST_TASK_ID = 42; @@ -42,11 +46,9 @@ describe("Activity Executor", () => { try { await executor.execute(TEST_INSTANCE_ID, "Bogus", TEST_TASK_ID, undefined); } catch (ex: any) { - console.log(ex); caughtException = ex; } - console.log(caughtException); expect(caughtException?.constructor?.name).toEqual(ActivityNotRegisteredError.name); expect(caughtException).not.toBeNull(); expect(caughtException?.message).toMatch(/Bogus/); @@ -57,6 +59,6 @@ describe("Activity Executor", () => { function getActivityExecutor(fn: TActivity): [ActivityExecutor, string] { const registry = new Registry(); const name = registry.addActivity(fn); - const executor = new ActivityExecutor(registry); + const executor = new ActivityExecutor(registry, testLogger); return [executor, name]; } diff --git a/packages/durabletask-js/test/backoff.spec.ts b/packages/durabletask-js/test/backoff.spec.ts new file mode 100644 index 0000000..f9c92fa --- /dev/null +++ b/packages/durabletask-js/test/backoff.spec.ts @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { ExponentialBackoff, sleep, withTimeout, DEFAULT_BACKOFF_OPTIONS } from "../src/utils/backoff.util"; + +describe("ExponentialBackoff", () => { + describe("constructor", () => { + it("should use default options when none provided", () => { + const backoff = new ExponentialBackoff(); + expect(backoff.attemptCount).toBe(0); + expect(backoff.currentDelayMs).toBe(DEFAULT_BACKOFF_OPTIONS.initialDelayMs); + }); + + it("should accept custom options", () => { + const backoff = new ExponentialBackoff({ + initialDelayMs: 500, + maxDelayMs: 10000, + multiplier: 3, + maxAttempts: 5, + }); + expect(backoff.currentDelayMs).toBe(500); + }); + }); + + describe("canRetry", () => { + it("should return true when maxAttempts is -1 (unlimited)", () => { + const backoff = new ExponentialBackoff({ maxAttempts: -1 }); + expect(backoff.canRetry()).toBe(true); + }); + + it("should return true when attempts are less than maxAttempts", () => { + const backoff = new ExponentialBackoff({ maxAttempts: 3 }); + expect(backoff.canRetry()).toBe(true); + }); + + it("should return false when attempts reach maxAttempts", async () => { + const backoff = new ExponentialBackoff({ + maxAttempts: 1, + initialDelayMs: 1, + jitterFactor: 0, + }); + await backoff.wait(); + expect(backoff.canRetry()).toBe(false); + }); + }); + + describe("wait", () => { + it("should increment attempt count after waiting", async () => { + const backoff = new ExponentialBackoff({ + initialDelayMs: 1, + jitterFactor: 0, + }); + expect(backoff.attemptCount).toBe(0); + await backoff.wait(); + expect(backoff.attemptCount).toBe(1); + }); + + it("should increase delay with multiplier", async () => { + const backoff = new ExponentialBackoff({ + initialDelayMs: 10, + multiplier: 2, + maxDelayMs: 1000, + jitterFactor: 0, + }); + + expect(backoff.currentDelayMs).toBe(10); + await backoff.wait(); + expect(backoff.currentDelayMs).toBe(20); + await backoff.wait(); + expect(backoff.currentDelayMs).toBe(40); + }); + + it("should cap delay at maxDelayMs", async () => { + const backoff = new ExponentialBackoff({ + initialDelayMs: 50, + multiplier: 10, + maxDelayMs: 100, + jitterFactor: 0, + }); + + await backoff.wait(); + expect(backoff.currentDelayMs).toBe(100); // 50 * 10 = 500, capped at 100 + }); + }); + + describe("reset", () => { + it("should reset delay and attempt count to initial values", async () => { + const backoff = new ExponentialBackoff({ + initialDelayMs: 10, + multiplier: 2, + jitterFactor: 0, + }); + + await backoff.wait(); + await backoff.wait(); + + expect(backoff.attemptCount).toBe(2); + expect(backoff.currentDelayMs).toBe(40); + + backoff.reset(); + + expect(backoff.attemptCount).toBe(0); + expect(backoff.currentDelayMs).toBe(10); + }); + }); + + describe("peekNextDelay", () => { + it("should return delay without modifying state", () => { + const backoff = new ExponentialBackoff({ + initialDelayMs: 100, + jitterFactor: 0, + }); + + const delay1 = backoff.peekNextDelay(); + const delay2 = backoff.peekNextDelay(); + + expect(delay1).toBe(100); + expect(delay2).toBe(100); + expect(backoff.attemptCount).toBe(0); + }); + }); + + describe("jitter", () => { + it("should apply jitter within expected range", () => { + const backoff = new ExponentialBackoff({ + initialDelayMs: 100, + jitterFactor: 0.5, + }); + + // Run multiple times to statistically verify jitter + const delays: number[] = []; + for (let i = 0; i < 20; i++) { + delays.push(backoff.peekNextDelay()); + } + + // With 0.5 jitter factor on 100ms, delays should be between 50 and 150 + const minExpected = 100 - 100 * 0.5; + const maxExpected = 100 + 100 * 0.5; + + for (const delay of delays) { + expect(delay).toBeGreaterThanOrEqual(minExpected); + expect(delay).toBeLessThanOrEqual(maxExpected); + } + + // Verify there's some variation (not all the same) + const uniqueDelays = new Set(delays); + expect(uniqueDelays.size).toBeGreaterThan(1); + }); + }); +}); + +describe("sleep", () => { + it("should resolve after specified delay", async () => { + const start = Date.now(); + await sleep(50); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(40); // Allow some timing variance + expect(elapsed).toBeLessThan(150); + }); +}); + +describe("withTimeout", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should resolve with value when promise completes before timeout", async () => { + const promise = withTimeout(Promise.resolve("success"), 1000); + const result = await promise; + expect(result).toBe("success"); + }); + + it("should reject with timeout error when promise takes too long", async () => { + const slowPromise = new Promise((resolve) => setTimeout(() => resolve("slow"), 500)); + const promise = withTimeout(slowPromise, 10); + jest.advanceTimersByTime(10); + await expect(promise).rejects.toThrow("Operation timed out"); + }); + + it("should use custom timeout message", async () => { + const slowPromise = new Promise((resolve) => setTimeout(() => resolve("slow"), 500)); + const promise = withTimeout(slowPromise, 10, "Custom timeout"); + jest.advanceTimersByTime(10); + await expect(promise).rejects.toThrow("Custom timeout"); + }); + + it("should propagate promise rejection", async () => { + const failingPromise = Promise.reject(new Error("Promise failed")); + await expect(withTimeout(failingPromise, 1000)).rejects.toThrow("Promise failed"); + }); +}); diff --git a/packages/durabletask-js/test/console-logger.spec.ts b/packages/durabletask-js/test/console-logger.spec.ts new file mode 100644 index 0000000..d0b0d14 --- /dev/null +++ b/packages/durabletask-js/test/console-logger.spec.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { ConsoleLogger } from "../src/types/logger.type"; + +describe("ConsoleLogger", () => { + let logger: ConsoleLogger; + + beforeEach(() => { + logger = new ConsoleLogger(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("error", () => { + it("should call console.error with message", () => { + const spy = jest.spyOn(console, "error").mockImplementation(); + + logger.error("test error message"); + + expect(spy).toHaveBeenCalledWith("test error message"); + }); + + it("should call console.error with message and additional args", () => { + const spy = jest.spyOn(console, "error").mockImplementation(); + + logger.error("test error", { instanceId: "123" }, "extra"); + + expect(spy).toHaveBeenCalledWith("test error", { instanceId: "123" }, "extra"); + }); + }); + + describe("warn", () => { + it("should call console.warn with message", () => { + const spy = jest.spyOn(console, "warn").mockImplementation(); + + logger.warn("test warning message"); + + expect(spy).toHaveBeenCalledWith("test warning message"); + }); + + it("should call console.warn with message and additional args", () => { + const spy = jest.spyOn(console, "warn").mockImplementation(); + + logger.warn("test warning", 42, { key: "value" }); + + expect(spy).toHaveBeenCalledWith("test warning", 42, { key: "value" }); + }); + }); + + describe("info", () => { + it("should call console.info with message", () => { + const spy = jest.spyOn(console, "info").mockImplementation(); + + logger.info("test info message"); + + expect(spy).toHaveBeenCalledWith("test info message"); + }); + + it("should call console.info with message and additional args", () => { + const spy = jest.spyOn(console, "info").mockImplementation(); + + logger.info("test info", ["array", "data"]); + + expect(spy).toHaveBeenCalledWith("test info", ["array", "data"]); + }); + }); + + describe("debug", () => { + it("should call console.debug with message", () => { + const spy = jest.spyOn(console, "debug").mockImplementation(); + + logger.debug("test debug message"); + + expect(spy).toHaveBeenCalledWith("test debug message"); + }); + + it("should call console.debug with message and additional args", () => { + const spy = jest.spyOn(console, "debug").mockImplementation(); + + logger.debug("debug data", { nested: { value: true } }); + + expect(spy).toHaveBeenCalledWith("debug data", { nested: { value: true } }); + }); + }); + + describe("Logger interface", () => { + it("should implement Logger interface correctly", () => { + expect(typeof logger.error).toBe("function"); + expect(typeof logger.warn).toBe("function"); + expect(typeof logger.info).toBe("function"); + expect(typeof logger.debug).toBe("function"); + }); + }); +}); diff --git a/packages/durabletask-js/test/noop-logger.spec.ts b/packages/durabletask-js/test/noop-logger.spec.ts new file mode 100644 index 0000000..c2a1cc0 --- /dev/null +++ b/packages/durabletask-js/test/noop-logger.spec.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { NoOpLogger } from "../src/types/logger.type"; + +describe("NoOpLogger", () => { + let logger: NoOpLogger; + + beforeEach(() => { + logger = new NoOpLogger(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("error", () => { + it("should not throw when called", () => { + expect(() => logger.error("test error")).not.toThrow(); + }); + + it("should not call console.error", () => { + const spy = jest.spyOn(console, "error").mockImplementation(); + + logger.error("test error", { instanceId: "123" }); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe("warn", () => { + it("should not throw when called", () => { + expect(() => logger.warn("test warning")).not.toThrow(); + }); + + it("should not call console.warn", () => { + const spy = jest.spyOn(console, "warn").mockImplementation(); + + logger.warn("test warning", 42); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe("info", () => { + it("should not throw when called", () => { + expect(() => logger.info("test info")).not.toThrow(); + }); + + it("should not call console.info", () => { + const spy = jest.spyOn(console, "info").mockImplementation(); + + logger.info("test info", ["data"]); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe("debug", () => { + it("should not throw when called", () => { + expect(() => logger.debug("test debug")).not.toThrow(); + }); + + it("should not call console.debug", () => { + const spy = jest.spyOn(console, "debug").mockImplementation(); + + logger.debug("test debug", { nested: true }); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe("Logger interface", () => { + it("should implement Logger interface correctly", () => { + expect(typeof logger.error).toBe("function"); + expect(typeof logger.warn).toBe("function"); + expect(typeof logger.info).toBe("function"); + expect(typeof logger.debug).toBe("function"); + }); + }); + + describe("use case", () => { + it("should be usable as a silent logger for testing", () => { + const silentLogger = new NoOpLogger(); + + // All these should execute without any side effects + silentLogger.error("This error is silently discarded"); + silentLogger.warn("This warning is silently discarded"); + silentLogger.info("This info is silently discarded"); + silentLogger.debug("This debug is silently discarded"); + + // If we got here, the test passes + expect(true).toBe(true); + }); + }); +}); diff --git a/packages/durabletask-js/test/orchestration_executor.spec.ts b/packages/durabletask-js/test/orchestration_executor.spec.ts index 611b2aa..5f07cff 100644 --- a/packages/durabletask-js/test/orchestration_executor.spec.ts +++ b/packages/durabletask-js/test/orchestration_executor.spec.ts @@ -19,15 +19,19 @@ import { newTimerCreatedEvent, newTimerFiredEvent, } from "../src/utils/pb-helper.util"; -import { OrchestrationExecutor, OrchestrationExecutionResult } from "../src/worker/orchestration-executor"; +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 { CompletableTask } from "../src/task/completable-task"; import { Task } from "../src/task/task"; import { getName, whenAll, whenAny } from "../src/task"; +// Use NoOpLogger to suppress log output during tests +const testLogger = new NoOpLogger(); + const TEST_INSTANCE_ID = "abc123"; describe("Orchestration Executor", () => { @@ -45,9 +49,9 @@ describe("Orchestration Executor", () => { newOrchestratorStartedEvent(startTime), newExecutionStartedEvent(name, TEST_INSTANCE_ID, JSON.stringify(testInput)), ]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()).not.toBeNull(); const expectedOutput = [testInput, TEST_INSTANCE_ID, startTime.toISOString(), false]; @@ -61,9 +65,9 @@ describe("Orchestration Executor", () => { const registry = new Registry(); const name = registry.addOrchestrator(emptyOrchestrator); const newEvents = [newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()).not.toBeNull(); expect(completeAction?.getResult()?.getValue()).toEqual('"done"'); @@ -72,9 +76,9 @@ describe("Orchestration Executor", () => { const registry = new Registry(); const name = "Bogus"; const newEvents = [newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("OrchestratorNotRegisteredError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).not.toBeNull(); @@ -94,13 +98,13 @@ describe("Orchestration Executor", () => { newOrchestratorStartedEvent(startTime), newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined), ]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); - expect(result.actions).not.toBeNull(); - expect(result.actions.length).toEqual(1); - expect(result.actions[0]?.constructor?.name).toEqual(OrchestratorAction.name); - expect(result.actions[0]?.getId()).toEqual(1); - expect(result.actions[0]?.getCreatetimer()?.getFireat()?.toDate()).toEqual(expectedFireAt); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + expect(actions).not.toBeNull(); + expect(actions.length).toEqual(1); + expect(actions[0]?.constructor?.name).toEqual(OrchestratorAction.name); + expect(actions[0]?.getId()).toEqual(1); + expect(actions[0]?.getCreatetimer()?.getFireat()?.toDate()).toEqual(expectedFireAt); }); it("should test the resumption of a task using a timerFired event", async () => { const delayOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, _: any): any { @@ -119,9 +123,9 @@ describe("Orchestration Executor", () => { newTimerCreatedEvent(1, expectedFireAt), ]; const newEvents = [newTimerFiredEvent(1, expectedFireAt)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()).not.toBeNull(); expect(completeAction?.getResult()?.getValue()).toEqual('"done"'); @@ -137,13 +141,13 @@ describe("Orchestration Executor", () => { const registry = new Registry(); const name = registry.addOrchestrator(orchestrator); const newEvents = [newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); - expect(result.actions).not.toBeNull(); - expect(result.actions.length).toEqual(1); - expect(result.actions[0]?.constructor?.name).toEqual(OrchestratorAction.name); - expect(result.actions[0]?.getId()).toEqual(1); - expect(result.actions[0]?.getScheduletask()?.getName()).toEqual("dummyActivity"); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + expect(actions).not.toBeNull(); + expect(actions.length).toEqual(1); + expect(actions[0]?.constructor?.name).toEqual(OrchestratorAction.name); + expect(actions[0]?.getId()).toEqual(1); + expect(actions[0]?.getScheduletask()?.getName()).toEqual("dummyActivity"); }); it("should test the successful completion of an activity task", async () => { const dummyActivity = async (_: ActivityContext) => { @@ -162,10 +166,9 @@ describe("Orchestration Executor", () => { ]; const encodedOutput = JSON.stringify("done!"); const newEvents = [newTaskCompletedEvent(1, encodedOutput)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); - console.log(completeAction?.getFailuredetails()); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual(encodedOutput); }); @@ -186,10 +189,9 @@ describe("Orchestration Executor", () => { ]; const encodedOutput = JSON.stringify("done!"); const newEvents = [newTaskCompletedEvent(1, encodedOutput)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); - console.log(completeAction?.getFailuredetails()); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual(encodedOutput); }); @@ -210,9 +212,9 @@ describe("Orchestration Executor", () => { ]; const ex = new Error("Kah-BOOOOM!!!"); const newEvents = [newTaskFailedEvent(1, ex)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("TaskFailedError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain(ex.message); @@ -240,9 +242,9 @@ describe("Orchestration Executor", () => { newTimerCreatedEvent(1, fireAt), ]; const newEvents = [newTimerFiredEvent(1, fireAt)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -262,9 +264,9 @@ describe("Orchestration Executor", () => { newTaskScheduledEvent(1, "bogusActivity"), ]; const newEvents = [newTaskCompletedEvent(1, "done!")]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -286,9 +288,9 @@ describe("Orchestration Executor", () => { newTaskScheduledEvent(1, getName(dummyActivity)), ]; const newEvents = [newTaskCompletedEvent(1)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -311,9 +313,9 @@ describe("Orchestration Executor", () => { newTaskScheduledEvent(1, "originalActivity"), ]; const newEvents = [newTaskCompletedEvent(1)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -338,9 +340,9 @@ describe("Orchestration Executor", () => { newSubOrchestrationCreatedEvent(1, subOrchestratorName, "sub-orch-123"), ]; const newEvents = [newSubOrchestrationCompletedEvent(1, "42")]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual("42"); }); @@ -362,9 +364,9 @@ describe("Orchestration Executor", () => { ]; const ex = new Error("Kah-BOOOOM!!!"); const newEvents = [newSubOrchestrationFailedEvent(1, ex)]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("TaskFailedError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain(ex.message); @@ -386,9 +388,9 @@ describe("Orchestration Executor", () => { newSubOrchestrationCreatedEvent(1, "some_sub_orchestration", "sub-orch-123"), ]; const newEvents = [newSubOrchestrationCompletedEvent(1, "42")]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -416,9 +418,9 @@ describe("Orchestration Executor", () => { newSubOrchestrationCreatedEvent(1, "some_sub_orchestration", "sub-orch-123"), ]; const newEvents = [newSubOrchestrationCompletedEvent(1, "42")]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -443,18 +445,18 @@ describe("Orchestration Executor", () => { // Execute the orchestration until it is waiting for an external event. // The result should be an empty list of actions because the orchestration didn't schedule any work - let executor = new OrchestrationExecutor(registry); - let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - expect(result.actions.length).toBe(0); + let executor = new OrchestrationExecutor(registry, testLogger); + let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + expect(actions.length).toBe(0); // Now send an external event to the orchestration and execute it again. // This time the orcehstration should complete oldEvents = newEvents; newEvents = [newEventRaisedEvent("my_event", "42")]; - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual("42"); }); @@ -478,10 +480,10 @@ describe("Orchestration Executor", () => { // Execute the orchestration // It should be in a running state waiting for the timer to fire - let executor = new OrchestrationExecutor(registry); - let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - expect(result.actions.length).toBe(1); - expect(result.actions[0].hasCreatetimer()).toBeTruthy(); + let executor = new OrchestrationExecutor(registry, testLogger); + let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + expect(actions.length).toBe(1); + expect(actions[0].hasCreatetimer()).toBeTruthy(); // Complete the timer task // The orchestration should move to the waitForExternalEvent step now which should @@ -490,10 +492,10 @@ describe("Orchestration Executor", () => { newEvents.push(newTimerCreatedEvent(1, timerDueTime)); oldEvents = newEvents; newEvents = [newTimerFiredEvent(1, timerDueTime)]; - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual("42"); }); @@ -514,17 +516,17 @@ describe("Orchestration Executor", () => { // Execute the orchestration // It should be in a running state because it was suspended prior // to the processing the event raised event - let executor = new OrchestrationExecutor(registry); - let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - expect(result.actions.length).toBe(0); + let executor = new OrchestrationExecutor(registry, testLogger); + let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + expect(actions.length).toBe(0); // Resume the orchestration, it should complete successfully oldEvents.push(...newEvents); newEvents = [newResumeEvent()]; - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual("42"); }); @@ -544,12 +546,12 @@ describe("Orchestration Executor", () => { // Execute the orchestration // It should be in a running state waiting for an external event - let executor = new OrchestrationExecutor(registry); - let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + let executor = new OrchestrationExecutor(registry, testLogger); + let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_TERMINATED); expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify("terminated!")); }); @@ -576,10 +578,10 @@ describe("Orchestration Executor", () => { const newEvents = [newTimerFiredEvent(1, new Date(Date.now() + 1 * 24 * 60 * 60 * 1000))]; // Execute the orchestration, it should be in a running state waiting for the timer to fire - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual( pb.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW, ); @@ -625,16 +627,16 @@ describe("Orchestration Executor", () => { newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID, "10"), ]; - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); // The result should be 10 "taskScheduled" actions with inputs from 0 to 9 - expect(result.actions.length).toEqual(10); + expect(actions.length).toEqual(10); for (let i = 0; i < 10; i++) { - expect(result.actions[i].hasScheduletask()); - expect(result.actions[i].getScheduletask()?.getName()).toEqual(activityName); - expect(result.actions[i].getScheduletask()?.getInput()?.getValue()).toEqual(`"${i}"`); + expect(actions[i].hasScheduletask()); + expect(actions[i].getScheduletask()?.getName()).toEqual(activityName); + expect(actions[i].getScheduletask()?.getInput()?.getValue()).toEqual(`"${i}"`); } }); @@ -673,16 +675,16 @@ describe("Orchestration Executor", () => { // First, test with only the first 5 events // we expect the orchestrator to be running // it should however return 0 actions, since it is still waiting for the other 5 tasks to complete - let executor = new OrchestrationExecutor(registry); - let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents.slice(0, 4)); - expect(result.actions.length).toBe(0); + let executor = new OrchestrationExecutor(registry, testLogger); + let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents.slice(0, 4)); + expect(actions.length).toBe(0); // Now test with the full set of new events // we expect the orchestration to complete - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual("[0,1,2,3,4,5,6,7,8,9]"); }); @@ -726,10 +728,10 @@ describe("Orchestration Executor", () => { // Now test with the full set of new events // We expect the orchestration to complete - const executor = new OrchestrationExecutor(registry); - const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const executor = new OrchestrationExecutor(registry, testLogger); + const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("TaskFailedError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain(ex.message); @@ -761,12 +763,12 @@ describe("Orchestration Executor", () => { // this should return 2 actions: a Tokyo Task Schedule and a Seattle Task Schedule let oldEvents: any[] = []; let newEvents = [newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID)]; - let executor = new OrchestrationExecutor(registry); - let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + let executor = new OrchestrationExecutor(registry, testLogger); + let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - expect(result.actions.length).toEqual(2); - expect(result.actions[0].hasScheduletask()).toBeTruthy(); - expect(result.actions[1].hasScheduletask()).toBeTruthy(); + expect(actions.length).toEqual(2); + expect(actions[0].hasScheduletask()).toBeTruthy(); + expect(actions[1].hasScheduletask()).toBeTruthy(); // The next tests assume that the orchestration has already await at the task.whenAny oldEvents = [ @@ -780,9 +782,9 @@ describe("Orchestration Executor", () => { // the orchestration should now complete with "Hello Tokyo!" let encodedOutput = JSON.stringify(hello(null, "Tokyo")); newEvents = [newTaskCompletedEvent(1, encodedOutput)]; - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - let completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + let completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual(encodedOutput); @@ -790,9 +792,9 @@ describe("Orchestration Executor", () => { // the orchestration should now complete with "Hello Tokyo!" encodedOutput = JSON.stringify(hello(null, "Seattle")); newEvents = [newTaskCompletedEvent(2, encodedOutput)]; - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual(encodedOutput); }); @@ -817,32 +819,32 @@ describe("Orchestration Executor", () => { const name = registry.addOrchestrator(orchestrator); // Act - Step 1: Start orchestration - let executor = new OrchestrationExecutor(registry); + let executor = new OrchestrationExecutor(registry, testLogger); let newEvents = [ newOrchestratorStartedEvent(), newExecutionStartedEvent(name, TEST_INSTANCE_ID, JSON.stringify(21)), ]; - let result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, [], newEvents); // Assert - Step 1: Should schedule activity - expect(result.actions.length).toBe(1); - expect(result.actions[0].hasScheduletask()).toBe(true); - expect(result.actions[0].getScheduletask()?.getName()).toBe("flakyActivity"); + expect(actions.length).toBe(1); + expect(actions[0].hasScheduletask()).toBe(true); + expect(actions[0].getScheduletask()?.getName()).toBe("flakyActivity"); // Act - Step 2: Activity scheduled, then fails const oldEvents = [ ...newEvents, newTaskScheduledEvent(1, "flakyActivity"), ]; - executor = new OrchestrationExecutor(registry); + executor = new OrchestrationExecutor(registry, testLogger); newEvents = [ newTaskFailedEvent(1, new Error("Transient failure on attempt 1")), ]; - result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); // Assert - Step 2: Should schedule a retry timer - expect(result.actions.length).toBe(1); - expect(result.actions[0].hasCreatetimer()).toBe(true); + expect(actions.length).toBe(1); + expect(actions[0].hasCreatetimer()).toBe(true); }); it("should complete successfully after retry timer fires and activity succeeds", async () => { @@ -864,49 +866,49 @@ describe("Orchestration Executor", () => { const startTime = new Date(); // Step 1: Start orchestration - let executor = new OrchestrationExecutor(registry); + let executor = new OrchestrationExecutor(registry, testLogger); const allEvents = [ newOrchestratorStartedEvent(startTime), newExecutionStartedEvent(name, TEST_INSTANCE_ID, JSON.stringify(21)), ]; - let result = await executor.execute(TEST_INSTANCE_ID, [], allEvents); - expect(result.actions.length).toBe(1); - expect(result.actions[0].hasScheduletask()).toBe(true); + let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, [], allEvents); + expect(actions.length).toBe(1); + expect(actions[0].hasScheduletask()).toBe(true); // Step 2: Activity scheduled, then fails allEvents.push(newTaskScheduledEvent(1, "flakyActivity")); - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ newTaskFailedEvent(1, new Error("Transient failure on attempt 1")), ]); - expect(result.actions.length).toBe(1); - expect(result.actions[0].hasCreatetimer()).toBe(true); - const timerFireAt = result.actions[0].getCreatetimer()?.getFireat()?.toDate(); + expect(actions.length).toBe(1); + expect(actions[0].hasCreatetimer()).toBe(true); + const timerFireAt = actions[0].getCreatetimer()?.getFireat()?.toDate(); expect(timerFireAt).toBeDefined(); // Step 3: Timer created, then fires allEvents.push(newTaskFailedEvent(1, new Error("Transient failure on attempt 1"))); allEvents.push(newTimerCreatedEvent(2, timerFireAt!)); - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ newTimerFiredEvent(2, timerFireAt!), ]); // Should reschedule the activity with a new ID - expect(result.actions.length).toBe(1); - expect(result.actions[0].hasScheduletask()).toBe(true); - expect(result.actions[0].getScheduletask()?.getName()).toBe("flakyActivity"); - expect(result.actions[0].getId()).toBe(3); // New ID after timer + expect(actions.length).toBe(1); + expect(actions[0].hasScheduletask()).toBe(true); + expect(actions[0].getScheduletask()?.getName()).toBe("flakyActivity"); + expect(actions[0].getId()).toBe(3); // New ID after timer // Step 4: Retried activity scheduled, then completes allEvents.push(newTimerFiredEvent(2, timerFireAt!)); allEvents.push(newTaskScheduledEvent(3, "flakyActivity")); - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ newTaskCompletedEvent(3, JSON.stringify(42)), ]); // Assert: Orchestration should complete successfully - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify(42)); }); @@ -930,55 +932,55 @@ describe("Orchestration Executor", () => { const startTime = new Date(); // Step 1: Start orchestration - let executor = new OrchestrationExecutor(registry); + let executor = new OrchestrationExecutor(registry, testLogger); const allEvents = [ newOrchestratorStartedEvent(startTime), newExecutionStartedEvent(name, TEST_INSTANCE_ID, JSON.stringify(21)), ]; - let result = await executor.execute(TEST_INSTANCE_ID, [], allEvents); - expect(result.actions.length).toBe(1); - expect(result.actions[0].hasScheduletask()).toBe(true); + let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, [], allEvents); + expect(actions.length).toBe(1); + expect(actions[0].hasScheduletask()).toBe(true); // Step 2: Activity fails - first attempt allEvents.push(newTaskScheduledEvent(1, "alwaysFailsActivity")); - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ newTaskFailedEvent(1, new Error("Failure on attempt 1")), ]); - expect(result.actions.length).toBe(1); - expect(result.actions[0].hasCreatetimer()).toBe(true); - const timerFireAt = result.actions[0].getCreatetimer()?.getFireat()?.toDate(); + expect(actions.length).toBe(1); + expect(actions[0].hasCreatetimer()).toBe(true); + const timerFireAt = actions[0].getCreatetimer()?.getFireat()?.toDate(); // Step 3: Timer fires, activity is rescheduled allEvents.push(newTaskFailedEvent(1, new Error("Failure on attempt 1"))); allEvents.push(newTimerCreatedEvent(2, timerFireAt!)); - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ newTimerFiredEvent(2, timerFireAt!), ]); - expect(result.actions.length).toBe(1); - expect(result.actions[0].hasScheduletask()).toBe(true); + expect(actions.length).toBe(1); + expect(actions[0].hasScheduletask()).toBe(true); // Step 4: Second activity attempt fails - max attempts reached allEvents.push(newTimerFiredEvent(2, timerFireAt!)); allEvents.push(newTaskScheduledEvent(3, "alwaysFailsActivity")); - executor = new OrchestrationExecutor(registry); - result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ + executor = new OrchestrationExecutor(registry, testLogger); + ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ newTaskFailedEvent(3, new Error("Failure on attempt 2")), ]); // Assert: Orchestration should fail - const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); }); }); }); function getAndValidateSingleCompleteOrchestrationAction( - result: OrchestrationExecutionResult, + actions: OrchestratorAction[], ): CompleteOrchestrationAction | undefined { - expect(result.actions.length).toEqual(1); - const action = result.actions[0]; + expect(actions.length).toEqual(1); + const action = actions[0]; expect(action?.constructor?.name).toEqual(CompleteOrchestrationAction.name); const resCompleteOrchestration = action.getCompleteorchestration(); diff --git a/submodules/durabletask-protobuf b/submodules/durabletask-protobuf new file mode 160000 index 0000000..139a8e3 --- /dev/null +++ b/submodules/durabletask-protobuf @@ -0,0 +1 @@ +Subproject commit 139a8e31fa37694163e02985ddc73f25e08e13c6 From 2fe9c71d033d02c327f33ae3655a11474ac4bf4c Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:16:27 -0800 Subject: [PATCH 02/12] fix: Await gRPC stub close in TaskHubGrpcClient stop method --- packages/durabletask-js/src/client/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/durabletask-js/src/client/client.ts b/packages/durabletask-js/src/client/client.ts index 70328b0..16bd83b 100644 --- a/packages/durabletask-js/src/client/client.ts +++ b/packages/durabletask-js/src/client/client.ts @@ -55,7 +55,7 @@ export class TaskHubGrpcClient { } async stop(): Promise { - this._stub.close(); + await this._stub.close(); // Brief pause to allow gRPC cleanup - this is a known issue with grpc-node // https://github.com/grpc/grpc-node/issues/1563#issuecomment-829483711 From 2d555c12da643c8014fa6ec350c2baee02550e98 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:30:10 -0800 Subject: [PATCH 03/12] Update packages/durabletask-js-azuremanaged/src/azure-logger-adapter.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../durabletask-js-azuremanaged/src/azure-logger-adapter.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/durabletask-js-azuremanaged/src/azure-logger-adapter.ts b/packages/durabletask-js-azuremanaged/src/azure-logger-adapter.ts index 216dbfa..f96fa35 100644 --- a/packages/durabletask-js-azuremanaged/src/azure-logger-adapter.ts +++ b/packages/durabletask-js-azuremanaged/src/azure-logger-adapter.ts @@ -8,9 +8,10 @@ import { Logger } from "@microsoft/durabletask-js"; * Pre-configured logger adapter that uses the default "durabletask" namespace. * * This adapter integrates with the Azure SDK logging infrastructure, allowing - * log output to be controlled via the `AZURE_LOG_LEVEL` environment variable. + * log output to be controlled via the `AZURE_LOG_LEVEL` environment variable + * or programmatically using `setLogLevel()` from `@azure/logger`. * - * Supported log levels (via AZURE_LOG_LEVEL): + * Supported log levels (when configured via AZURE_LOG_LEVEL or setLogLevel()): * - error: Only show errors * - warning: Show warnings and errors * - info: Show info, warnings, and errors From b2a92c49ed531b3f00796e66c352fa5e1cb813f9 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:30:18 -0800 Subject: [PATCH 04/12] Update packages/durabletask-js/src/client/client.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/durabletask-js/src/client/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/durabletask-js/src/client/client.ts b/packages/durabletask-js/src/client/client.ts index 16bd83b..df52cd9 100644 --- a/packages/durabletask-js/src/client/client.ts +++ b/packages/durabletask-js/src/client/client.ts @@ -59,7 +59,7 @@ export class TaskHubGrpcClient { // Brief pause to allow gRPC cleanup - this is a known issue with grpc-node // https://github.com/grpc/grpc-node/issues/1563#issuecomment-829483711 - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 1000)); } /** From 502686224cdfbf387c1cea46993fa402f0483562 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:30:25 -0800 Subject: [PATCH 05/12] Update packages/durabletask-js/src/worker/task-hub-grpc-worker.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/durabletask-js/src/worker/task-hub-grpc-worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts index a98ebd5..f157a66 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -290,7 +290,7 @@ export class TaskHubGrpcWorker { // Brief pause to allow gRPC cleanup // https://github.com/grpc/grpc-node/issues/1563#issuecomment-829483711 - await sleep(500); + await sleep(1000); } /** From 80529eae3281cf983858079bdf9b4367a39701da Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:31:40 -0800 Subject: [PATCH 06/12] fix: Remove non-null assertion in withTimeout by initializing timeoutId with undefined --- packages/durabletask-js/src/utils/backoff.util.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/durabletask-js/src/utils/backoff.util.ts b/packages/durabletask-js/src/utils/backoff.util.ts index 1f5c639..8a5007e 100644 --- a/packages/durabletask-js/src/utils/backoff.util.ts +++ b/packages/durabletask-js/src/utils/backoff.util.ts @@ -213,7 +213,7 @@ export async function withTimeout( timeoutMs: number, timeoutMessage = "Operation timed out", ): Promise { - let timeoutId: ReturnType; + let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); @@ -222,6 +222,8 @@ export async function withTimeout( try { return await Promise.race([promise, timeoutPromise]); } finally { - clearTimeout(timeoutId!); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } } } From 1cd050385f53ce5a8064435b245f7b9d2e48e6e9 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:43:51 -0800 Subject: [PATCH 07/12] fix: Catch unhandled promise rejections in worker start() method --- .../durabletask-js/src/worker/task-hub-grpc-worker.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts index f157a66..d6cd184 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -158,8 +158,13 @@ export class TaskHubGrpcWorker { const client = new GrpcClient(this._hostAddress, this._grpcChannelOptions, this._tls, this._grpcChannelCredentials); this._stub = client.stub; - // do not await so it runs in the background - this.internalRunWorker(client); + // Run in background but catch any unhandled errors to prevent unhandled rejections + this.internalRunWorker(client).catch((err) => { + // Only log if the worker wasn't stopped intentionally + if (!this._stopWorker) { + this._logger.error(`Worker error: ${err}`); + } + }); this._isRunning = true; } From f2dffd3f7dd65c5965450a0b742c8a9f4f6dbc6e Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:52:31 -0800 Subject: [PATCH 08/12] fix: Reorder stream cleanup to prevent unhandled CANCELLED errors - Cancel stream before removing listeners in stop() so error handler can suppress CANCELLED - Remove unnecessary stream.cancel() in 'end' handler (stream already closed) - Add .catch() handlers to all recursive internalRunWorker calls --- .../src/worker/task-hub-grpc-worker.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts index d6cd184..dcd1493 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -206,14 +206,15 @@ export class TaskHubGrpcWorker { // Wait for the stream to end or error stream.on("end", async () => { - // Clean up event listeners to prevent memory leaks - stream.removeAllListeners(); - stream.cancel(); - stream.destroy(); if (this._stopWorker) { this._logger.info("Stream ended"); + stream.removeAllListeners(); + stream.destroy(); return; } + // Stream ended unexpectedly - clean up and retry + stream.removeAllListeners(); + stream.destroy(); this._logger.info(`Stream abruptly closed, will retry in ${this._backoff.peekNextDelay()}ms...`); await this._backoff.wait(); // Create a new client for the retry to avoid stale channel issues @@ -225,7 +226,11 @@ export class TaskHubGrpcWorker { ); this._stub = newClient.stub; // do not await - this.internalRunWorker(newClient, true); + this.internalRunWorker(newClient, true).catch((err) => { + if (!this._stopWorker) { + this._logger.error(`Worker error: ${err}`); + } + }); }); stream.on("error", (err: Error) => { @@ -254,7 +259,11 @@ export class TaskHubGrpcWorker { this._grpcChannelCredentials, ); this._stub = newClient.stub; - this.internalRunWorker(newClient, true); + this.internalRunWorker(newClient, true).catch((retryErr) => { + if (!this._stopWorker) { + this._logger.error(`Worker error: ${retryErr}`); + } + }); return; } } @@ -270,9 +279,13 @@ export class TaskHubGrpcWorker { this._stopWorker = true; - // Clean up stream listeners to prevent memory leaks - this._responseStream?.removeAllListeners(); + // Cancel stream first while error handlers are still attached + // This allows the error handler to suppress CANCELLED errors this._responseStream?.cancel(); + // Brief pause to let cancellation error propagate to handlers + await sleep(10); + // Now safe to remove listeners and destroy + this._responseStream?.removeAllListeners(); this._responseStream?.destroy(); // Wait for pending work items to complete with timeout From 9e36c64d036319871ed471dc39ae47b309fd94e7 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:08:52 -0800 Subject: [PATCH 09/12] fix: Use event-driven synchronization instead of fixed delay in stop() - Replace 10ms sleep with awaiting stream close/end/error events - Add 1s timeout fallback for stream closure - Fix inconsistent JSDoc indentation in client-builder.ts --- .../src/client-builder.ts | 4 ++-- .../src/worker/task-hub-grpc-worker.ts | 23 +++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/durabletask-js-azuremanaged/src/client-builder.ts b/packages/durabletask-js-azuremanaged/src/client-builder.ts index 4a41cdf..3697002 100644 --- a/packages/durabletask-js-azuremanaged/src/client-builder.ts +++ b/packages/durabletask-js-azuremanaged/src/client-builder.ts @@ -125,8 +125,8 @@ export class DurableTaskAzureManagedClientBuilder { } /** - * Sets the logger to use for logging. - * Defaults to ConsoleLogger. + * Sets the logger to use for logging. + * Defaults to ConsoleLogger. * * @param logger The logger instance. * @returns This builder instance. diff --git a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts index dcd1493..80b42fd 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -282,8 +282,27 @@ export class TaskHubGrpcWorker { // Cancel stream first while error handlers are still attached // This allows the error handler to suppress CANCELLED errors this._responseStream?.cancel(); - // Brief pause to let cancellation error propagate to handlers - await sleep(10); + + // Wait for the stream to react to cancellation using events rather than a fixed delay. + // This avoids race conditions caused by relying on timing alone. + if (this._responseStream) { + try { + await withTimeout( + new Promise((resolve) => { + const stream = this._responseStream!; + // Any of these events indicates the stream has processed cancellation / is closing. + stream.once("end", resolve); + stream.once("close", resolve); + stream.once("error", () => resolve()); + }), + 1000, + "Timed out waiting for response stream to close after cancellation", + ); + } catch { + // If we time out waiting for the stream to close, proceed with forced cleanup below. + } + } + // Now safe to remove listeners and destroy this._responseStream?.removeAllListeners(); this._responseStream?.destroy(); From 73922cd52ed11916986790e571880afd769623bf Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:15:49 -0800 Subject: [PATCH 10/12] fix: Improve error logging format to preserve stack traces - Pass error objects as separate arguments instead of string interpolation - Consolidate duplicate error+info logging calls into single error calls - Maintains proper severity levels for error logs Addresses Copilot review feedback --- .../src/worker/task-hub-grpc-worker.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts index 80b42fd..ccaccfe 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -162,7 +162,7 @@ export class TaskHubGrpcWorker { this.internalRunWorker(client).catch((err) => { // Only log if the worker wasn't stopped intentionally if (!this._stopWorker) { - this._logger.error(`Worker error: ${err}`); + this._logger.error("Worker error:", err); } }); @@ -228,7 +228,7 @@ export class TaskHubGrpcWorker { // do not await this.internalRunWorker(newClient, true).catch((err) => { if (!this._stopWorker) { - this._logger.error(`Worker error: ${err}`); + this._logger.error("Worker error:", err); } }); }); @@ -261,7 +261,7 @@ export class TaskHubGrpcWorker { this._stub = newClient.stub; this.internalRunWorker(newClient, true).catch((retryErr) => { if (!this._stopWorker) { - this._logger.error(`Worker error: ${retryErr}`); + this._logger.error("Worker error:", retryErr); } }); return; @@ -373,8 +373,10 @@ export class TaskHubGrpcWorker { res.setCustomstatus(pbh.getStringValue(result.customStatus)); } } catch (e: any) { - this._logger.error(e); - this._logger.info(`An error occurred while trying to execute instance '${req.getInstanceid()}': ${e.message}`); + this._logger.error( + `An error occurred while trying to execute instance '${req.getInstanceid()}':`, + e, + ); const failureDetails = pbh.newFailureDetails(e); @@ -448,8 +450,7 @@ export class TaskHubGrpcWorker { res.setCompletiontoken(completionToken); res.setResult(s); } catch (e: any) { - this._logger.error(e); - this._logger.info(`An error occurred while trying to execute activity '${req.getName()}': ${e.message}`); + this._logger.error(`An error occurred while trying to execute activity '${req.getName()}':`, e); const failureDetails = pbh.newFailureDetails(e); From bde01d79cfa9caaafab3ff6d317c313e4c9aeb56 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:22:36 -0800 Subject: [PATCH 11/12] Update packages/durabletask-js/src/worker/task-hub-grpc-worker.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/durabletask-js/src/worker/task-hub-grpc-worker.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts index ccaccfe..dc14268 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -322,7 +322,10 @@ export class TaskHubGrpcWorker { } } - this._stub?.close(); + if (this._stub) { + // Await the stub close operation to ensure gRPC client cleanup completes before returning. + await Promise.resolve(this._stub.close()); + } this._isRunning = false; // Brief pause to allow gRPC cleanup From b47ff2f2e2e65e6a2867faa598e2f2228fea6d74 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:31:38 -0800 Subject: [PATCH 12/12] feat: Enhance TaskHubGrpcClient and TaskHubGrpcWorker with options interfaces and constructors --- packages/durabletask-js/src/client/client.ts | 66 ++++- packages/durabletask-js/src/index.ts | 4 +- .../src/worker/task-hub-grpc-worker.ts | 143 ++++++++--- .../test/orchestration_executor.spec.ts | 234 +++++++++--------- submodules/durabletask-protobuf | 1 - 5 files changed, 288 insertions(+), 160 deletions(-) delete mode 160000 submodules/durabletask-protobuf diff --git a/packages/durabletask-js/src/client/client.ts b/packages/durabletask-js/src/client/client.ts index df52cd9..572ddcf 100644 --- a/packages/durabletask-js/src/client/client.ts +++ b/packages/durabletask-js/src/client/client.ts @@ -26,11 +26,36 @@ import { Logger, ConsoleLogger } from "../types/logger.type"; // Re-export MetadataGenerator for backward compatibility export { MetadataGenerator } from "../utils/grpc-helper.util"; +/** + * Options for creating a TaskHubGrpcClient. + */ +export interface TaskHubGrpcClientOptions { + /** The host address to connect to. Defaults to "localhost:4001". */ + hostAddress?: string; + /** gRPC channel options. */ + options?: grpc.ChannelOptions; + /** Whether to use TLS. Defaults to false. */ + useTLS?: boolean; + /** Optional pre-configured channel credentials. If provided, useTLS is ignored. */ + credentials?: grpc.ChannelCredentials; + /** Optional function to generate per-call metadata (for taskhub, auth tokens, etc.). */ + metadataGenerator?: MetadataGenerator; + /** Optional logger instance. Defaults to ConsoleLogger. */ + logger?: Logger; +} + export class TaskHubGrpcClient { private _stub: stubs.TaskHubSidecarServiceClient; private _metadataGenerator?: MetadataGenerator; private _logger: Logger; + /** + * Creates a new TaskHubGrpcClient instance. + * + * @param options Configuration options for the client. + */ + constructor(options: TaskHubGrpcClientOptions); + /** * Creates a new TaskHubGrpcClient instance. * @@ -40,6 +65,7 @@ export class TaskHubGrpcClient { * @param credentials Optional pre-configured channel credentials. If provided, useTLS is ignored. * @param metadataGenerator Optional function to generate per-call metadata (for taskhub, auth tokens, etc.). * @param logger Optional logger instance. Defaults to ConsoleLogger. + * @deprecated Use the options object constructor instead. */ constructor( hostAddress?: string, @@ -48,10 +74,44 @@ export class TaskHubGrpcClient { credentials?: grpc.ChannelCredentials, metadataGenerator?: MetadataGenerator, logger?: Logger, + ); + + constructor( + hostAddressOrOptions?: string | TaskHubGrpcClientOptions, + options?: grpc.ChannelOptions, + useTLS?: boolean, + credentials?: grpc.ChannelCredentials, + metadataGenerator?: MetadataGenerator, + logger?: Logger, ) { - this._stub = new GrpcClient(hostAddress, options, useTLS, credentials).stub; - this._metadataGenerator = metadataGenerator; - this._logger = logger ?? new ConsoleLogger(); + let resolvedHostAddress: string | undefined; + let resolvedOptions: grpc.ChannelOptions | undefined; + let resolvedUseTLS: boolean | undefined; + let resolvedCredentials: grpc.ChannelCredentials | undefined; + let resolvedMetadataGenerator: MetadataGenerator | undefined; + let resolvedLogger: Logger | undefined; + + if (typeof hostAddressOrOptions === "object" && hostAddressOrOptions !== null) { + // Options object constructor + resolvedHostAddress = hostAddressOrOptions.hostAddress; + resolvedOptions = hostAddressOrOptions.options; + resolvedUseTLS = hostAddressOrOptions.useTLS; + resolvedCredentials = hostAddressOrOptions.credentials; + resolvedMetadataGenerator = hostAddressOrOptions.metadataGenerator; + resolvedLogger = hostAddressOrOptions.logger; + } else { + // Deprecated positional parameters constructor + resolvedHostAddress = hostAddressOrOptions; + resolvedOptions = options; + resolvedUseTLS = useTLS; + resolvedCredentials = credentials; + resolvedMetadataGenerator = metadataGenerator; + resolvedLogger = logger; + } + + this._stub = new GrpcClient(resolvedHostAddress, resolvedOptions, resolvedUseTLS, resolvedCredentials).stub; + this._metadataGenerator = resolvedMetadataGenerator; + this._logger = resolvedLogger ?? new ConsoleLogger(); } async stop(): Promise { diff --git a/packages/durabletask-js/src/index.ts b/packages/durabletask-js/src/index.ts index 1f65971..63337a6 100644 --- a/packages/durabletask-js/src/index.ts +++ b/packages/durabletask-js/src/index.ts @@ -2,8 +2,8 @@ // Licensed under the MIT License. // Client and Worker -export { TaskHubGrpcClient, MetadataGenerator } from "./client/client"; -export { TaskHubGrpcWorker } from "./worker/task-hub-grpc-worker"; +export { TaskHubGrpcClient, TaskHubGrpcClientOptions, MetadataGenerator } from "./client/client"; +export { TaskHubGrpcWorker, TaskHubGrpcWorkerOptions } from "./worker/task-hub-grpc-worker"; // Contexts export { OrchestrationContext } from "./task/context/orchestration-context"; diff --git a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts index dc14268..d28cbe1 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -22,6 +22,26 @@ import { ExponentialBackoff, sleep, withTimeout } from "../utils/backoff.util"; /** Default timeout in milliseconds for graceful shutdown. */ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30000; +/** + * Options for creating a TaskHubGrpcWorker. + */ +export interface TaskHubGrpcWorkerOptions { + /** The host address to connect to. Defaults to "localhost:4001". */ + hostAddress?: string; + /** gRPC channel options. */ + options?: grpc.ChannelOptions; + /** Whether to use TLS. Defaults to false. */ + useTLS?: boolean; + /** Optional pre-configured channel credentials. If provided, useTLS is ignored. */ + credentials?: grpc.ChannelCredentials; + /** Optional function to generate per-call metadata (for taskhub, auth tokens, etc.). */ + metadataGenerator?: MetadataGenerator; + /** Optional logger instance. Defaults to ConsoleLogger. */ + logger?: Logger; + /** Optional timeout in milliseconds for graceful shutdown. Defaults to 30000. */ + shutdownTimeoutMs?: number; +} + export class TaskHubGrpcWorker { private _responseStream: grpc.ClientReadableStream | null; private _registry: Registry; @@ -38,6 +58,13 @@ export class TaskHubGrpcWorker { private _shutdownTimeoutMs: number; private _backoff: ExponentialBackoff; + /** + * Creates a new TaskHubGrpcWorker instance. + * + * @param options Configuration options for the worker. + */ + constructor(options: TaskHubGrpcWorkerOptions); + /** * Creates a new TaskHubGrpcWorker instance. * @@ -48,6 +75,7 @@ export class TaskHubGrpcWorker { * @param metadataGenerator Optional function to generate per-call metadata (for taskhub, auth tokens, etc.). * @param logger Optional logger instance. Defaults to ConsoleLogger. * @param shutdownTimeoutMs Optional timeout in milliseconds for graceful shutdown. Defaults to 30000. + * @deprecated Use the options object constructor instead. */ constructor( hostAddress?: string, @@ -57,20 +85,58 @@ export class TaskHubGrpcWorker { metadataGenerator?: MetadataGenerator, logger?: Logger, shutdownTimeoutMs?: number, + ); + + constructor( + hostAddressOrOptions?: string | TaskHubGrpcWorkerOptions, + options?: grpc.ChannelOptions, + useTLS?: boolean, + credentials?: grpc.ChannelCredentials, + metadataGenerator?: MetadataGenerator, + logger?: Logger, + shutdownTimeoutMs?: number, ) { + let resolvedHostAddress: string | undefined; + let resolvedOptions: grpc.ChannelOptions | undefined; + let resolvedUseTLS: boolean | undefined; + let resolvedCredentials: grpc.ChannelCredentials | undefined; + let resolvedMetadataGenerator: MetadataGenerator | undefined; + let resolvedLogger: Logger | undefined; + let resolvedShutdownTimeoutMs: number | undefined; + + if (typeof hostAddressOrOptions === "object" && hostAddressOrOptions !== null) { + // Options object constructor + resolvedHostAddress = hostAddressOrOptions.hostAddress; + resolvedOptions = hostAddressOrOptions.options; + resolvedUseTLS = hostAddressOrOptions.useTLS; + resolvedCredentials = hostAddressOrOptions.credentials; + resolvedMetadataGenerator = hostAddressOrOptions.metadataGenerator; + resolvedLogger = hostAddressOrOptions.logger; + resolvedShutdownTimeoutMs = hostAddressOrOptions.shutdownTimeoutMs; + } else { + // Deprecated positional parameters constructor + resolvedHostAddress = hostAddressOrOptions; + resolvedOptions = options; + resolvedUseTLS = useTLS; + resolvedCredentials = credentials; + resolvedMetadataGenerator = metadataGenerator; + resolvedLogger = logger; + resolvedShutdownTimeoutMs = shutdownTimeoutMs; + } + this._registry = new Registry(); - this._hostAddress = hostAddress; - this._tls = useTLS; - this._grpcChannelOptions = options; - this._grpcChannelCredentials = credentials; - this._metadataGenerator = metadataGenerator; + this._hostAddress = resolvedHostAddress; + this._tls = resolvedUseTLS; + this._grpcChannelOptions = resolvedOptions; + this._grpcChannelCredentials = resolvedCredentials; + this._metadataGenerator = resolvedMetadataGenerator; this._responseStream = null; this._isRunning = false; this._stopWorker = false; this._stub = null; - this._logger = logger ?? new ConsoleLogger(); + this._logger = resolvedLogger ?? new ConsoleLogger(); this._pendingWorkItems = new Set(); - this._shutdownTimeoutMs = shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS; + this._shutdownTimeoutMs = resolvedShutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS; this._backoff = new ExponentialBackoff({ initialDelayMs: 1000, maxDelayMs: 30000, @@ -88,6 +154,34 @@ export class TaskHubGrpcWorker { return new grpc.Metadata(); } + /** + * Creates a new gRPC client and retries the worker. + * Properly closes the old client to prevent connection leaks. + */ + private async _createNewClientAndRetry(): Promise { + // Close the old stub to prevent connection leaks + if (this._stub) { + this._stub.close(); + } + + await this._backoff.wait(); + + const newClient = new GrpcClient( + this._hostAddress, + this._grpcChannelOptions, + this._tls, + this._grpcChannelCredentials, + ); + this._stub = newClient.stub; + + // Do not await - run in background + this.internalRunWorker(newClient, true).catch((err) => { + if (!this._stopWorker) { + this._logger.error("Worker error:", err); + } + }); + } + /** * Registers an orchestrator function with the worker. * @@ -216,21 +310,7 @@ export class TaskHubGrpcWorker { stream.removeAllListeners(); stream.destroy(); this._logger.info(`Stream abruptly closed, will retry in ${this._backoff.peekNextDelay()}ms...`); - await this._backoff.wait(); - // Create a new client for the retry to avoid stale channel issues - const newClient = new GrpcClient( - this._hostAddress, - this._grpcChannelOptions, - this._tls, - this._grpcChannelCredentials, - ); - this._stub = newClient.stub; - // do not await - this.internalRunWorker(newClient, true).catch((err) => { - if (!this._stopWorker) { - this._logger.error("Worker error:", err); - } - }); + await this._createNewClientAndRetry(); }); stream.on("error", (err: Error) => { @@ -250,20 +330,7 @@ export class TaskHubGrpcWorker { throw err; } this._logger.info(`Connection will be retried in ${this._backoff.peekNextDelay()}ms...`); - await this._backoff.wait(); - // Create a new client for the retry - const newClient = new GrpcClient( - this._hostAddress, - this._grpcChannelOptions, - this._tls, - this._grpcChannelCredentials, - ); - this._stub = newClient.stub; - this.internalRunWorker(newClient, true).catch((retryErr) => { - if (!this._stopWorker) { - this._logger.error("Worker error:", retryErr); - } - }); + await this._createNewClientAndRetry(); return; } } @@ -323,8 +390,8 @@ export class TaskHubGrpcWorker { } if (this._stub) { - // Await the stub close operation to ensure gRPC client cleanup completes before returning. - await Promise.resolve(this._stub.close()); + // Close the gRPC client - this is a synchronous operation + this._stub.close(); } this._isRunning = false; diff --git a/packages/durabletask-js/test/orchestration_executor.spec.ts b/packages/durabletask-js/test/orchestration_executor.spec.ts index 5f07cff..65ac518 100644 --- a/packages/durabletask-js/test/orchestration_executor.spec.ts +++ b/packages/durabletask-js/test/orchestration_executor.spec.ts @@ -19,7 +19,7 @@ import { newTimerCreatedEvent, newTimerFiredEvent, } from "../src/utils/pb-helper.util"; -import { OrchestrationExecutor } from "../src/worker/orchestration-executor"; +import { OrchestrationExecutor, OrchestrationExecutionResult } 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"; @@ -50,8 +50,8 @@ describe("Orchestration Executor", () => { newExecutionStartedEvent(name, TEST_INSTANCE_ID, JSON.stringify(testInput)), ]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, [], newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()).not.toBeNull(); const expectedOutput = [testInput, TEST_INSTANCE_ID, startTime.toISOString(), false]; @@ -66,8 +66,8 @@ describe("Orchestration Executor", () => { const name = registry.addOrchestrator(emptyOrchestrator); const newEvents = [newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, [], newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()).not.toBeNull(); expect(completeAction?.getResult()?.getValue()).toEqual('"done"'); @@ -77,8 +77,8 @@ describe("Orchestration Executor", () => { const name = "Bogus"; const newEvents = [newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, [], newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("OrchestratorNotRegisteredError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).not.toBeNull(); @@ -99,12 +99,12 @@ describe("Orchestration Executor", () => { newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined), ]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, [], newEvents); - expect(actions).not.toBeNull(); - expect(actions.length).toEqual(1); - expect(actions[0]?.constructor?.name).toEqual(OrchestratorAction.name); - expect(actions[0]?.getId()).toEqual(1); - expect(actions[0]?.getCreatetimer()?.getFireat()?.toDate()).toEqual(expectedFireAt); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + expect(result.actions).not.toBeNull(); + expect(result.actions.length).toEqual(1); + expect(result.actions[0]?.constructor?.name).toEqual(OrchestratorAction.name); + expect(result.actions[0]?.getId()).toEqual(1); + expect(result.actions[0]?.getCreatetimer()?.getFireat()?.toDate()).toEqual(expectedFireAt); }); it("should test the resumption of a task using a timerFired event", async () => { const delayOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, _: any): any { @@ -124,8 +124,8 @@ describe("Orchestration Executor", () => { ]; const newEvents = [newTimerFiredEvent(1, expectedFireAt)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const 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()).not.toBeNull(); expect(completeAction?.getResult()?.getValue()).toEqual('"done"'); @@ -142,12 +142,12 @@ describe("Orchestration Executor", () => { const name = registry.addOrchestrator(orchestrator); const newEvents = [newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, [], newEvents); - expect(actions).not.toBeNull(); - expect(actions.length).toEqual(1); - expect(actions[0]?.constructor?.name).toEqual(OrchestratorAction.name); - expect(actions[0]?.getId()).toEqual(1); - expect(actions[0]?.getScheduletask()?.getName()).toEqual("dummyActivity"); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + expect(result.actions).not.toBeNull(); + expect(result.actions.length).toEqual(1); + expect(result.actions[0]?.constructor?.name).toEqual(OrchestratorAction.name); + expect(result.actions[0]?.getId()).toEqual(1); + expect(result.actions[0]?.getScheduletask()?.getName()).toEqual("dummyActivity"); }); it("should test the successful completion of an activity task", async () => { const dummyActivity = async (_: ActivityContext) => { @@ -167,8 +167,9 @@ describe("Orchestration Executor", () => { const encodedOutput = JSON.stringify("done!"); const newEvents = [newTaskCompletedEvent(1, encodedOutput)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + console.log(completeAction?.getFailuredetails()); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual(encodedOutput); }); @@ -190,8 +191,9 @@ describe("Orchestration Executor", () => { const encodedOutput = JSON.stringify("done!"); const newEvents = [newTaskCompletedEvent(1, encodedOutput)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + console.log(completeAction?.getFailuredetails()); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual(encodedOutput); }); @@ -213,8 +215,8 @@ describe("Orchestration Executor", () => { const ex = new Error("Kah-BOOOOM!!!"); const newEvents = [newTaskFailedEvent(1, ex)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("TaskFailedError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain(ex.message); @@ -243,8 +245,8 @@ describe("Orchestration Executor", () => { ]; const newEvents = [newTimerFiredEvent(1, fireAt)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -265,8 +267,8 @@ describe("Orchestration Executor", () => { ]; const newEvents = [newTaskCompletedEvent(1, "done!")]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -289,8 +291,8 @@ describe("Orchestration Executor", () => { ]; const newEvents = [newTaskCompletedEvent(1)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -314,8 +316,8 @@ describe("Orchestration Executor", () => { ]; const newEvents = [newTaskCompletedEvent(1)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -341,8 +343,8 @@ describe("Orchestration Executor", () => { ]; const newEvents = [newSubOrchestrationCompletedEvent(1, "42")]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const 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("42"); }); @@ -365,8 +367,8 @@ describe("Orchestration Executor", () => { const ex = new Error("Kah-BOOOOM!!!"); const newEvents = [newSubOrchestrationFailedEvent(1, ex)]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("TaskFailedError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain(ex.message); @@ -389,8 +391,8 @@ describe("Orchestration Executor", () => { ]; const newEvents = [newSubOrchestrationCompletedEvent(1, "42")]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -419,8 +421,8 @@ describe("Orchestration Executor", () => { ]; const newEvents = [newSubOrchestrationCompletedEvent(1, "42")]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("NonDeterminismError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("1"); @@ -446,17 +448,17 @@ describe("Orchestration Executor", () => { // Execute the orchestration until it is waiting for an external event. // The result should be an empty list of actions because the orchestration didn't schedule any work let executor = new OrchestrationExecutor(registry, testLogger); - let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - expect(actions.length).toBe(0); + let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + expect(result.actions.length).toBe(0); // Now send an external event to the orchestration and execute it again. // This time the orcehstration should complete oldEvents = newEvents; newEvents = [newEventRaisedEvent("my_event", "42")]; executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual("42"); }); @@ -481,9 +483,9 @@ describe("Orchestration Executor", () => { // Execute the orchestration // It should be in a running state waiting for the timer to fire let executor = new OrchestrationExecutor(registry, testLogger); - let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - expect(actions.length).toBe(1); - expect(actions[0].hasCreatetimer()).toBeTruthy(); + 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 task // The orchestration should move to the waitForExternalEvent step now which should @@ -493,9 +495,9 @@ describe("Orchestration Executor", () => { oldEvents = newEvents; newEvents = [newTimerFiredEvent(1, timerDueTime)]; executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual("42"); }); @@ -517,16 +519,16 @@ describe("Orchestration Executor", () => { // It should be in a running state because it was suspended prior // to the processing the event raised event let executor = new OrchestrationExecutor(registry, testLogger); - let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - expect(actions.length).toBe(0); + let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + expect(result.actions.length).toBe(0); // Resume the orchestration, it should complete successfully oldEvents.push(...newEvents); newEvents = [newResumeEvent()]; executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual("42"); }); @@ -547,11 +549,11 @@ describe("Orchestration Executor", () => { // Execute the orchestration // It should be in a running state waiting for an external event let executor = new OrchestrationExecutor(registry, testLogger); - let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_TERMINATED); expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify("terminated!")); }); @@ -579,9 +581,9 @@ describe("Orchestration Executor", () => { // Execute the orchestration, it should be in a running state waiting for the timer to fire const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual( pb.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW, ); @@ -628,15 +630,15 @@ describe("Orchestration Executor", () => { ]; const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); // The result should be 10 "taskScheduled" actions with inputs from 0 to 9 - expect(actions.length).toEqual(10); + expect(result.actions.length).toEqual(10); for (let i = 0; i < 10; i++) { - expect(actions[i].hasScheduletask()); - expect(actions[i].getScheduletask()?.getName()).toEqual(activityName); - expect(actions[i].getScheduletask()?.getInput()?.getValue()).toEqual(`"${i}"`); + expect(result.actions[i].hasScheduletask()); + expect(result.actions[i].getScheduletask()?.getName()).toEqual(activityName); + expect(result.actions[i].getScheduletask()?.getInput()?.getValue()).toEqual(`"${i}"`); } }); @@ -676,15 +678,15 @@ describe("Orchestration Executor", () => { // we expect the orchestrator to be running // it should however return 0 actions, since it is still waiting for the other 5 tasks to complete let executor = new OrchestrationExecutor(registry, testLogger); - let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents.slice(0, 4)); - expect(actions.length).toBe(0); + let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents.slice(0, 4)); + expect(result.actions.length).toBe(0); // Now test with the full set of new events // we expect the orchestration to complete executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual("[0,1,2,3,4,5,6,7,8,9]"); }); @@ -729,9 +731,9 @@ describe("Orchestration Executor", () => { // Now test with the full set of new events // We expect the orchestration to complete const executor = new OrchestrationExecutor(registry, testLogger); - const { actions } = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("TaskFailedError"); expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain(ex.message); @@ -764,11 +766,11 @@ describe("Orchestration Executor", () => { let oldEvents: any[] = []; let newEvents = [newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID)]; let executor = new OrchestrationExecutor(registry, testLogger); - let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); - expect(actions.length).toEqual(2); - expect(actions[0].hasScheduletask()).toBeTruthy(); - expect(actions[1].hasScheduletask()).toBeTruthy(); + expect(result.actions.length).toEqual(2); + expect(result.actions[0].hasScheduletask()).toBeTruthy(); + expect(result.actions[1].hasScheduletask()).toBeTruthy(); // The next tests assume that the orchestration has already await at the task.whenAny oldEvents = [ @@ -783,8 +785,8 @@ describe("Orchestration Executor", () => { let encodedOutput = JSON.stringify(hello(null, "Tokyo")); newEvents = [newTaskCompletedEvent(1, encodedOutput)]; executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - let completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + let completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual(encodedOutput); @@ -793,8 +795,8 @@ describe("Orchestration Executor", () => { encodedOutput = JSON.stringify(hello(null, "Seattle")); newEvents = [newTaskCompletedEvent(2, encodedOutput)]; executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); - completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual(encodedOutput); }); @@ -824,12 +826,12 @@ describe("Orchestration Executor", () => { newOrchestratorStartedEvent(), newExecutionStartedEvent(name, TEST_INSTANCE_ID, JSON.stringify(21)), ]; - let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, [], newEvents); + let result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); // Assert - Step 1: Should schedule activity - expect(actions.length).toBe(1); - expect(actions[0].hasScheduletask()).toBe(true); - expect(actions[0].getScheduletask()?.getName()).toBe("flakyActivity"); + expect(result.actions.length).toBe(1); + expect(result.actions[0].hasScheduletask()).toBe(true); + expect(result.actions[0].getScheduletask()?.getName()).toBe("flakyActivity"); // Act - Step 2: Activity scheduled, then fails const oldEvents = [ @@ -840,11 +842,11 @@ describe("Orchestration Executor", () => { newEvents = [ newTaskFailedEvent(1, new Error("Transient failure on attempt 1")), ]; - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, oldEvents, newEvents); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); // Assert - Step 2: Should schedule a retry timer - expect(actions.length).toBe(1); - expect(actions[0].hasCreatetimer()).toBe(true); + expect(result.actions.length).toBe(1); + expect(result.actions[0].hasCreatetimer()).toBe(true); }); it("should complete successfully after retry timer fires and activity succeeds", async () => { @@ -871,44 +873,44 @@ describe("Orchestration Executor", () => { newOrchestratorStartedEvent(startTime), newExecutionStartedEvent(name, TEST_INSTANCE_ID, JSON.stringify(21)), ]; - let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, [], allEvents); - expect(actions.length).toBe(1); - expect(actions[0].hasScheduletask()).toBe(true); + let result = await executor.execute(TEST_INSTANCE_ID, [], allEvents); + expect(result.actions.length).toBe(1); + expect(result.actions[0].hasScheduletask()).toBe(true); // Step 2: Activity scheduled, then fails allEvents.push(newTaskScheduledEvent(1, "flakyActivity")); executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ + result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ newTaskFailedEvent(1, new Error("Transient failure on attempt 1")), ]); - expect(actions.length).toBe(1); - expect(actions[0].hasCreatetimer()).toBe(true); - const timerFireAt = actions[0].getCreatetimer()?.getFireat()?.toDate(); + expect(result.actions.length).toBe(1); + expect(result.actions[0].hasCreatetimer()).toBe(true); + const timerFireAt = result.actions[0].getCreatetimer()?.getFireat()?.toDate(); expect(timerFireAt).toBeDefined(); // Step 3: Timer created, then fires allEvents.push(newTaskFailedEvent(1, new Error("Transient failure on attempt 1"))); allEvents.push(newTimerCreatedEvent(2, timerFireAt!)); executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ + result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ newTimerFiredEvent(2, timerFireAt!), ]); // Should reschedule the activity with a new ID - expect(actions.length).toBe(1); - expect(actions[0].hasScheduletask()).toBe(true); - expect(actions[0].getScheduletask()?.getName()).toBe("flakyActivity"); - expect(actions[0].getId()).toBe(3); // New ID after timer + expect(result.actions.length).toBe(1); + expect(result.actions[0].hasScheduletask()).toBe(true); + expect(result.actions[0].getScheduletask()?.getName()).toBe("flakyActivity"); + expect(result.actions[0].getId()).toBe(3); // New ID after timer // Step 4: Retried activity scheduled, then completes allEvents.push(newTimerFiredEvent(2, timerFireAt!)); allEvents.push(newTaskScheduledEvent(3, "flakyActivity")); executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ + result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ newTaskCompletedEvent(3, JSON.stringify(42)), ]); // Assert: Orchestration should complete successfully - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify(42)); }); @@ -937,50 +939,50 @@ describe("Orchestration Executor", () => { newOrchestratorStartedEvent(startTime), newExecutionStartedEvent(name, TEST_INSTANCE_ID, JSON.stringify(21)), ]; - let ({ actions } = await executor.execute)(TEST_INSTANCE_ID, [], allEvents); - expect(actions.length).toBe(1); - expect(actions[0].hasScheduletask()).toBe(true); + let result = await executor.execute(TEST_INSTANCE_ID, [], allEvents); + expect(result.actions.length).toBe(1); + expect(result.actions[0].hasScheduletask()).toBe(true); // Step 2: Activity fails - first attempt allEvents.push(newTaskScheduledEvent(1, "alwaysFailsActivity")); executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ + result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ newTaskFailedEvent(1, new Error("Failure on attempt 1")), ]); - expect(actions.length).toBe(1); - expect(actions[0].hasCreatetimer()).toBe(true); - const timerFireAt = actions[0].getCreatetimer()?.getFireat()?.toDate(); + expect(result.actions.length).toBe(1); + expect(result.actions[0].hasCreatetimer()).toBe(true); + const timerFireAt = result.actions[0].getCreatetimer()?.getFireat()?.toDate(); // Step 3: Timer fires, activity is rescheduled allEvents.push(newTaskFailedEvent(1, new Error("Failure on attempt 1"))); allEvents.push(newTimerCreatedEvent(2, timerFireAt!)); executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ + result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ newTimerFiredEvent(2, timerFireAt!), ]); - expect(actions.length).toBe(1); - expect(actions[0].hasScheduletask()).toBe(true); + expect(result.actions.length).toBe(1); + expect(result.actions[0].hasScheduletask()).toBe(true); // Step 4: Second activity attempt fails - max attempts reached allEvents.push(newTimerFiredEvent(2, timerFireAt!)); allEvents.push(newTaskScheduledEvent(3, "alwaysFailsActivity")); executor = new OrchestrationExecutor(registry, testLogger); - ({ actions } = await executor.execute)(TEST_INSTANCE_ID, allEvents, [ + result = await executor.execute(TEST_INSTANCE_ID, allEvents, [ newTaskFailedEvent(3, new Error("Failure on attempt 2")), ]); // Assert: Orchestration should fail - const completeAction = getAndValidateSingleCompleteOrchestrationAction(actions); + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); }); }); }); function getAndValidateSingleCompleteOrchestrationAction( - actions: OrchestratorAction[], + result: OrchestrationExecutionResult, ): CompleteOrchestrationAction | undefined { - expect(actions.length).toEqual(1); - const action = actions[0]; + expect(result.actions.length).toEqual(1); + const action = result.actions[0]; expect(action?.constructor?.name).toEqual(CompleteOrchestrationAction.name); const resCompleteOrchestration = action.getCompleteorchestration(); diff --git a/submodules/durabletask-protobuf b/submodules/durabletask-protobuf deleted file mode 160000 index 139a8e3..0000000 --- a/submodules/durabletask-protobuf +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 139a8e31fa37694163e02985ddc73f25e08e13c6