From 57b48be73f7e033ad734a99b8d892b28fd3a7ea7 Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 29 Jun 2026 15:30:30 -0700 Subject: [PATCH 1/7] Add Functions gRPC core helpers Expose low-level protobuf codecs and single work-item execution helpers for Azure Functions Durable JS gRPC consolidation without adding Functions metadata support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- packages/durabletask-js/src/index.ts | 32 +- .../src/protocol/base64-protobuf.ts | 56 ++ .../src/worker/task-hub-grpc-worker.ts | 547 ++++-------------- .../src/worker/work-item-executor.ts | 481 +++++++++++++++ .../test/functions-grpc-support.spec.ts | 157 +++++ 6 files changed, 835 insertions(+), 440 deletions(-) create mode 100644 packages/durabletask-js/src/protocol/base64-protobuf.ts create mode 100644 packages/durabletask-js/src/worker/work-item-executor.ts create mode 100644 packages/durabletask-js/test/functions-grpc-support.spec.ts diff --git a/README.md b/README.md index ae80647..706714a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This repo contains a JavaScript/TypeScript SDK for use with the [Azure Durable Task Scheduler](https://github.com/Azure/Durable-Task-Scheduler). With this SDK, you can define, schedule, and manage durable orchestrations using ordinary TypeScript/JavaScript code. -> Note that this SDK is **not** currently compatible with [Azure Durable Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview). If you are looking for a JavaScript SDK for Azure Durable Functions, please see [this repo](https://github.com/Azure/azure-functions-durable-js). +> Note that this SDK does **not** provide the [Azure Durable Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) programming model, decorators, or worker-indexing metadata. If you are looking for a JavaScript SDK for Azure Durable Functions, please see [this repo](https://github.com/Azure/azure-functions-durable-js). This package exposes low-level TaskHubSidecarService gRPC/protobuf helpers that host integrations can reuse; those helpers follow this package's Node.js 22+ requirement. ## npm packages diff --git a/packages/durabletask-js/src/index.ts b/packages/durabletask-js/src/index.ts index bbc5542..360889a 100644 --- a/packages/durabletask-js/src/index.ts +++ b/packages/durabletask-js/src/index.ts @@ -4,6 +4,18 @@ // Client and Worker export { TaskHubGrpcClient, TaskHubGrpcClientOptions, MetadataGenerator } from "./client/client"; export { TaskHubGrpcWorker, TaskHubGrpcWorkerOptions } from "./worker/task-hub-grpc-worker"; +export { Registry } from "./worker/registry"; +export { + WorkItemExecutorOptions, + CompletedOrchestratorWorkItemResult, + AbandonedOrchestratorWorkItemResult, + EntityBatchRequestConversion, + OrchestratorWorkItemResult, + executeOrchestratorWorkItem, + executeEntityBatchWorkItem, + executeEntityWorkItem, + convertEntityRequestToBatchRequest, +} from "./worker/work-item-executor"; export { VersioningOptions, VersionMatchStrategy, VersionFailureStrategy } from "./worker/versioning-options"; export { WorkItemFilters, @@ -68,7 +80,25 @@ export { } from "./orchestration/history-event"; // Proto types (for advanced usage) -export { OrchestrationStatus as ProtoOrchestrationStatus } from "./proto/orchestrator_service_pb"; +export { + OrchestrationStatus as ProtoOrchestrationStatus, + OrchestratorRequest, + OrchestratorResponse, + EntityBatchRequest, + EntityBatchResult, + EntityRequest, +} from "./proto/orchestrator_service_pb"; + +// Base64 protobuf helpers for host integrations +export { + decodeBase64Protobuf, + encodeBase64Protobuf, + decodeOrchestratorRequestFromBase64, + encodeOrchestratorResponseToBase64, + decodeEntityBatchRequestFromBase64, + encodeEntityBatchResultToBase64, + decodeEntityRequestFromBase64, +} from "./protocol/base64-protobuf"; // Failure details export { FailureDetails, TaskFailureDetails } from "./task/failure-details"; diff --git a/packages/durabletask-js/src/protocol/base64-protobuf.ts b/packages/durabletask-js/src/protocol/base64-protobuf.ts new file mode 100644 index 0000000..89da082 --- /dev/null +++ b/packages/durabletask-js/src/protocol/base64-protobuf.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Message } from "google-protobuf"; +import * as pb from "../proto/orchestrator_service_pb"; + +type ProtobufMessageConstructor = { + deserializeBinary(bytes: Uint8Array): T; +}; + +/** + * Decodes a base64-encoded protobuf message. + * + * @remarks + * This is intended for host integrations, such as Azure Functions, that receive + * TaskHubSidecarService payloads as base64 strings. It follows this package's + * runtime support matrix, which currently requires Node.js 22 or higher. + */ +export function decodeBase64Protobuf( + encodedMessage: string, + messageType: ProtobufMessageConstructor, +): T { + return messageType.deserializeBinary(Buffer.from(encodedMessage, "base64")); +} + +/** + * Encodes a protobuf message as a base64 string. + * + * @remarks + * This is intended for host integrations, such as Azure Functions, that return + * TaskHubSidecarService payloads as base64 strings. It follows this package's + * runtime support matrix, which currently requires Node.js 22 or higher. + */ +export function encodeBase64Protobuf(message: Message): string { + return Buffer.from(message.serializeBinary()).toString("base64"); +} + +export function decodeOrchestratorRequestFromBase64(encodedMessage: string): pb.OrchestratorRequest { + return decodeBase64Protobuf(encodedMessage, pb.OrchestratorRequest); +} + +export function encodeOrchestratorResponseToBase64(message: pb.OrchestratorResponse): string { + return encodeBase64Protobuf(message); +} + +export function decodeEntityBatchRequestFromBase64(encodedMessage: string): pb.EntityBatchRequest { + return decodeBase64Protobuf(encodedMessage, pb.EntityBatchRequest); +} + +export function encodeEntityBatchResultToBase64(message: pb.EntityBatchResult): string { + return encodeBase64Protobuf(message); +} + +export function decodeEntityRequestFromBase64(encodedMessage: string): pb.EntityRequest { + return decodeBase64Protobuf(encodedMessage, pb.EntityRequest); +} 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 9fec6f3..a9d1db6 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -13,30 +13,25 @@ import { GrpcClient } from "../client/client-grpc"; import { Empty } from "google-protobuf/google/protobuf/empty_pb"; import * as pbh from "../utils/pb-helper.util"; import { callWithMetadata, MetadataGenerator } from "../utils/grpc-helper.util"; -import { OrchestrationExecutor } from "./orchestration-executor"; import { ActivityExecutor } from "./activity-executor"; -import { TaskEntityShim } from "./entity-executor"; -import { EntityInstanceId } from "../entities/entity-instance-id"; import { EntityFactory } from "../entities/task-entity"; import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; import { Logger, ConsoleLogger } from "../types/logger.type"; import { ExponentialBackoff, sleep, withTimeout } from "../utils/backoff.util"; -import { VersioningOptions, VersionMatchStrategy, VersionFailureStrategy } from "./versioning-options"; +import { VersioningOptions } from "./versioning-options"; import { WorkItemFilters, generateWorkItemFiltersFromRegistry, toGrpcWorkItemFilters } from "./work-item-filters"; -import { compareVersions } from "../utils/versioning.util"; import * as WorkerLogs from "./logs"; import { - DurableTaskAttributes, - startSpanForOrchestrationExecution, startSpanForTaskExecution, - processActionsForTracing, - createOrchestrationTraceContextPb, - setOrchestrationStatusFromActions, - processNewEventsForTracing, setSpanError, setSpanOk, endSpan, } from "../tracing"; +import { + executeEntityBatchWorkItem, + executeEntityWorkItem, + executeOrchestratorWorkItem, +} from "./work-item-executor"; /** Default timeout in milliseconds for graceful shutdown. */ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30000; @@ -313,6 +308,74 @@ export class TaskHubGrpcWorker { return name.toLowerCase(); } + /** + * Executes a single orchestration request and returns the response without + * completing it over gRPC. + * + * @remarks + * This is intended for host integrations, such as Azure Functions, that + * receive a base64-encoded TaskHubSidecarService OrchestratorRequest and need + * to return a base64-encoded OrchestratorResponse. It follows this package's + * runtime support matrix, which currently requires Node.js 22 or higher. + */ + async executeOrchestratorRequest( + req: pb.OrchestratorRequest, + completionToken: string = "", + ): Promise { + const result = await executeOrchestratorWorkItem( + this._registry, + req, + completionToken, + { logger: this._logger, versioning: this._versioning }, + ); + + if (result.kind === "abandoned") { + throw new Error( + `Orchestrator work item was rejected: ${result.errorMessage ?? result.errorType ?? "unknown reason"}`, + ); + } + + return result.response; + } + + /** + * Executes a single entity batch request and returns the result without + * completing it over gRPC. + * + * @remarks + * This is intended for host integrations, such as Azure Functions, that + * receive a base64-encoded TaskHubSidecarService EntityBatchRequest and need + * to return a base64-encoded EntityBatchResult. It follows this package's + * runtime support matrix, which currently requires Node.js 22 or higher. + */ + async executeEntityBatchRequest( + req: pb.EntityBatchRequest, + completionToken: string = "", + ): Promise { + return executeEntityBatchWorkItem( + this._registry, + req, + completionToken, + { logger: this._logger, versioning: this._versioning }, + ); + } + + /** + * Executes a single V2 entity request and returns the batch result without + * completing it over gRPC. + */ + async executeEntityRequest( + req: pb.EntityRequest, + completionToken: string = "", + ): Promise { + return executeEntityWorkItem( + this._registry, + req, + completionToken, + { logger: this._logger, versioning: this._versioning }, + ); + } + /** * In node.js we don't require a new thread as we have a main event loop * Therefore, we open the stream and simply listen through the eventemitter behind the scenes @@ -524,88 +587,6 @@ export class TaskHubGrpcWorker { return request; } - /** - * Result of version compatibility check. - */ - private _checkVersionCompatibility(req: pb.OrchestratorRequest): { - compatible: boolean; - shouldFail: boolean; - orchestrationVersion?: string; - errorType?: string; - errorMessage?: string; - } { - // If no versioning options configured or match strategy is None, always compatible - if (!this._versioning || this._versioning.matchStrategy === VersionMatchStrategy.None) { - return { compatible: true, shouldFail: false }; - } - - // Extract orchestration version from ExecutionStarted event - const orchestrationVersion = this._getOrchestrationVersion(req); - const workerVersion = this._versioning.version; - - // If worker version is not set, process all - if (!workerVersion) { - return { compatible: true, shouldFail: false }; - } - - let compatible = false; - let errorType = "VersionMismatch"; - let errorMessage = ""; - - switch (this._versioning.matchStrategy) { - case VersionMatchStrategy.Strict: - // Only process if versions match (using semantic comparison) - compatible = compareVersions(orchestrationVersion, workerVersion) === 0; - if (!compatible) { - errorMessage = `The orchestration version '${orchestrationVersion ?? ""}' does not match the worker version '${workerVersion}'.`; - } - break; - - case VersionMatchStrategy.CurrentOrOlder: - // Process if orchestration version is current or older - if (!orchestrationVersion) { - // Empty orchestration version is considered older - compatible = true; - } else { - compatible = compareVersions(orchestrationVersion, workerVersion) <= 0; - if (!compatible) { - errorMessage = `The orchestration version '${orchestrationVersion}' is greater than the worker version '${workerVersion}'.`; - } - } - break; - - default: - // Unknown match strategy - treat as version error - compatible = false; - errorType = "VersionError"; - errorMessage = `The version match strategy '${this._versioning.matchStrategy}' is unknown.`; - break; - } - - if (!compatible) { - const shouldFail = this._versioning.failureStrategy === VersionFailureStrategy.Fail; - return { compatible: false, shouldFail, orchestrationVersion, errorType, errorMessage }; - } - - return { compatible: true, shouldFail: false }; - } - - /** - * Extracts the orchestration version from the ExecutionStarted event in the request. - */ - private _getOrchestrationVersion(req: pb.OrchestratorRequest): string | undefined { - // Look for ExecutionStarted event in both past and new events - const allEvents = [...req.getPasteventsList(), ...req.getNeweventsList()]; - - for (const event of allEvents) { - if (event.hasExecutionstarted()) { - return event.getExecutionstarted()?.getVersion()?.getValue(); - } - } - - return undefined; - } - private _trackPendingWorkItem(workPromise: Promise, onError: (error: Error) => void): void { const handledPromise = workPromise .catch((e: unknown) => { @@ -641,165 +622,29 @@ export class TaskHubGrpcWorker { completionToken: string, stub: stubs.TaskHubSidecarServiceClient, ): Promise { - const instanceId = req.getInstanceid(); - - if (!instanceId) { - throw new Error(`Could not execute the orchestrator as the instanceId was not provided (${instanceId})`); - } - - // Check version compatibility if versioning is enabled - const versionCheckResult = this._checkVersionCompatibility(req); - if (!versionCheckResult.compatible) { - if (versionCheckResult.shouldFail) { - // Fail the orchestration with version mismatch error - WorkerLogs.versionMismatchFail( - this._logger, - instanceId, - versionCheckResult.errorType!, - versionCheckResult.errorMessage!, - ); - - const failureDetails = pbh.newVersionMismatchFailureDetails( - versionCheckResult.errorType!, - versionCheckResult.errorMessage!, - ); + const result = await executeOrchestratorWorkItem( + this._registry, + req, + completionToken, + { logger: this._logger, versioning: this._versioning }, + ); - const actions = [ - pbh.newCompleteOrchestrationAction( - -1, - pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, - undefined, - failureDetails, - ), - ]; - - const res = new pb.OrchestratorResponse(); - res.setInstanceid(instanceId); - res.setCompletiontoken(completionToken); - res.setActionsList(actions); - - try { - await callWithMetadata(stub.completeOrchestratorTask.bind(stub), res, this._metadataGenerator); - } catch (e: unknown) { - const error = e instanceof Error ? e : new Error(String(e)); - WorkerLogs.completionError(this._logger, instanceId, error); - } - return; - } else { - // Reject the work item - explicitly abandon it so it can be picked up by another worker - WorkerLogs.versionMismatchAbandon( - this._logger, - instanceId, - versionCheckResult.errorType!, - versionCheckResult.errorMessage!, + if (result.kind === "abandoned") { + try { + await callWithMetadata( + stub.abandonTaskOrchestratorWorkItem.bind(stub), + result.abandonRequest, + this._metadataGenerator, ); - - try { - const abandonRequest = new pb.AbandonOrchestrationTaskRequest(); - abandonRequest.setCompletiontoken(completionToken); - await callWithMetadata(stub.abandonTaskOrchestratorWorkItem.bind(stub), abandonRequest, this._metadataGenerator); - } catch (e: unknown) { - const error = e instanceof Error ? e : new Error(String(e)); - WorkerLogs.completionError(this._logger, instanceId, error); - } - return; - } - } - - // Find the ExecutionStartedEvent from either past or new events for tracing - const allProtoEvents = [...req.getPasteventsList(), ...req.getNeweventsList()]; - let executionStartedProtoEvent: pb.ExecutionStartedEvent | undefined; - for (const protoEvent of allProtoEvents) { - if (protoEvent.hasExecutionstarted()) { - executionStartedProtoEvent = protoEvent.getExecutionstarted()!; - break; - } - } - - // Start the orchestration span BEFORE execution so failures are traced - const orchTraceContext = req.getOrchestrationtracecontext(); - const tracingResult = executionStartedProtoEvent - ? startSpanForOrchestrationExecution(executionStartedProtoEvent, orchTraceContext, instanceId) - : undefined; - - // Emit retroactive spans for tasks/sub-orchestrations that completed/failed and timers - // that fired. This follows the .NET SDK pattern where these spans are emitted from - // history events BEFORE the orchestrator executor runs. - const orchName = executionStartedProtoEvent?.getName() ?? ""; - if (tracingResult) { - processNewEventsForTracing( - tracingResult.span, - req.getPasteventsList(), - req.getNeweventsList(), - instanceId, - orchName, - ); - } - - let res; - - try { - const executor = new OrchestrationExecutor(this._registry, this._logger); - const result = await executor.execute(req.getInstanceid(), req.getPasteventsList(), req.getNeweventsList()); - - // Process actions to inject trace context into scheduled tasks, sub-orchestrations, etc. - if (tracingResult) { - const executionId = req.getExecutionid()?.getValue(); - processActionsForTracing(tracingResult.span, result.actions, orchName, instanceId, executionId); - } - - res = new pb.OrchestratorResponse(); - res.setInstanceid(req.getInstanceid()); - res.setCompletiontoken(completionToken); - res.setActionsList(result.actions); - if (result.customStatus !== undefined) { - res.setCustomstatus(pbh.getStringValue(result.customStatus)); - } - - // Set the OrchestrationTraceContext on the response for replay continuity - if (tracingResult) { - const orchTraceCtxPb = createOrchestrationTraceContextPb(tracingResult.spanInfo); - res.setOrchestrationtracecontext(orchTraceCtxPb); - - // Set orchestration completion status attribute and span status - // (OK for success, ERROR for failed orchestrations — matching .NET) - setOrchestrationStatusFromActions(tracingResult.span, result.actions); - } - } catch (e: unknown) { - const error = e instanceof Error ? e : new Error(String(e)); - WorkerLogs.executionError(this._logger, req.getInstanceid(), error); - - // Record the failure on the tracing span - if (tracingResult) { - setSpanError(tracingResult.span, error); - // Set just the status attribute — don't call setOrchestrationStatusFromActions - // which would overwrite the specific error message with a generic one - tracingResult.span.setAttribute(DurableTaskAttributes.TASK_STATUS, "Failed"); + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error(String(e)); + WorkerLogs.completionError(this._logger, req.getInstanceid(), error); } - - const failureDetails = pbh.newFailureDetails(error); - - const actions = [ - pbh.newCompleteOrchestrationAction( - -1, - pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, - undefined, - failureDetails, - ), - ]; - - res = new pb.OrchestratorResponse(); - res.setInstanceid(req.getInstanceid()); - res.setCompletiontoken(completionToken); - res.setActionsList(actions); - } finally { - // Always end the orchestration span, regardless of success or failure. - // Status (OK/Error) is set in the respective try/catch branches above. - endSpan(tracingResult?.span); + return; } try { - await callWithMetadata(stub.completeOrchestratorTask.bind(stub), res, this._metadataGenerator); + await callWithMetadata(stub.completeOrchestratorTask.bind(stub), result.response, this._metadataGenerator); } catch (e: unknown) { const error = e instanceof Error ? e : new Error(String(e)); WorkerLogs.completionError(this._logger, req.getInstanceid(), error); @@ -911,8 +756,8 @@ export class TaskHubGrpcWorker { * @param operationInfos - Optional V2 operation info list to include in the result. * * @remarks - * This method looks up the entity by name, creates a TaskEntityShim, executes the batch, - * and sends the result back to the sidecar. + * This method delegates execution to the shared single-work-item executor and sends the + * result back to the sidecar. */ private async _executeEntityInternal( req: pb.EntityBatchRequest, @@ -920,69 +765,13 @@ export class TaskHubGrpcWorker { stub: stubs.TaskHubSidecarServiceClient, operationInfos?: pb.OperationInfo[], ): Promise { - const instanceIdString = req.getInstanceid(); - - if (!instanceIdString) { - throw new Error("Entity request does not contain an instance id"); - } - - // Parse the entity instance ID (format: @name@key) - let entityId: EntityInstanceId; - try { - entityId = EntityInstanceId.fromString(instanceIdString); - } catch (e: any) { - WorkerLogs.entityInstanceIdParseError(this._logger, instanceIdString, e); - // Return error result for all operations - const batchResult = this._createEntityNotFoundResult( - req, - completionToken, - `Invalid entity instance id format: '${instanceIdString}'`, - ); - await this._sendEntityResult(batchResult, stub); - return; - } - - let batchResult: pb.EntityBatchResult; - - try { - // Look up the entity factory by name - const factory = this._registry.getEntity(entityId.name); - - if (factory) { - // Create the entity instance and execute the batch - const entity = factory(); - const shim = new TaskEntityShim(entity, entityId); - batchResult = await shim.executeAsync(req); - batchResult.setCompletiontoken(completionToken); - } else { - // Entity not found - return error result for all operations - WorkerLogs.entityNotFound(this._logger, entityId.name); - batchResult = this._createEntityNotFoundResult( - req, - completionToken, - `No entity task named '${entityId.name}' was found.`, - ); - } - } catch (e: any) { - // Framework-level error - return result with failure details - // This will cause the batch to be abandoned and retried - WorkerLogs.entityExecutionFailed(this._logger, entityId.name, e); - - const failureDetails = pbh.newFailureDetails(e); - - batchResult = new pb.EntityBatchResult(); - batchResult.setCompletiontoken(completionToken); - batchResult.setFailuredetails(failureDetails); - } - - // Add V2 operationInfos if provided (used by DTS backend) - if (operationInfos && operationInfos.length > 0) { - // Take only as many operationInfos as there are results - const resultsCount = batchResult.getResultsList().length; - const infosToInclude = operationInfos.slice(0, resultsCount || operationInfos.length); - batchResult.setOperationinfosList(infosToInclude); - } - + const batchResult = await executeEntityBatchWorkItem( + this._registry, + req, + completionToken, + { logger: this._logger, versioning: this._versioning }, + operationInfos, + ); await this._sendEntityResult(batchResult, stub); } @@ -1008,139 +797,21 @@ export class TaskHubGrpcWorker { * @param stub - The gRPC stub for completing the task. * * @remarks - * This method handles the V2 entity request format which uses HistoryEvent - * instead of OperationRequest. It converts the V2 format to V1 format - * (EntityBatchRequest) and delegates to the existing execution logic. + * This method delegates V2 entity execution to the shared single-work-item executor and + * sends the result back to the sidecar. */ private async _executeEntityV2Internal( req: pb.EntityRequest, completionToken: string, stub: stubs.TaskHubSidecarServiceClient, ): Promise { - // Convert EntityRequest (V2) to EntityBatchRequest (V1) format - const batchRequest = new pb.EntityBatchRequest(); - batchRequest.setInstanceid(req.getInstanceid()); - - // Copy entity state - const entityState = req.getEntitystate(); - if (entityState) { - batchRequest.setEntitystate(entityState); - } - - // Convert HistoryEvent operations to OperationRequest format - // Also build the operationInfos list for V2 responses - const historyEvents = req.getOperationrequestsList(); - const operations: pb.OperationRequest[] = []; - const operationInfos: pb.OperationInfo[] = []; - - for (const event of historyEvents) { - const eventType = event.getEventtypeCase(); - - if (eventType === pb.HistoryEvent.EventtypeCase.ENTITYOPERATIONSIGNALED) { - const signaled = event.getEntityoperationsignaled(); - if (signaled) { - const opRequest = new pb.OperationRequest(); - opRequest.setOperation(signaled.getOperation()); - opRequest.setRequestid(signaled.getRequestid()); - const input = signaled.getInput(); - if (input) { - opRequest.setInput(input); - } - operations.push(opRequest); - - // Build OperationInfo for signaled operations (no response destination) - const opInfo = new pb.OperationInfo(); - opInfo.setRequestid(signaled.getRequestid()); - // Signals don't send a response, so responseDestination is null - operationInfos.push(opInfo); - } - } else if (eventType === pb.HistoryEvent.EventtypeCase.ENTITYOPERATIONCALLED) { - const called = event.getEntityoperationcalled(); - if (called) { - const opRequest = new pb.OperationRequest(); - opRequest.setOperation(called.getOperation()); - opRequest.setRequestid(called.getRequestid()); - const input = called.getInput(); - if (input) { - opRequest.setInput(input); - } - operations.push(opRequest); - - // Build OperationInfo for called operations (with response destination) - const opInfo = new pb.OperationInfo(); - opInfo.setRequestid(called.getRequestid()); - - // Called operations send responses to the parent orchestration - const parentInstanceId = called.getParentinstanceid(); - const parentExecutionId = called.getParentexecutionid(); - if (parentInstanceId || parentExecutionId) { - const responseDestination = new pb.OrchestrationInstance(); - if (parentInstanceId) { - responseDestination.setInstanceid(parentInstanceId.getValue()); - } - if (parentExecutionId) { - // executionId needs to be wrapped in a StringValue - const execIdValue = new StringValue(); - execIdValue.setValue(parentExecutionId.getValue()); - responseDestination.setExecutionid(execIdValue); - } - opInfo.setResponsedestination(responseDestination); - } - operationInfos.push(opInfo); - } - } else { - WorkerLogs.entityUnknownOperationEventType(this._logger, eventType.toString()); - } - } - - batchRequest.setOperationsList(operations); - - // Delegate to the V1 execution logic with V2 operationInfos - await this._executeEntityInternal(batchRequest, completionToken, stub, operationInfos); - } - - /** - * Creates an EntityBatchResult for when an entity is not found. - * - * @remarks - * Returns a non-retriable error for each operation in the batch. - */ - private _createEntityNotFoundResult( - req: pb.EntityBatchRequest, - completionToken: string, - errorMessage: string, - ): pb.EntityBatchResult { - const batchResult = new pb.EntityBatchResult(); - batchResult.setCompletiontoken(completionToken); - - // State is unmodified - return the original state - const originalState = req.getEntitystate(); - if (originalState) { - batchResult.setEntitystate(originalState); - } - - // Create a failure result for each operation in the batch - const operations = req.getOperationsList(); - const results: pb.OperationResult[] = []; - - for (let i = 0; i < operations.length; i++) { - const result = new pb.OperationResult(); - const failure = new pb.OperationResultFailure(); - const failureDetails = new pb.TaskFailureDetails(); - - failureDetails.setErrortype("EntityTaskNotFound"); - failureDetails.setErrormessage(errorMessage); - failureDetails.setIsnonretriable(true); - - failure.setFailuredetails(failureDetails); - result.setFailure(failure); - results.push(result); - } - - batchResult.setResultsList(results); - batchResult.setActionsList([]); - - return batchResult; + const batchResult = await executeEntityWorkItem( + this._registry, + req, + completionToken, + { logger: this._logger, versioning: this._versioning }, + ); + await this._sendEntityResult(batchResult, stub); } /** diff --git a/packages/durabletask-js/src/worker/work-item-executor.ts b/packages/durabletask-js/src/worker/work-item-executor.ts new file mode 100644 index 0000000..2d7e36d --- /dev/null +++ b/packages/durabletask-js/src/worker/work-item-executor.ts @@ -0,0 +1,481 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; +import { EntityInstanceId } from "../entities/entity-instance-id"; +import { EntityFactory } from "../entities/task-entity"; +import * as pb from "../proto/orchestrator_service_pb"; +import { Logger, ConsoleLogger } from "../types/logger.type"; +import * as pbh from "../utils/pb-helper.util"; +import { compareVersions } from "../utils/versioning.util"; +import { + DurableTaskAttributes, + createOrchestrationTraceContextPb, + endSpan, + processActionsForTracing, + processNewEventsForTracing, + setOrchestrationStatusFromActions, + setSpanError, + startSpanForOrchestrationExecution, +} from "../tracing"; +import { TaskEntityShim } from "./entity-executor"; +import * as WorkerLogs from "./logs"; +import { OrchestrationExecutor } from "./orchestration-executor"; +import { Registry } from "./registry"; +import { VersionFailureStrategy, VersioningOptions, VersionMatchStrategy } from "./versioning-options"; + +export interface WorkItemExecutorOptions { + /** Optional logger instance. Defaults to ConsoleLogger. */ + logger?: Logger; + /** Optional versioning options for filtering orchestration requests. */ + versioning?: VersioningOptions; +} + +export interface CompletedOrchestratorWorkItemResult { + kind: "completed"; + response: pb.OrchestratorResponse; +} + +export interface AbandonedOrchestratorWorkItemResult { + kind: "abandoned"; + abandonRequest: pb.AbandonOrchestrationTaskRequest; + errorType?: string; + errorMessage?: string; +} + +export type OrchestratorWorkItemResult = + | CompletedOrchestratorWorkItemResult + | AbandonedOrchestratorWorkItemResult; + +interface VersionCompatibilityResult { + compatible: boolean; + shouldFail: boolean; + orchestrationVersion?: string; + errorType?: string; + errorMessage?: string; +} + +export interface EntityBatchRequestConversion { + batchRequest: pb.EntityBatchRequest; + operationInfos: pb.OperationInfo[]; +} + +/** + * Executes one orchestration work item and returns the sidecar response message. + * + * @remarks + * This helper is intended for host integrations, such as Azure Functions, that + * receive a single TaskHubSidecarService OrchestratorRequest and need to return + * an OrchestratorResponse without running the long-lived gRPC worker loop. It + * follows this package's runtime support matrix, which currently requires + * Node.js 22 or higher. + */ +export async function executeOrchestratorWorkItem( + registry: Registry, + req: pb.OrchestratorRequest, + completionToken: string = "", + options?: WorkItemExecutorOptions, +): Promise { + const logger = options?.logger ?? new ConsoleLogger(); + const instanceId = req.getInstanceid(); + + if (!instanceId) { + throw new Error(`Could not execute the orchestrator as the instanceId was not provided (${instanceId})`); + } + + const versionCheckResult = checkVersionCompatibility(req, options?.versioning); + if (!versionCheckResult.compatible) { + if (versionCheckResult.shouldFail) { + WorkerLogs.versionMismatchFail( + logger, + instanceId, + versionCheckResult.errorType!, + versionCheckResult.errorMessage!, + ); + + const failureDetails = pbh.newVersionMismatchFailureDetails( + versionCheckResult.errorType!, + versionCheckResult.errorMessage!, + ); + + const response = new pb.OrchestratorResponse(); + response.setInstanceid(instanceId); + response.setCompletiontoken(completionToken); + response.setActionsList([ + pbh.newCompleteOrchestrationAction( + -1, + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + undefined, + failureDetails, + ), + ]); + + return { kind: "completed", response }; + } + + WorkerLogs.versionMismatchAbandon( + logger, + instanceId, + versionCheckResult.errorType!, + versionCheckResult.errorMessage!, + ); + + const abandonRequest = new pb.AbandonOrchestrationTaskRequest(); + abandonRequest.setCompletiontoken(completionToken); + + return { + kind: "abandoned", + abandonRequest, + errorType: versionCheckResult.errorType, + errorMessage: versionCheckResult.errorMessage, + }; + } + + const allProtoEvents = [...req.getPasteventsList(), ...req.getNeweventsList()]; + let executionStartedProtoEvent: pb.ExecutionStartedEvent | undefined; + for (const protoEvent of allProtoEvents) { + if (protoEvent.hasExecutionstarted()) { + executionStartedProtoEvent = protoEvent.getExecutionstarted()!; + break; + } + } + + const orchTraceContext = req.getOrchestrationtracecontext(); + const tracingResult = executionStartedProtoEvent + ? startSpanForOrchestrationExecution(executionStartedProtoEvent, orchTraceContext, instanceId) + : undefined; + + const orchName = executionStartedProtoEvent?.getName() ?? ""; + if (tracingResult) { + processNewEventsForTracing( + tracingResult.span, + req.getPasteventsList(), + req.getNeweventsList(), + instanceId, + orchName, + ); + } + + let response: pb.OrchestratorResponse; + + try { + const executor = new OrchestrationExecutor(registry, logger); + const result = await executor.execute(instanceId, req.getPasteventsList(), req.getNeweventsList()); + + if (tracingResult) { + const executionId = req.getExecutionid()?.getValue(); + processActionsForTracing(tracingResult.span, result.actions, orchName, instanceId, executionId); + } + + response = new pb.OrchestratorResponse(); + response.setInstanceid(instanceId); + response.setCompletiontoken(completionToken); + response.setActionsList(result.actions); + + if (result.customStatus !== undefined) { + response.setCustomstatus(pbh.getStringValue(result.customStatus)); + } + + if (tracingResult) { + response.setOrchestrationtracecontext(createOrchestrationTraceContextPb(tracingResult.spanInfo)); + setOrchestrationStatusFromActions(tracingResult.span, result.actions); + } + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error(String(e)); + WorkerLogs.executionError(logger, instanceId, error); + + if (tracingResult) { + setSpanError(tracingResult.span, error); + tracingResult.span.setAttribute(DurableTaskAttributes.TASK_STATUS, "Failed"); + } + + response = new pb.OrchestratorResponse(); + response.setInstanceid(instanceId); + response.setCompletiontoken(completionToken); + response.setActionsList([ + pbh.newCompleteOrchestrationAction( + -1, + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + undefined, + pbh.newFailureDetails(error), + ), + ]); + } finally { + endSpan(tracingResult?.span); + } + + return { kind: "completed", response }; +} + +/** + * Executes one entity batch work item and returns the sidecar result message. + * + * @remarks + * This helper is intended for host integrations, such as Azure Functions, that + * receive a single TaskHubSidecarService EntityBatchRequest and need to return + * an EntityBatchResult without running the long-lived gRPC worker loop. It + * follows this package's runtime support matrix, which currently requires + * Node.js 22 or higher. + */ +export async function executeEntityBatchWorkItem( + registry: Registry, + req: pb.EntityBatchRequest, + completionToken: string = "", + options?: WorkItemExecutorOptions, + operationInfos?: pb.OperationInfo[], +): Promise { + const logger = options?.logger ?? new ConsoleLogger(); + const instanceIdString = req.getInstanceid(); + + if (!instanceIdString) { + throw new Error("Entity request does not contain an instance id"); + } + + let entityId: EntityInstanceId; + try { + entityId = EntityInstanceId.fromString(instanceIdString); + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error(String(e)); + WorkerLogs.entityInstanceIdParseError(logger, instanceIdString, error); + return createEntityNotFoundResult( + req, + completionToken, + `Invalid entity instance id format: '${instanceIdString}'`, + ); + } + + let batchResult: pb.EntityBatchResult; + + try { + const factory = registry.getEntity(entityId.name); + + if (factory) { + batchResult = await executeRegisteredEntity(factory, entityId, req); + batchResult.setCompletiontoken(completionToken); + } else { + WorkerLogs.entityNotFound(logger, entityId.name); + batchResult = createEntityNotFoundResult( + req, + completionToken, + `No entity task named '${entityId.name}' was found.`, + ); + } + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error(String(e)); + WorkerLogs.entityExecutionFailed(logger, entityId.name, error); + + batchResult = new pb.EntityBatchResult(); + batchResult.setCompletiontoken(completionToken); + batchResult.setFailuredetails(pbh.newFailureDetails(error)); + } + + if (operationInfos && operationInfos.length > 0) { + const resultsCount = batchResult.getResultsList().length; + const infosToInclude = operationInfos.slice(0, resultsCount || operationInfos.length); + batchResult.setOperationinfosList(infosToInclude); + } + + return batchResult; +} + +/** + * Converts and executes one V2 entity work item. + */ +export async function executeEntityWorkItem( + registry: Registry, + req: pb.EntityRequest, + completionToken: string = "", + options?: WorkItemExecutorOptions, +): Promise { + const conversion = convertEntityRequestToBatchRequest(req, options?.logger); + return executeEntityBatchWorkItem( + registry, + conversion.batchRequest, + completionToken, + options, + conversion.operationInfos, + ); +} + +export function convertEntityRequestToBatchRequest( + req: pb.EntityRequest, + logger?: Logger, +): EntityBatchRequestConversion { + const batchRequest = new pb.EntityBatchRequest(); + batchRequest.setInstanceid(req.getInstanceid()); + + const entityState = req.getEntitystate(); + if (entityState) { + batchRequest.setEntitystate(entityState); + } + + const operations: pb.OperationRequest[] = []; + const operationInfos: pb.OperationInfo[] = []; + + for (const event of req.getOperationrequestsList()) { + const eventType = event.getEventtypeCase(); + + if (eventType === pb.HistoryEvent.EventtypeCase.ENTITYOPERATIONSIGNALED) { + const signaled = event.getEntityoperationsignaled(); + if (signaled) { + const opRequest = new pb.OperationRequest(); + opRequest.setOperation(signaled.getOperation()); + opRequest.setRequestid(signaled.getRequestid()); + const input = signaled.getInput(); + if (input) { + opRequest.setInput(input); + } + operations.push(opRequest); + + const opInfo = new pb.OperationInfo(); + opInfo.setRequestid(signaled.getRequestid()); + operationInfos.push(opInfo); + } + } else if (eventType === pb.HistoryEvent.EventtypeCase.ENTITYOPERATIONCALLED) { + const called = event.getEntityoperationcalled(); + if (called) { + const opRequest = new pb.OperationRequest(); + opRequest.setOperation(called.getOperation()); + opRequest.setRequestid(called.getRequestid()); + const input = called.getInput(); + if (input) { + opRequest.setInput(input); + } + operations.push(opRequest); + + const opInfo = new pb.OperationInfo(); + opInfo.setRequestid(called.getRequestid()); + + const parentInstanceId = called.getParentinstanceid(); + const parentExecutionId = called.getParentexecutionid(); + if (parentInstanceId || parentExecutionId) { + const responseDestination = new pb.OrchestrationInstance(); + if (parentInstanceId) { + responseDestination.setInstanceid(parentInstanceId.getValue()); + } + if (parentExecutionId) { + const execIdValue = new StringValue(); + execIdValue.setValue(parentExecutionId.getValue()); + responseDestination.setExecutionid(execIdValue); + } + opInfo.setResponsedestination(responseDestination); + } + operationInfos.push(opInfo); + } + } else { + WorkerLogs.entityUnknownOperationEventType(logger ?? new ConsoleLogger(), eventType.toString()); + } + } + + batchRequest.setOperationsList(operations); + return { batchRequest, operationInfos }; +} + +function checkVersionCompatibility( + req: pb.OrchestratorRequest, + versioning?: VersioningOptions, +): VersionCompatibilityResult { + if (!versioning || versioning.matchStrategy === VersionMatchStrategy.None) { + return { compatible: true, shouldFail: false }; + } + + const orchestrationVersion = getOrchestrationVersion(req); + const workerVersion = versioning.version; + + if (!workerVersion) { + return { compatible: true, shouldFail: false }; + } + + let compatible = false; + let errorType = "VersionMismatch"; + let errorMessage = ""; + + switch (versioning.matchStrategy) { + case VersionMatchStrategy.Strict: + compatible = compareVersions(orchestrationVersion, workerVersion) === 0; + if (!compatible) { + errorMessage = `The orchestration version '${orchestrationVersion ?? ""}' does not match the worker version '${workerVersion}'.`; + } + break; + + case VersionMatchStrategy.CurrentOrOlder: + if (!orchestrationVersion) { + compatible = true; + } else { + compatible = compareVersions(orchestrationVersion, workerVersion) <= 0; + if (!compatible) { + errorMessage = `The orchestration version '${orchestrationVersion}' is greater than the worker version '${workerVersion}'.`; + } + } + break; + + default: + compatible = false; + errorType = "VersionError"; + errorMessage = `The version match strategy '${versioning.matchStrategy}' is unknown.`; + break; + } + + if (!compatible) { + const shouldFail = versioning.failureStrategy === VersionFailureStrategy.Fail; + return { compatible: false, shouldFail, orchestrationVersion, errorType, errorMessage }; + } + + return { compatible: true, shouldFail: false }; +} + +function getOrchestrationVersion(req: pb.OrchestratorRequest): string | undefined { + const allEvents = [...req.getPasteventsList(), ...req.getNeweventsList()]; + + for (const event of allEvents) { + if (event.hasExecutionstarted()) { + return event.getExecutionstarted()?.getVersion()?.getValue(); + } + } + + return undefined; +} + +function createEntityNotFoundResult( + req: pb.EntityBatchRequest, + completionToken: string, + errorMessage: string, +): pb.EntityBatchResult { + const batchResult = new pb.EntityBatchResult(); + batchResult.setCompletiontoken(completionToken); + + const originalState = req.getEntitystate(); + if (originalState) { + batchResult.setEntitystate(originalState); + } + + const results: pb.OperationResult[] = []; + for (let i = 0; i < req.getOperationsList().length; i++) { + const result = new pb.OperationResult(); + const failure = new pb.OperationResultFailure(); + const failureDetails = new pb.TaskFailureDetails(); + + failureDetails.setErrortype("EntityTaskNotFound"); + failureDetails.setErrormessage(errorMessage); + failureDetails.setIsnonretriable(true); + + failure.setFailuredetails(failureDetails); + result.setFailure(failure); + results.push(result); + } + + batchResult.setResultsList(results); + batchResult.setActionsList([]); + + return batchResult; +} + +async function executeRegisteredEntity( + factory: EntityFactory, + entityId: EntityInstanceId, + req: pb.EntityBatchRequest, +): Promise { + const entity = factory(); + const shim = new TaskEntityShim(entity, entityId); + return shim.executeAsync(req); +} diff --git a/packages/durabletask-js/test/functions-grpc-support.spec.ts b/packages/durabletask-js/test/functions-grpc-support.spec.ts new file mode 100644 index 0000000..7d9c2be --- /dev/null +++ b/packages/durabletask-js/test/functions-grpc-support.spec.ts @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + EntityBatchRequest, + EntityBatchResult, + EntityRequest, + OrchestrationContext, + OrchestratorRequest, + OrchestratorResponse, + TaskEntity, + TaskHubGrpcWorker, + TOrchestrator, + decodeEntityBatchRequestFromBase64, + decodeEntityRequestFromBase64, + decodeOrchestratorRequestFromBase64, + encodeEntityBatchResultToBase64, + encodeOrchestratorResponseToBase64, +} from "../src"; +import * as pb from "../src/proto/orchestrator_service_pb"; +import { + newExecutionStartedEvent, + newOrchestratorStartedEvent, +} from "../src/utils/pb-helper.util"; +import { NoOpLogger } from "../src/types/logger.type"; + +const TEST_INSTANCE_ID = "functions-grpc-instance"; +const COMPLETION_TOKEN = "functions-completion-token"; + +class CounterEntity extends TaskEntity { + increment(): number { + this.state++; + return this.state; + } + + protected initializeState(): number { + return 0; + } +} + +describe("Functions gRPC support surface", () => { + it("round-trips orchestration request and response protobufs through base64 helpers", () => { + const request = new OrchestratorRequest(); + request.setInstanceid(TEST_INSTANCE_ID); + + const encodedRequest = Buffer.from(request.serializeBinary()).toString("base64"); + const decodedRequest = decodeOrchestratorRequestFromBase64(encodedRequest); + + expect(decodedRequest).toBeInstanceOf(OrchestratorRequest); + expect(decodedRequest.getInstanceid()).toBe(TEST_INSTANCE_ID); + + const response = new OrchestratorResponse(); + response.setInstanceid(TEST_INSTANCE_ID); + response.setCompletiontoken(COMPLETION_TOKEN); + + const decodedResponse = OrchestratorResponse.deserializeBinary( + Buffer.from(encodeOrchestratorResponseToBase64(response), "base64"), + ); + + expect(decodedResponse.getInstanceid()).toBe(TEST_INSTANCE_ID); + expect(decodedResponse.getCompletiontoken()).toBe(COMPLETION_TOKEN); + }); + + it("round-trips entity request and result protobufs through base64 helpers", () => { + const request = createEntityBatchRequest("counter", "key1"); + + const encodedRequest = Buffer.from(request.serializeBinary()).toString("base64"); + const decodedRequest = decodeEntityBatchRequestFromBase64(encodedRequest); + + expect(decodedRequest).toBeInstanceOf(EntityBatchRequest); + expect(decodedRequest.getInstanceid()).toBe("@counter@key1"); + expect(decodedRequest.getOperationsList()[0].getOperation()).toBe("increment"); + + const result = new EntityBatchResult(); + result.setCompletiontoken(COMPLETION_TOKEN); + + const decodedResult = EntityBatchResult.deserializeBinary( + Buffer.from(encodeEntityBatchResultToBase64(result), "base64"), + ); + + expect(decodedResult.getCompletiontoken()).toBe(COMPLETION_TOKEN); + }); + + it("executes a single orchestration request without using the gRPC worker loop", async () => { + const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); + const orchestrator: TOrchestrator = async (_ctx: OrchestrationContext) => "done"; + const name = worker.addOrchestrator(orchestrator); + + const request = new OrchestratorRequest(); + request.setInstanceid(TEST_INSTANCE_ID); + request.setNeweventsList([ + newOrchestratorStartedEvent(new Date("2026-01-01T00:00:00.000Z")), + newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined), + ]); + + const response = await worker.executeOrchestratorRequest(request, COMPLETION_TOKEN); + + expect(response.getInstanceid()).toBe(TEST_INSTANCE_ID); + expect(response.getCompletiontoken()).toBe(COMPLETION_TOKEN); + expect(response.getActionsList()).toHaveLength(1); + const completed = response.getActionsList()[0].getCompleteorchestration(); + expect(completed?.getOrchestrationstatus()).toBe( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED, + ); + expect(completed?.getResult()?.getValue()).toBe('"done"'); + }); + + it("executes a single entity batch request without using the gRPC worker loop", async () => { + const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); + worker.addNamedEntity("counter", () => new CounterEntity()); + + const response = await worker.executeEntityBatchRequest( + createEntityBatchRequest("counter", "key1"), + COMPLETION_TOKEN, + ); + + expect(response.getCompletiontoken()).toBe(COMPLETION_TOKEN); + expect(response.getResultsList()).toHaveLength(1); + expect(response.getResultsList()[0].getSuccess()?.getResult()?.getValue()).toBe("1"); + expect(response.getEntitystate()?.getValue()).toBe("1"); + }); + + it("decodes and executes a single V2 entity request", async () => { + const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); + worker.addNamedEntity("counter", () => new CounterEntity()); + + const request = new EntityRequest(); + request.setInstanceid("@counter@key1"); + const operationEvent = new pb.HistoryEvent(); + const operation = new pb.EntityOperationSignaledEvent(); + operation.setOperation("increment"); + operation.setRequestid("req-1"); + operationEvent.setEntityoperationsignaled(operation); + request.setOperationrequestsList([operationEvent]); + + const decodedRequest = decodeEntityRequestFromBase64( + Buffer.from(request.serializeBinary()).toString("base64"), + ); + const response = await worker.executeEntityRequest(decodedRequest, COMPLETION_TOKEN); + + expect(response.getCompletiontoken()).toBe(COMPLETION_TOKEN); + expect(response.getResultsList()[0].getSuccess()?.getResult()?.getValue()).toBe("1"); + expect(response.getOperationinfosList()[0].getRequestid()).toBe("req-1"); + }); +}); + +function createEntityBatchRequest(entityName: string, entityKey: string): EntityBatchRequest { + const request = new EntityBatchRequest(); + request.setInstanceid(`@${entityName}@${entityKey}`); + + const operation = new pb.OperationRequest(); + operation.setOperation("increment"); + operation.setRequestid("req-1"); + request.setOperationsList([operation]); + + return request; +} From c272a5550e15de8ddbb312202988c3902002decb Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 29 Jun 2026 15:32:47 -0700 Subject: [PATCH 2/7] Add raw Functions gRPC worker processors Expose stable byte-oriented worker processing methods and endpoint/taskHub client options for Azure Functions Durable JS gRPC consolidation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 16 ++++++++++ packages/durabletask-js/src/client/client.ts | 27 ++++++++++++++-- .../src/worker/task-hub-grpc-worker.ts | 28 +++++++++++++++++ .../test/client-options.spec.ts | 30 ++++++++++++++++++ .../test/functions-grpc-support.spec.ts | 31 +++++++++++++++++++ 5 files changed, 130 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 706714a..1149d74 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,22 @@ This repo contains a JavaScript/TypeScript SDK for use with the [Azure Durable T > Note that this SDK does **not** provide the [Azure Durable Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) programming model, decorators, or worker-indexing metadata. If you are looking for a JavaScript SDK for Azure Durable Functions, please see [this repo](https://github.com/Azure/azure-functions-durable-js). This package exposes low-level TaskHubSidecarService gRPC/protobuf helpers that host integrations can reuse; those helpers follow this package's Node.js 22+ requirement. +## Low-level host integration APIs + +Host integrations that already own trigger metadata and transport encoding can depend on the `@microsoft/durabletask-js` package directly. `TaskHubGrpcWorker` registers orchestrators, activities, and entities, and can process raw TaskHubSidecarService protobuf payloads without starting the long-running gRPC worker loop: + +```typescript +const worker = new TaskHubGrpcWorker(); +worker.addOrchestrator(myOrchestrator); +worker.addActivity(myActivity); +worker.addEntity(myEntity); + +const orchestrationResponseBytes = await worker.processOrchestratorRequest(orchestrationRequestBytes); +const entityResponseBytes = await worker.processEntityBatchRequest(entityBatchRequestBytes); +``` + +`TaskHubGrpcClient` connects to a gRPC endpoint using `new TaskHubGrpcClient({ endpoint, taskHub })` or the existing `hostAddress` option. It exposes orchestration start/query/event/terminate/suspend/resume/purge APIs and entity signal/read/query/clean APIs. Azure-managed scheduler connection strings remain in `@microsoft/durabletask-js-azuremanaged`. + ## npm packages The following npm packages are available for download. diff --git a/packages/durabletask-js/src/client/client.ts b/packages/durabletask-js/src/client/client.ts index 0dea458..86dabee 100644 --- a/packages/durabletask-js/src/client/client.ts +++ b/packages/durabletask-js/src/client/client.ts @@ -51,12 +51,33 @@ import { // Re-export MetadataGenerator for backward compatibility export { MetadataGenerator } from "../utils/grpc-helper.util"; +function createMetadataGenerator( + metadataGenerator?: MetadataGenerator, + taskHub?: string, +): MetadataGenerator | undefined { + if (!taskHub) { + return metadataGenerator; + } + + return async () => { + const metadata = metadataGenerator ? await metadataGenerator() : new grpc.Metadata(); + if (metadata.get("taskhub").length === 0) { + metadata.set("taskhub", taskHub); + } + return metadata; + }; +} + /** * Options for creating a TaskHubGrpcClient. */ export interface TaskHubGrpcClientOptions { /** The host address to connect to. Defaults to "localhost:4001". */ hostAddress?: string; + /** Alias for hostAddress, intended for host integrations that expose an endpoint setting. */ + endpoint?: string; + /** Optional task hub name to send as gRPC metadata using the "taskhub" key. */ + taskHub?: string; /** gRPC channel options. */ options?: grpc.ChannelOptions; /** Whether to use TLS. Defaults to false. */ @@ -123,16 +144,18 @@ export class TaskHubGrpcClient { let resolvedMetadataGenerator: MetadataGenerator | undefined; let resolvedLogger: Logger | undefined; let resolvedDefaultVersion: string | undefined; + let resolvedTaskHub: string | undefined; if (typeof hostAddressOrOptions === "object" && hostAddressOrOptions !== null) { // Options object constructor - resolvedHostAddress = hostAddressOrOptions.hostAddress; + resolvedHostAddress = hostAddressOrOptions.hostAddress ?? hostAddressOrOptions.endpoint; resolvedOptions = hostAddressOrOptions.options; resolvedUseTLS = hostAddressOrOptions.useTLS; resolvedCredentials = hostAddressOrOptions.credentials; resolvedMetadataGenerator = hostAddressOrOptions.metadataGenerator; resolvedLogger = hostAddressOrOptions.logger; resolvedDefaultVersion = hostAddressOrOptions.defaultVersion; + resolvedTaskHub = hostAddressOrOptions.taskHub; } else { // Deprecated positional parameters constructor resolvedHostAddress = hostAddressOrOptions; @@ -144,7 +167,7 @@ export class TaskHubGrpcClient { } this._stub = new GrpcClient(resolvedHostAddress, resolvedOptions, resolvedUseTLS, resolvedCredentials).stub; - this._metadataGenerator = resolvedMetadataGenerator; + this._metadataGenerator = createMetadataGenerator(resolvedMetadataGenerator, resolvedTaskHub); this._logger = resolvedLogger ?? new ConsoleLogger(); this._defaultVersion = resolvedDefaultVersion; } 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 a9d1db6..9630716 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -338,6 +338,20 @@ export class TaskHubGrpcWorker { return result.response; } + /** + * Processes a serialized TaskHubSidecarService OrchestratorRequest and returns + * the serialized OrchestratorResponse. + * + * @remarks + * Host integrations should handle any transport-specific base64 conversion and + * pass raw protobuf bytes to this method. + */ + async processOrchestratorRequest(request: Uint8Array | Buffer): Promise { + const req = pb.OrchestratorRequest.deserializeBinary(request); + const response = await this.executeOrchestratorRequest(req); + return response.serializeBinary(); + } + /** * Executes a single entity batch request and returns the result without * completing it over gRPC. @@ -360,6 +374,20 @@ export class TaskHubGrpcWorker { ); } + /** + * Processes a serialized TaskHubSidecarService EntityBatchRequest and returns + * the serialized EntityBatchResult. + * + * @remarks + * Host integrations should handle any transport-specific base64 conversion and + * pass raw protobuf bytes to this method. + */ + async processEntityBatchRequest(request: Uint8Array | Buffer): Promise { + const req = pb.EntityBatchRequest.deserializeBinary(request); + const response = await this.executeEntityBatchRequest(req); + return response.serializeBinary(); + } + /** * Executes a single V2 entity request and returns the batch result without * completing it over gRPC. diff --git a/packages/durabletask-js/test/client-options.spec.ts b/packages/durabletask-js/test/client-options.spec.ts index 579d065..57d491c 100644 --- a/packages/durabletask-js/test/client-options.spec.ts +++ b/packages/durabletask-js/test/client-options.spec.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import * as grpc from "@grpc/grpc-js"; import { TaskHubGrpcClient, TaskHubGrpcClientOptions } from "../src"; describe("TaskHubGrpcClient", () => { @@ -38,5 +39,34 @@ describe("TaskHubGrpcClient", () => { expect(client).toBeDefined(); }); + + it("should accept endpoint alias and task hub metadata option", async () => { + const client = new TaskHubGrpcClient({ + endpoint: "localhost:4001", + taskHub: "functions-taskhub", + }); + + const metadata = await (client as any)._metadataGenerator(); + + expect(metadata.get("taskhub")).toEqual(["functions-taskhub"]); + }); + + it("should preserve taskhub from custom metadata generator", async () => { + const client = new TaskHubGrpcClient({ + endpoint: "localhost:4001", + taskHub: "options-taskhub", + metadataGenerator: async () => { + const metadata = new grpc.Metadata(); + metadata.set("taskhub", "custom-taskhub"); + metadata.set("x-test", "value"); + return metadata; + }, + }); + + const metadata = await (client as any)._metadataGenerator(); + + expect(metadata.get("taskhub")).toEqual(["custom-taskhub"]); + expect(metadata.get("x-test")).toEqual(["value"]); + }); }); }); diff --git a/packages/durabletask-js/test/functions-grpc-support.spec.ts b/packages/durabletask-js/test/functions-grpc-support.spec.ts index 7d9c2be..aa4f002 100644 --- a/packages/durabletask-js/test/functions-grpc-support.spec.ts +++ b/packages/durabletask-js/test/functions-grpc-support.spec.ts @@ -105,6 +105,25 @@ describe("Functions gRPC support surface", () => { expect(completed?.getResult()?.getValue()).toBe('"done"'); }); + it("processes serialized orchestration request bytes", async () => { + const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); + const orchestrator: TOrchestrator = async (_ctx: OrchestrationContext) => "done"; + const name = worker.addOrchestrator(orchestrator); + + const request = new OrchestratorRequest(); + request.setInstanceid(TEST_INSTANCE_ID); + request.setNeweventsList([ + newOrchestratorStartedEvent(new Date("2026-01-01T00:00:00.000Z")), + newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined), + ]); + + const responseBytes = await worker.processOrchestratorRequest(Buffer.from(request.serializeBinary())); + const response = OrchestratorResponse.deserializeBinary(responseBytes); + + expect(response.getInstanceid()).toBe(TEST_INSTANCE_ID); + expect(response.getActionsList()[0].getCompleteorchestration()?.getResult()?.getValue()).toBe('"done"'); + }); + it("executes a single entity batch request without using the gRPC worker loop", async () => { const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); worker.addNamedEntity("counter", () => new CounterEntity()); @@ -120,6 +139,18 @@ describe("Functions gRPC support surface", () => { expect(response.getEntitystate()?.getValue()).toBe("1"); }); + it("processes serialized entity batch request bytes", async () => { + const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); + worker.addNamedEntity("counter", () => new CounterEntity()); + + const request = createEntityBatchRequest("counter", "key1"); + const responseBytes = await worker.processEntityBatchRequest(request.serializeBinary()); + const response = EntityBatchResult.deserializeBinary(responseBytes); + + expect(response.getResultsList()[0].getSuccess()?.getResult()?.getValue()).toBe("1"); + expect(response.getEntitystate()?.getValue()).toBe("1"); + }); + it("decodes and executes a single V2 entity request", async () => { const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); worker.addNamedEntity("counter", () => new CounterEntity()); From fb3a55be855fb97c9e05010602129d7945f9584d Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 29 Jun 2026 16:59:44 -0700 Subject: [PATCH 3/7] Remove extra client task hub shortcut Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- packages/durabletask-js/src/client/client.ts | 27 ++--------------- .../test/client-options.spec.ts | 29 ------------------- 3 files changed, 3 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 1149d74..6385dd6 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ const orchestrationResponseBytes = await worker.processOrchestratorRequest(orche const entityResponseBytes = await worker.processEntityBatchRequest(entityBatchRequestBytes); ``` -`TaskHubGrpcClient` connects to a gRPC endpoint using `new TaskHubGrpcClient({ endpoint, taskHub })` or the existing `hostAddress` option. It exposes orchestration start/query/event/terminate/suspend/resume/purge APIs and entity signal/read/query/clean APIs. Azure-managed scheduler connection strings remain in `@microsoft/durabletask-js-azuremanaged`. +`TaskHubGrpcClient` already exposes orchestration start/query/event/terminate/suspend/resume/purge APIs and entity signal/read/query/clean APIs through its existing `hostAddress` and `metadataGenerator` options. Host integrations that need task-hub routing metadata should provide it through `metadataGenerator`, keeping host-specific metadata policy outside the core client. Azure-managed scheduler connection strings remain in `@microsoft/durabletask-js-azuremanaged`. ## npm packages diff --git a/packages/durabletask-js/src/client/client.ts b/packages/durabletask-js/src/client/client.ts index 86dabee..0dea458 100644 --- a/packages/durabletask-js/src/client/client.ts +++ b/packages/durabletask-js/src/client/client.ts @@ -51,33 +51,12 @@ import { // Re-export MetadataGenerator for backward compatibility export { MetadataGenerator } from "../utils/grpc-helper.util"; -function createMetadataGenerator( - metadataGenerator?: MetadataGenerator, - taskHub?: string, -): MetadataGenerator | undefined { - if (!taskHub) { - return metadataGenerator; - } - - return async () => { - const metadata = metadataGenerator ? await metadataGenerator() : new grpc.Metadata(); - if (metadata.get("taskhub").length === 0) { - metadata.set("taskhub", taskHub); - } - return metadata; - }; -} - /** * Options for creating a TaskHubGrpcClient. */ export interface TaskHubGrpcClientOptions { /** The host address to connect to. Defaults to "localhost:4001". */ hostAddress?: string; - /** Alias for hostAddress, intended for host integrations that expose an endpoint setting. */ - endpoint?: string; - /** Optional task hub name to send as gRPC metadata using the "taskhub" key. */ - taskHub?: string; /** gRPC channel options. */ options?: grpc.ChannelOptions; /** Whether to use TLS. Defaults to false. */ @@ -144,18 +123,16 @@ export class TaskHubGrpcClient { let resolvedMetadataGenerator: MetadataGenerator | undefined; let resolvedLogger: Logger | undefined; let resolvedDefaultVersion: string | undefined; - let resolvedTaskHub: string | undefined; if (typeof hostAddressOrOptions === "object" && hostAddressOrOptions !== null) { // Options object constructor - resolvedHostAddress = hostAddressOrOptions.hostAddress ?? hostAddressOrOptions.endpoint; + resolvedHostAddress = hostAddressOrOptions.hostAddress; resolvedOptions = hostAddressOrOptions.options; resolvedUseTLS = hostAddressOrOptions.useTLS; resolvedCredentials = hostAddressOrOptions.credentials; resolvedMetadataGenerator = hostAddressOrOptions.metadataGenerator; resolvedLogger = hostAddressOrOptions.logger; resolvedDefaultVersion = hostAddressOrOptions.defaultVersion; - resolvedTaskHub = hostAddressOrOptions.taskHub; } else { // Deprecated positional parameters constructor resolvedHostAddress = hostAddressOrOptions; @@ -167,7 +144,7 @@ export class TaskHubGrpcClient { } this._stub = new GrpcClient(resolvedHostAddress, resolvedOptions, resolvedUseTLS, resolvedCredentials).stub; - this._metadataGenerator = createMetadataGenerator(resolvedMetadataGenerator, resolvedTaskHub); + this._metadataGenerator = resolvedMetadataGenerator; this._logger = resolvedLogger ?? new ConsoleLogger(); this._defaultVersion = resolvedDefaultVersion; } diff --git a/packages/durabletask-js/test/client-options.spec.ts b/packages/durabletask-js/test/client-options.spec.ts index 57d491c..38fa7d1 100644 --- a/packages/durabletask-js/test/client-options.spec.ts +++ b/packages/durabletask-js/test/client-options.spec.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import * as grpc from "@grpc/grpc-js"; import { TaskHubGrpcClient, TaskHubGrpcClientOptions } from "../src"; describe("TaskHubGrpcClient", () => { @@ -40,33 +39,5 @@ describe("TaskHubGrpcClient", () => { expect(client).toBeDefined(); }); - it("should accept endpoint alias and task hub metadata option", async () => { - const client = new TaskHubGrpcClient({ - endpoint: "localhost:4001", - taskHub: "functions-taskhub", - }); - - const metadata = await (client as any)._metadataGenerator(); - - expect(metadata.get("taskhub")).toEqual(["functions-taskhub"]); - }); - - it("should preserve taskhub from custom metadata generator", async () => { - const client = new TaskHubGrpcClient({ - endpoint: "localhost:4001", - taskHub: "options-taskhub", - metadataGenerator: async () => { - const metadata = new grpc.Metadata(); - metadata.set("taskhub", "custom-taskhub"); - metadata.set("x-test", "value"); - return metadata; - }, - }); - - const metadata = await (client as any)._metadataGenerator(); - - expect(metadata.get("taskhub")).toEqual(["custom-taskhub"]); - expect(metadata.get("x-test")).toEqual(["value"]); - }); }); }); From fe7c594a4c9eff658075918358a6ed9dbaa54fda Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 30 Jun 2026 09:12:49 -0700 Subject: [PATCH 4/7] Align Functions gRPC support with Python and drop extra surface Replace the work-item-executor extraction and base64 protobuf helpers with a minimal, Python-aligned implementation. processOrchestratorRequest and processEntityBatchRequest now reuse the existing worker execution path via an in-process capturing stub (mirroring durabletask-python PR #155's null-stub pattern) instead of refactoring the worker. Removed the base64 helper module, the object-level execute* methods, the V2 EntityRequest host path, and the related exports and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/durabletask-js/src/index.ts | 32 +- .../src/protocol/base64-protobuf.ts | 56 -- .../src/worker/task-hub-grpc-worker.ts | 634 ++++++++++++++---- .../src/worker/work-item-executor.ts | 481 ------------- .../test/client-options.spec.ts | 1 - .../test/functions-grpc-support.spec.ts | 144 +--- 6 files changed, 528 insertions(+), 820 deletions(-) delete mode 100644 packages/durabletask-js/src/protocol/base64-protobuf.ts delete mode 100644 packages/durabletask-js/src/worker/work-item-executor.ts diff --git a/packages/durabletask-js/src/index.ts b/packages/durabletask-js/src/index.ts index 360889a..bbc5542 100644 --- a/packages/durabletask-js/src/index.ts +++ b/packages/durabletask-js/src/index.ts @@ -4,18 +4,6 @@ // Client and Worker export { TaskHubGrpcClient, TaskHubGrpcClientOptions, MetadataGenerator } from "./client/client"; export { TaskHubGrpcWorker, TaskHubGrpcWorkerOptions } from "./worker/task-hub-grpc-worker"; -export { Registry } from "./worker/registry"; -export { - WorkItemExecutorOptions, - CompletedOrchestratorWorkItemResult, - AbandonedOrchestratorWorkItemResult, - EntityBatchRequestConversion, - OrchestratorWorkItemResult, - executeOrchestratorWorkItem, - executeEntityBatchWorkItem, - executeEntityWorkItem, - convertEntityRequestToBatchRequest, -} from "./worker/work-item-executor"; export { VersioningOptions, VersionMatchStrategy, VersionFailureStrategy } from "./worker/versioning-options"; export { WorkItemFilters, @@ -80,25 +68,7 @@ export { } from "./orchestration/history-event"; // Proto types (for advanced usage) -export { - OrchestrationStatus as ProtoOrchestrationStatus, - OrchestratorRequest, - OrchestratorResponse, - EntityBatchRequest, - EntityBatchResult, - EntityRequest, -} from "./proto/orchestrator_service_pb"; - -// Base64 protobuf helpers for host integrations -export { - decodeBase64Protobuf, - encodeBase64Protobuf, - decodeOrchestratorRequestFromBase64, - encodeOrchestratorResponseToBase64, - decodeEntityBatchRequestFromBase64, - encodeEntityBatchResultToBase64, - decodeEntityRequestFromBase64, -} from "./protocol/base64-protobuf"; +export { OrchestrationStatus as ProtoOrchestrationStatus } from "./proto/orchestrator_service_pb"; // Failure details export { FailureDetails, TaskFailureDetails } from "./task/failure-details"; diff --git a/packages/durabletask-js/src/protocol/base64-protobuf.ts b/packages/durabletask-js/src/protocol/base64-protobuf.ts deleted file mode 100644 index 89da082..0000000 --- a/packages/durabletask-js/src/protocol/base64-protobuf.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { Message } from "google-protobuf"; -import * as pb from "../proto/orchestrator_service_pb"; - -type ProtobufMessageConstructor = { - deserializeBinary(bytes: Uint8Array): T; -}; - -/** - * Decodes a base64-encoded protobuf message. - * - * @remarks - * This is intended for host integrations, such as Azure Functions, that receive - * TaskHubSidecarService payloads as base64 strings. It follows this package's - * runtime support matrix, which currently requires Node.js 22 or higher. - */ -export function decodeBase64Protobuf( - encodedMessage: string, - messageType: ProtobufMessageConstructor, -): T { - return messageType.deserializeBinary(Buffer.from(encodedMessage, "base64")); -} - -/** - * Encodes a protobuf message as a base64 string. - * - * @remarks - * This is intended for host integrations, such as Azure Functions, that return - * TaskHubSidecarService payloads as base64 strings. It follows this package's - * runtime support matrix, which currently requires Node.js 22 or higher. - */ -export function encodeBase64Protobuf(message: Message): string { - return Buffer.from(message.serializeBinary()).toString("base64"); -} - -export function decodeOrchestratorRequestFromBase64(encodedMessage: string): pb.OrchestratorRequest { - return decodeBase64Protobuf(encodedMessage, pb.OrchestratorRequest); -} - -export function encodeOrchestratorResponseToBase64(message: pb.OrchestratorResponse): string { - return encodeBase64Protobuf(message); -} - -export function decodeEntityBatchRequestFromBase64(encodedMessage: string): pb.EntityBatchRequest { - return decodeBase64Protobuf(encodedMessage, pb.EntityBatchRequest); -} - -export function encodeEntityBatchResultToBase64(message: pb.EntityBatchResult): string { - return encodeBase64Protobuf(message); -} - -export function decodeEntityRequestFromBase64(encodedMessage: string): pb.EntityRequest { - return decodeBase64Protobuf(encodedMessage, pb.EntityRequest); -} 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 9630716..1ce8cf3 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -13,25 +13,30 @@ import { GrpcClient } from "../client/client-grpc"; import { Empty } from "google-protobuf/google/protobuf/empty_pb"; import * as pbh from "../utils/pb-helper.util"; import { callWithMetadata, MetadataGenerator } from "../utils/grpc-helper.util"; +import { OrchestrationExecutor } from "./orchestration-executor"; import { ActivityExecutor } from "./activity-executor"; +import { TaskEntityShim } from "./entity-executor"; +import { EntityInstanceId } from "../entities/entity-instance-id"; import { EntityFactory } from "../entities/task-entity"; import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; import { Logger, ConsoleLogger } from "../types/logger.type"; import { ExponentialBackoff, sleep, withTimeout } from "../utils/backoff.util"; -import { VersioningOptions } from "./versioning-options"; +import { VersioningOptions, VersionMatchStrategy, VersionFailureStrategy } from "./versioning-options"; import { WorkItemFilters, generateWorkItemFiltersFromRegistry, toGrpcWorkItemFilters } from "./work-item-filters"; +import { compareVersions } from "../utils/versioning.util"; import * as WorkerLogs from "./logs"; import { + DurableTaskAttributes, + startSpanForOrchestrationExecution, startSpanForTaskExecution, + processActionsForTracing, + createOrchestrationTraceContextPb, + setOrchestrationStatusFromActions, + processNewEventsForTracing, setSpanError, setSpanOk, endSpan, } from "../tracing"; -import { - executeEntityBatchWorkItem, - executeEntityWorkItem, - executeOrchestratorWorkItem, -} from "./work-item-executor"; /** Default timeout in milliseconds for graceful shutdown. */ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30000; @@ -309,99 +314,55 @@ export class TaskHubGrpcWorker { } /** - * Executes a single orchestration request and returns the response without - * completing it over gRPC. + * Processes a single serialized TaskHubSidecarService OrchestratorRequest and + * returns the serialized OrchestratorResponse. + * + * @param request - The protobuf-encoded OrchestratorRequest bytes. + * @returns The protobuf-encoded OrchestratorResponse bytes. * * @remarks - * This is intended for host integrations, such as Azure Functions, that - * receive a base64-encoded TaskHubSidecarService OrchestratorRequest and need - * to return a base64-encoded OrchestratorResponse. It follows this package's - * runtime support matrix, which currently requires Node.js 22 or higher. + * This is intended for host integrations, such as Azure Functions, that drive a + * single orchestration work item per invocation instead of running the + * long-lived gRPC worker loop. It reuses the same execution path as the worker + * loop, capturing the response in-process rather than completing it over gRPC. + * Host integrations own any transport-specific encoding (for example base64). */ - async executeOrchestratorRequest( - req: pb.OrchestratorRequest, - completionToken: string = "", - ): Promise { - const result = await executeOrchestratorWorkItem( - this._registry, - req, - completionToken, - { logger: this._logger, versioning: this._versioning }, - ); + async processOrchestratorRequest(request: Uint8Array): Promise { + const req = pb.OrchestratorRequest.deserializeBinary(request); + const stub = new CapturingSidecarStub(); + await this._executeOrchestratorInternal(req, "", stub as unknown as stubs.TaskHubSidecarServiceClient); - if (result.kind === "abandoned") { - throw new Error( - `Orchestrator work item was rejected: ${result.errorMessage ?? result.errorType ?? "unknown reason"}`, - ); + if (!stub.orchestratorResponse) { + throw new Error("Orchestrator execution did not produce a response."); } - return result.response; + return stub.orchestratorResponse.serializeBinary(); } /** - * Processes a serialized TaskHubSidecarService OrchestratorRequest and returns - * the serialized OrchestratorResponse. + * Processes a single serialized TaskHubSidecarService EntityBatchRequest and + * returns the serialized EntityBatchResult. * - * @remarks - * Host integrations should handle any transport-specific base64 conversion and - * pass raw protobuf bytes to this method. - */ - async processOrchestratorRequest(request: Uint8Array | Buffer): Promise { - const req = pb.OrchestratorRequest.deserializeBinary(request); - const response = await this.executeOrchestratorRequest(req); - return response.serializeBinary(); - } - - /** - * Executes a single entity batch request and returns the result without - * completing it over gRPC. - * - * @remarks - * This is intended for host integrations, such as Azure Functions, that - * receive a base64-encoded TaskHubSidecarService EntityBatchRequest and need - * to return a base64-encoded EntityBatchResult. It follows this package's - * runtime support matrix, which currently requires Node.js 22 or higher. - */ - async executeEntityBatchRequest( - req: pb.EntityBatchRequest, - completionToken: string = "", - ): Promise { - return executeEntityBatchWorkItem( - this._registry, - req, - completionToken, - { logger: this._logger, versioning: this._versioning }, - ); - } - - /** - * Processes a serialized TaskHubSidecarService EntityBatchRequest and returns - * the serialized EntityBatchResult. + * @param request - The protobuf-encoded EntityBatchRequest bytes. + * @returns The protobuf-encoded EntityBatchResult bytes. * * @remarks - * Host integrations should handle any transport-specific base64 conversion and - * pass raw protobuf bytes to this method. + * This is intended for host integrations, such as Azure Functions, that drive a + * single entity batch work item per invocation instead of running the + * long-lived gRPC worker loop. It reuses the same execution path as the worker + * loop, capturing the result in-process rather than completing it over gRPC. + * Host integrations own any transport-specific encoding (for example base64). */ - async processEntityBatchRequest(request: Uint8Array | Buffer): Promise { + async processEntityBatchRequest(request: Uint8Array): Promise { const req = pb.EntityBatchRequest.deserializeBinary(request); - const response = await this.executeEntityBatchRequest(req); - return response.serializeBinary(); - } + const stub = new CapturingSidecarStub(); + await this._executeEntityInternal(req, "", stub as unknown as stubs.TaskHubSidecarServiceClient); - /** - * Executes a single V2 entity request and returns the batch result without - * completing it over gRPC. - */ - async executeEntityRequest( - req: pb.EntityRequest, - completionToken: string = "", - ): Promise { - return executeEntityWorkItem( - this._registry, - req, - completionToken, - { logger: this._logger, versioning: this._versioning }, - ); + if (!stub.entityResult) { + throw new Error("Entity batch execution did not produce a result."); + } + + return stub.entityResult.serializeBinary(); } /** @@ -615,6 +576,88 @@ export class TaskHubGrpcWorker { return request; } + /** + * Result of version compatibility check. + */ + private _checkVersionCompatibility(req: pb.OrchestratorRequest): { + compatible: boolean; + shouldFail: boolean; + orchestrationVersion?: string; + errorType?: string; + errorMessage?: string; + } { + // If no versioning options configured or match strategy is None, always compatible + if (!this._versioning || this._versioning.matchStrategy === VersionMatchStrategy.None) { + return { compatible: true, shouldFail: false }; + } + + // Extract orchestration version from ExecutionStarted event + const orchestrationVersion = this._getOrchestrationVersion(req); + const workerVersion = this._versioning.version; + + // If worker version is not set, process all + if (!workerVersion) { + return { compatible: true, shouldFail: false }; + } + + let compatible = false; + let errorType = "VersionMismatch"; + let errorMessage = ""; + + switch (this._versioning.matchStrategy) { + case VersionMatchStrategy.Strict: + // Only process if versions match (using semantic comparison) + compatible = compareVersions(orchestrationVersion, workerVersion) === 0; + if (!compatible) { + errorMessage = `The orchestration version '${orchestrationVersion ?? ""}' does not match the worker version '${workerVersion}'.`; + } + break; + + case VersionMatchStrategy.CurrentOrOlder: + // Process if orchestration version is current or older + if (!orchestrationVersion) { + // Empty orchestration version is considered older + compatible = true; + } else { + compatible = compareVersions(orchestrationVersion, workerVersion) <= 0; + if (!compatible) { + errorMessage = `The orchestration version '${orchestrationVersion}' is greater than the worker version '${workerVersion}'.`; + } + } + break; + + default: + // Unknown match strategy - treat as version error + compatible = false; + errorType = "VersionError"; + errorMessage = `The version match strategy '${this._versioning.matchStrategy}' is unknown.`; + break; + } + + if (!compatible) { + const shouldFail = this._versioning.failureStrategy === VersionFailureStrategy.Fail; + return { compatible: false, shouldFail, orchestrationVersion, errorType, errorMessage }; + } + + return { compatible: true, shouldFail: false }; + } + + /** + * Extracts the orchestration version from the ExecutionStarted event in the request. + */ + private _getOrchestrationVersion(req: pb.OrchestratorRequest): string | undefined { + // Look for ExecutionStarted event in both past and new events + const allEvents = [...req.getPasteventsList(), ...req.getNeweventsList()]; + + for (const event of allEvents) { + if (event.hasExecutionstarted()) { + return event.getExecutionstarted()?.getVersion()?.getValue(); + } + } + + return undefined; + } + private _trackPendingWorkItem(workPromise: Promise, onError: (error: Error) => void): void { const handledPromise = workPromise .catch((e: unknown) => { @@ -650,29 +693,165 @@ export class TaskHubGrpcWorker { completionToken: string, stub: stubs.TaskHubSidecarServiceClient, ): Promise { - const result = await executeOrchestratorWorkItem( - this._registry, - req, - completionToken, - { logger: this._logger, versioning: this._versioning }, - ); + const instanceId = req.getInstanceid(); - if (result.kind === "abandoned") { - try { - await callWithMetadata( - stub.abandonTaskOrchestratorWorkItem.bind(stub), - result.abandonRequest, - this._metadataGenerator, + if (!instanceId) { + throw new Error(`Could not execute the orchestrator as the instanceId was not provided (${instanceId})`); + } + + // Check version compatibility if versioning is enabled + const versionCheckResult = this._checkVersionCompatibility(req); + if (!versionCheckResult.compatible) { + if (versionCheckResult.shouldFail) { + // Fail the orchestration with version mismatch error + WorkerLogs.versionMismatchFail( + this._logger, + instanceId, + versionCheckResult.errorType!, + versionCheckResult.errorMessage!, ); - } catch (e: unknown) { - const error = e instanceof Error ? e : new Error(String(e)); - WorkerLogs.completionError(this._logger, req.getInstanceid(), error); + + const failureDetails = pbh.newVersionMismatchFailureDetails( + versionCheckResult.errorType!, + versionCheckResult.errorMessage!, + ); + + const actions = [ + pbh.newCompleteOrchestrationAction( + -1, + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + undefined, + failureDetails, + ), + ]; + + const res = new pb.OrchestratorResponse(); + res.setInstanceid(instanceId); + res.setCompletiontoken(completionToken); + res.setActionsList(actions); + + try { + await callWithMetadata(stub.completeOrchestratorTask.bind(stub), res, this._metadataGenerator); + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error(String(e)); + WorkerLogs.completionError(this._logger, instanceId, error); + } + return; + } else { + // Reject the work item - explicitly abandon it so it can be picked up by another worker + WorkerLogs.versionMismatchAbandon( + this._logger, + instanceId, + versionCheckResult.errorType!, + versionCheckResult.errorMessage!, + ); + + try { + const abandonRequest = new pb.AbandonOrchestrationTaskRequest(); + abandonRequest.setCompletiontoken(completionToken); + await callWithMetadata(stub.abandonTaskOrchestratorWorkItem.bind(stub), abandonRequest, this._metadataGenerator); + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error(String(e)); + WorkerLogs.completionError(this._logger, instanceId, error); + } + return; } - return; + } + + // Find the ExecutionStartedEvent from either past or new events for tracing + const allProtoEvents = [...req.getPasteventsList(), ...req.getNeweventsList()]; + let executionStartedProtoEvent: pb.ExecutionStartedEvent | undefined; + for (const protoEvent of allProtoEvents) { + if (protoEvent.hasExecutionstarted()) { + executionStartedProtoEvent = protoEvent.getExecutionstarted()!; + break; + } + } + + // Start the orchestration span BEFORE execution so failures are traced + const orchTraceContext = req.getOrchestrationtracecontext(); + const tracingResult = executionStartedProtoEvent + ? startSpanForOrchestrationExecution(executionStartedProtoEvent, orchTraceContext, instanceId) + : undefined; + + // Emit retroactive spans for tasks/sub-orchestrations that completed/failed and timers + // that fired. This follows the .NET SDK pattern where these spans are emitted from + // history events BEFORE the orchestrator executor runs. + const orchName = executionStartedProtoEvent?.getName() ?? ""; + if (tracingResult) { + processNewEventsForTracing( + tracingResult.span, + req.getPasteventsList(), + req.getNeweventsList(), + instanceId, + orchName, + ); + } + + let res; + + try { + const executor = new OrchestrationExecutor(this._registry, this._logger); + const result = await executor.execute(req.getInstanceid(), req.getPasteventsList(), req.getNeweventsList()); + + // Process actions to inject trace context into scheduled tasks, sub-orchestrations, etc. + if (tracingResult) { + const executionId = req.getExecutionid()?.getValue(); + processActionsForTracing(tracingResult.span, result.actions, orchName, instanceId, executionId); + } + + res = new pb.OrchestratorResponse(); + res.setInstanceid(req.getInstanceid()); + res.setCompletiontoken(completionToken); + res.setActionsList(result.actions); + if (result.customStatus !== undefined) { + res.setCustomstatus(pbh.getStringValue(result.customStatus)); + } + + // Set the OrchestrationTraceContext on the response for replay continuity + if (tracingResult) { + const orchTraceCtxPb = createOrchestrationTraceContextPb(tracingResult.spanInfo); + res.setOrchestrationtracecontext(orchTraceCtxPb); + + // Set orchestration completion status attribute and span status + // (OK for success, ERROR for failed orchestrations — matching .NET) + setOrchestrationStatusFromActions(tracingResult.span, result.actions); + } + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error(String(e)); + WorkerLogs.executionError(this._logger, req.getInstanceid(), error); + + // Record the failure on the tracing span + if (tracingResult) { + setSpanError(tracingResult.span, error); + // Set just the status attribute — don't call setOrchestrationStatusFromActions + // which would overwrite the specific error message with a generic one + tracingResult.span.setAttribute(DurableTaskAttributes.TASK_STATUS, "Failed"); + } + + const failureDetails = pbh.newFailureDetails(error); + + const actions = [ + pbh.newCompleteOrchestrationAction( + -1, + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + undefined, + failureDetails, + ), + ]; + + res = new pb.OrchestratorResponse(); + res.setInstanceid(req.getInstanceid()); + res.setCompletiontoken(completionToken); + res.setActionsList(actions); + } finally { + // Always end the orchestration span, regardless of success or failure. + // Status (OK/Error) is set in the respective try/catch branches above. + endSpan(tracingResult?.span); } try { - await callWithMetadata(stub.completeOrchestratorTask.bind(stub), result.response, this._metadataGenerator); + await callWithMetadata(stub.completeOrchestratorTask.bind(stub), res, this._metadataGenerator); } catch (e: unknown) { const error = e instanceof Error ? e : new Error(String(e)); WorkerLogs.completionError(this._logger, req.getInstanceid(), error); @@ -784,8 +963,8 @@ export class TaskHubGrpcWorker { * @param operationInfos - Optional V2 operation info list to include in the result. * * @remarks - * This method delegates execution to the shared single-work-item executor and sends the - * result back to the sidecar. + * This method looks up the entity by name, creates a TaskEntityShim, executes the batch, + * and sends the result back to the sidecar. */ private async _executeEntityInternal( req: pb.EntityBatchRequest, @@ -793,13 +972,69 @@ export class TaskHubGrpcWorker { stub: stubs.TaskHubSidecarServiceClient, operationInfos?: pb.OperationInfo[], ): Promise { - const batchResult = await executeEntityBatchWorkItem( - this._registry, - req, - completionToken, - { logger: this._logger, versioning: this._versioning }, - operationInfos, - ); + const instanceIdString = req.getInstanceid(); + + if (!instanceIdString) { + throw new Error("Entity request does not contain an instance id"); + } + + // Parse the entity instance ID (format: @name@key) + let entityId: EntityInstanceId; + try { + entityId = EntityInstanceId.fromString(instanceIdString); + } catch (e: any) { + WorkerLogs.entityInstanceIdParseError(this._logger, instanceIdString, e); + // Return error result for all operations + const batchResult = this._createEntityNotFoundResult( + req, + completionToken, + `Invalid entity instance id format: '${instanceIdString}'`, + ); + await this._sendEntityResult(batchResult, stub); + return; + } + + let batchResult: pb.EntityBatchResult; + + try { + // Look up the entity factory by name + const factory = this._registry.getEntity(entityId.name); + + if (factory) { + // Create the entity instance and execute the batch + const entity = factory(); + const shim = new TaskEntityShim(entity, entityId); + batchResult = await shim.executeAsync(req); + batchResult.setCompletiontoken(completionToken); + } else { + // Entity not found - return error result for all operations + WorkerLogs.entityNotFound(this._logger, entityId.name); + batchResult = this._createEntityNotFoundResult( + req, + completionToken, + `No entity task named '${entityId.name}' was found.`, + ); + } + } catch (e: any) { + // Framework-level error - return result with failure details + // This will cause the batch to be abandoned and retried + WorkerLogs.entityExecutionFailed(this._logger, entityId.name, e); + + const failureDetails = pbh.newFailureDetails(e); + + batchResult = new pb.EntityBatchResult(); + batchResult.setCompletiontoken(completionToken); + batchResult.setFailuredetails(failureDetails); + } + + // Add V2 operationInfos if provided (used by DTS backend) + if (operationInfos && operationInfos.length > 0) { + // Take only as many operationInfos as there are results + const resultsCount = batchResult.getResultsList().length; + const infosToInclude = operationInfos.slice(0, resultsCount || operationInfos.length); + batchResult.setOperationinfosList(infosToInclude); + } + await this._sendEntityResult(batchResult, stub); } @@ -825,21 +1060,139 @@ export class TaskHubGrpcWorker { * @param stub - The gRPC stub for completing the task. * * @remarks - * This method delegates V2 entity execution to the shared single-work-item executor and - * sends the result back to the sidecar. + * This method handles the V2 entity request format which uses HistoryEvent + * instead of OperationRequest. It converts the V2 format to V1 format + * (EntityBatchRequest) and delegates to the existing execution logic. */ private async _executeEntityV2Internal( req: pb.EntityRequest, completionToken: string, stub: stubs.TaskHubSidecarServiceClient, ): Promise { - const batchResult = await executeEntityWorkItem( - this._registry, - req, - completionToken, - { logger: this._logger, versioning: this._versioning }, - ); - await this._sendEntityResult(batchResult, stub); + // Convert EntityRequest (V2) to EntityBatchRequest (V1) format + const batchRequest = new pb.EntityBatchRequest(); + batchRequest.setInstanceid(req.getInstanceid()); + + // Copy entity state + const entityState = req.getEntitystate(); + if (entityState) { + batchRequest.setEntitystate(entityState); + } + + // Convert HistoryEvent operations to OperationRequest format + // Also build the operationInfos list for V2 responses + const historyEvents = req.getOperationrequestsList(); + const operations: pb.OperationRequest[] = []; + const operationInfos: pb.OperationInfo[] = []; + + for (const event of historyEvents) { + const eventType = event.getEventtypeCase(); + + if (eventType === pb.HistoryEvent.EventtypeCase.ENTITYOPERATIONSIGNALED) { + const signaled = event.getEntityoperationsignaled(); + if (signaled) { + const opRequest = new pb.OperationRequest(); + opRequest.setOperation(signaled.getOperation()); + opRequest.setRequestid(signaled.getRequestid()); + const input = signaled.getInput(); + if (input) { + opRequest.setInput(input); + } + operations.push(opRequest); + + // Build OperationInfo for signaled operations (no response destination) + const opInfo = new pb.OperationInfo(); + opInfo.setRequestid(signaled.getRequestid()); + // Signals don't send a response, so responseDestination is null + operationInfos.push(opInfo); + } + } else if (eventType === pb.HistoryEvent.EventtypeCase.ENTITYOPERATIONCALLED) { + const called = event.getEntityoperationcalled(); + if (called) { + const opRequest = new pb.OperationRequest(); + opRequest.setOperation(called.getOperation()); + opRequest.setRequestid(called.getRequestid()); + const input = called.getInput(); + if (input) { + opRequest.setInput(input); + } + operations.push(opRequest); + + // Build OperationInfo for called operations (with response destination) + const opInfo = new pb.OperationInfo(); + opInfo.setRequestid(called.getRequestid()); + + // Called operations send responses to the parent orchestration + const parentInstanceId = called.getParentinstanceid(); + const parentExecutionId = called.getParentexecutionid(); + if (parentInstanceId || parentExecutionId) { + const responseDestination = new pb.OrchestrationInstance(); + if (parentInstanceId) { + responseDestination.setInstanceid(parentInstanceId.getValue()); + } + if (parentExecutionId) { + // executionId needs to be wrapped in a StringValue + const execIdValue = new StringValue(); + execIdValue.setValue(parentExecutionId.getValue()); + responseDestination.setExecutionid(execIdValue); + } + opInfo.setResponsedestination(responseDestination); + } + operationInfos.push(opInfo); + } + } else { + WorkerLogs.entityUnknownOperationEventType(this._logger, eventType.toString()); + } + } + + batchRequest.setOperationsList(operations); + + // Delegate to the V1 execution logic with V2 operationInfos + await this._executeEntityInternal(batchRequest, completionToken, stub, operationInfos); + } + + /** + * Creates an EntityBatchResult for when an entity is not found. + * + * @remarks + * Returns a non-retriable error for each operation in the batch. + */ + private _createEntityNotFoundResult( + req: pb.EntityBatchRequest, + completionToken: string, + errorMessage: string, + ): pb.EntityBatchResult { + const batchResult = new pb.EntityBatchResult(); + batchResult.setCompletiontoken(completionToken); + + // State is unmodified - return the original state + const originalState = req.getEntitystate(); + if (originalState) { + batchResult.setEntitystate(originalState); + } + + // Create a failure result for each operation in the batch + const operations = req.getOperationsList(); + const results: pb.OperationResult[] = []; + + for (let i = 0; i < operations.length; i++) { + const result = new pb.OperationResult(); + const failure = new pb.OperationResultFailure(); + const failureDetails = new pb.TaskFailureDetails(); + + failureDetails.setErrortype("EntityTaskNotFound"); + failureDetails.setErrormessage(errorMessage); + failureDetails.setIsnonretriable(true); + + failure.setFailuredetails(failureDetails); + result.setFailure(failure); + results.push(result); + } + + batchResult.setResultsList(results); + batchResult.setActionsList([]); + + return batchResult; } /** @@ -856,3 +1209,46 @@ export class TaskHubGrpcWorker { } } } + +/** + * A minimal in-process stand-in for the TaskHubSidecarService client that captures the + * completion payload instead of sending it over gRPC. + * + * @remarks + * This lets host integrations reuse the worker's existing execution path for a single work + * item (see {@link TaskHubGrpcWorker.processOrchestratorRequest} and + * {@link TaskHubGrpcWorker.processEntityBatchRequest}) without opening a gRPC channel. Only the + * completion/abandon methods used by those execution paths are implemented. + */ +class CapturingSidecarStub { + orchestratorResponse?: pb.OrchestratorResponse; + entityResult?: pb.EntityBatchResult; + abandonRequest?: pb.AbandonOrchestrationTaskRequest; + + completeOrchestratorTask( + request: pb.OrchestratorResponse, + _metadata: grpc.Metadata, + callback: (error: grpc.ServiceError | null, response: Empty) => void, + ): void { + this.orchestratorResponse = request; + callback(null, new Empty()); + } + + completeEntityTask( + request: pb.EntityBatchResult, + _metadata: grpc.Metadata, + callback: (error: grpc.ServiceError | null, response: Empty) => void, + ): void { + this.entityResult = request; + callback(null, new Empty()); + } + + abandonTaskOrchestratorWorkItem( + request: pb.AbandonOrchestrationTaskRequest, + _metadata: grpc.Metadata, + callback: (error: grpc.ServiceError | null, response: Empty) => void, + ): void { + this.abandonRequest = request; + callback(null, new Empty()); + } +} diff --git a/packages/durabletask-js/src/worker/work-item-executor.ts b/packages/durabletask-js/src/worker/work-item-executor.ts deleted file mode 100644 index 2d7e36d..0000000 --- a/packages/durabletask-js/src/worker/work-item-executor.ts +++ /dev/null @@ -1,481 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; -import { EntityInstanceId } from "../entities/entity-instance-id"; -import { EntityFactory } from "../entities/task-entity"; -import * as pb from "../proto/orchestrator_service_pb"; -import { Logger, ConsoleLogger } from "../types/logger.type"; -import * as pbh from "../utils/pb-helper.util"; -import { compareVersions } from "../utils/versioning.util"; -import { - DurableTaskAttributes, - createOrchestrationTraceContextPb, - endSpan, - processActionsForTracing, - processNewEventsForTracing, - setOrchestrationStatusFromActions, - setSpanError, - startSpanForOrchestrationExecution, -} from "../tracing"; -import { TaskEntityShim } from "./entity-executor"; -import * as WorkerLogs from "./logs"; -import { OrchestrationExecutor } from "./orchestration-executor"; -import { Registry } from "./registry"; -import { VersionFailureStrategy, VersioningOptions, VersionMatchStrategy } from "./versioning-options"; - -export interface WorkItemExecutorOptions { - /** Optional logger instance. Defaults to ConsoleLogger. */ - logger?: Logger; - /** Optional versioning options for filtering orchestration requests. */ - versioning?: VersioningOptions; -} - -export interface CompletedOrchestratorWorkItemResult { - kind: "completed"; - response: pb.OrchestratorResponse; -} - -export interface AbandonedOrchestratorWorkItemResult { - kind: "abandoned"; - abandonRequest: pb.AbandonOrchestrationTaskRequest; - errorType?: string; - errorMessage?: string; -} - -export type OrchestratorWorkItemResult = - | CompletedOrchestratorWorkItemResult - | AbandonedOrchestratorWorkItemResult; - -interface VersionCompatibilityResult { - compatible: boolean; - shouldFail: boolean; - orchestrationVersion?: string; - errorType?: string; - errorMessage?: string; -} - -export interface EntityBatchRequestConversion { - batchRequest: pb.EntityBatchRequest; - operationInfos: pb.OperationInfo[]; -} - -/** - * Executes one orchestration work item and returns the sidecar response message. - * - * @remarks - * This helper is intended for host integrations, such as Azure Functions, that - * receive a single TaskHubSidecarService OrchestratorRequest and need to return - * an OrchestratorResponse without running the long-lived gRPC worker loop. It - * follows this package's runtime support matrix, which currently requires - * Node.js 22 or higher. - */ -export async function executeOrchestratorWorkItem( - registry: Registry, - req: pb.OrchestratorRequest, - completionToken: string = "", - options?: WorkItemExecutorOptions, -): Promise { - const logger = options?.logger ?? new ConsoleLogger(); - const instanceId = req.getInstanceid(); - - if (!instanceId) { - throw new Error(`Could not execute the orchestrator as the instanceId was not provided (${instanceId})`); - } - - const versionCheckResult = checkVersionCompatibility(req, options?.versioning); - if (!versionCheckResult.compatible) { - if (versionCheckResult.shouldFail) { - WorkerLogs.versionMismatchFail( - logger, - instanceId, - versionCheckResult.errorType!, - versionCheckResult.errorMessage!, - ); - - const failureDetails = pbh.newVersionMismatchFailureDetails( - versionCheckResult.errorType!, - versionCheckResult.errorMessage!, - ); - - const response = new pb.OrchestratorResponse(); - response.setInstanceid(instanceId); - response.setCompletiontoken(completionToken); - response.setActionsList([ - pbh.newCompleteOrchestrationAction( - -1, - pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, - undefined, - failureDetails, - ), - ]); - - return { kind: "completed", response }; - } - - WorkerLogs.versionMismatchAbandon( - logger, - instanceId, - versionCheckResult.errorType!, - versionCheckResult.errorMessage!, - ); - - const abandonRequest = new pb.AbandonOrchestrationTaskRequest(); - abandonRequest.setCompletiontoken(completionToken); - - return { - kind: "abandoned", - abandonRequest, - errorType: versionCheckResult.errorType, - errorMessage: versionCheckResult.errorMessage, - }; - } - - const allProtoEvents = [...req.getPasteventsList(), ...req.getNeweventsList()]; - let executionStartedProtoEvent: pb.ExecutionStartedEvent | undefined; - for (const protoEvent of allProtoEvents) { - if (protoEvent.hasExecutionstarted()) { - executionStartedProtoEvent = protoEvent.getExecutionstarted()!; - break; - } - } - - const orchTraceContext = req.getOrchestrationtracecontext(); - const tracingResult = executionStartedProtoEvent - ? startSpanForOrchestrationExecution(executionStartedProtoEvent, orchTraceContext, instanceId) - : undefined; - - const orchName = executionStartedProtoEvent?.getName() ?? ""; - if (tracingResult) { - processNewEventsForTracing( - tracingResult.span, - req.getPasteventsList(), - req.getNeweventsList(), - instanceId, - orchName, - ); - } - - let response: pb.OrchestratorResponse; - - try { - const executor = new OrchestrationExecutor(registry, logger); - const result = await executor.execute(instanceId, req.getPasteventsList(), req.getNeweventsList()); - - if (tracingResult) { - const executionId = req.getExecutionid()?.getValue(); - processActionsForTracing(tracingResult.span, result.actions, orchName, instanceId, executionId); - } - - response = new pb.OrchestratorResponse(); - response.setInstanceid(instanceId); - response.setCompletiontoken(completionToken); - response.setActionsList(result.actions); - - if (result.customStatus !== undefined) { - response.setCustomstatus(pbh.getStringValue(result.customStatus)); - } - - if (tracingResult) { - response.setOrchestrationtracecontext(createOrchestrationTraceContextPb(tracingResult.spanInfo)); - setOrchestrationStatusFromActions(tracingResult.span, result.actions); - } - } catch (e: unknown) { - const error = e instanceof Error ? e : new Error(String(e)); - WorkerLogs.executionError(logger, instanceId, error); - - if (tracingResult) { - setSpanError(tracingResult.span, error); - tracingResult.span.setAttribute(DurableTaskAttributes.TASK_STATUS, "Failed"); - } - - response = new pb.OrchestratorResponse(); - response.setInstanceid(instanceId); - response.setCompletiontoken(completionToken); - response.setActionsList([ - pbh.newCompleteOrchestrationAction( - -1, - pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, - undefined, - pbh.newFailureDetails(error), - ), - ]); - } finally { - endSpan(tracingResult?.span); - } - - return { kind: "completed", response }; -} - -/** - * Executes one entity batch work item and returns the sidecar result message. - * - * @remarks - * This helper is intended for host integrations, such as Azure Functions, that - * receive a single TaskHubSidecarService EntityBatchRequest and need to return - * an EntityBatchResult without running the long-lived gRPC worker loop. It - * follows this package's runtime support matrix, which currently requires - * Node.js 22 or higher. - */ -export async function executeEntityBatchWorkItem( - registry: Registry, - req: pb.EntityBatchRequest, - completionToken: string = "", - options?: WorkItemExecutorOptions, - operationInfos?: pb.OperationInfo[], -): Promise { - const logger = options?.logger ?? new ConsoleLogger(); - const instanceIdString = req.getInstanceid(); - - if (!instanceIdString) { - throw new Error("Entity request does not contain an instance id"); - } - - let entityId: EntityInstanceId; - try { - entityId = EntityInstanceId.fromString(instanceIdString); - } catch (e: unknown) { - const error = e instanceof Error ? e : new Error(String(e)); - WorkerLogs.entityInstanceIdParseError(logger, instanceIdString, error); - return createEntityNotFoundResult( - req, - completionToken, - `Invalid entity instance id format: '${instanceIdString}'`, - ); - } - - let batchResult: pb.EntityBatchResult; - - try { - const factory = registry.getEntity(entityId.name); - - if (factory) { - batchResult = await executeRegisteredEntity(factory, entityId, req); - batchResult.setCompletiontoken(completionToken); - } else { - WorkerLogs.entityNotFound(logger, entityId.name); - batchResult = createEntityNotFoundResult( - req, - completionToken, - `No entity task named '${entityId.name}' was found.`, - ); - } - } catch (e: unknown) { - const error = e instanceof Error ? e : new Error(String(e)); - WorkerLogs.entityExecutionFailed(logger, entityId.name, error); - - batchResult = new pb.EntityBatchResult(); - batchResult.setCompletiontoken(completionToken); - batchResult.setFailuredetails(pbh.newFailureDetails(error)); - } - - if (operationInfos && operationInfos.length > 0) { - const resultsCount = batchResult.getResultsList().length; - const infosToInclude = operationInfos.slice(0, resultsCount || operationInfos.length); - batchResult.setOperationinfosList(infosToInclude); - } - - return batchResult; -} - -/** - * Converts and executes one V2 entity work item. - */ -export async function executeEntityWorkItem( - registry: Registry, - req: pb.EntityRequest, - completionToken: string = "", - options?: WorkItemExecutorOptions, -): Promise { - const conversion = convertEntityRequestToBatchRequest(req, options?.logger); - return executeEntityBatchWorkItem( - registry, - conversion.batchRequest, - completionToken, - options, - conversion.operationInfos, - ); -} - -export function convertEntityRequestToBatchRequest( - req: pb.EntityRequest, - logger?: Logger, -): EntityBatchRequestConversion { - const batchRequest = new pb.EntityBatchRequest(); - batchRequest.setInstanceid(req.getInstanceid()); - - const entityState = req.getEntitystate(); - if (entityState) { - batchRequest.setEntitystate(entityState); - } - - const operations: pb.OperationRequest[] = []; - const operationInfos: pb.OperationInfo[] = []; - - for (const event of req.getOperationrequestsList()) { - const eventType = event.getEventtypeCase(); - - if (eventType === pb.HistoryEvent.EventtypeCase.ENTITYOPERATIONSIGNALED) { - const signaled = event.getEntityoperationsignaled(); - if (signaled) { - const opRequest = new pb.OperationRequest(); - opRequest.setOperation(signaled.getOperation()); - opRequest.setRequestid(signaled.getRequestid()); - const input = signaled.getInput(); - if (input) { - opRequest.setInput(input); - } - operations.push(opRequest); - - const opInfo = new pb.OperationInfo(); - opInfo.setRequestid(signaled.getRequestid()); - operationInfos.push(opInfo); - } - } else if (eventType === pb.HistoryEvent.EventtypeCase.ENTITYOPERATIONCALLED) { - const called = event.getEntityoperationcalled(); - if (called) { - const opRequest = new pb.OperationRequest(); - opRequest.setOperation(called.getOperation()); - opRequest.setRequestid(called.getRequestid()); - const input = called.getInput(); - if (input) { - opRequest.setInput(input); - } - operations.push(opRequest); - - const opInfo = new pb.OperationInfo(); - opInfo.setRequestid(called.getRequestid()); - - const parentInstanceId = called.getParentinstanceid(); - const parentExecutionId = called.getParentexecutionid(); - if (parentInstanceId || parentExecutionId) { - const responseDestination = new pb.OrchestrationInstance(); - if (parentInstanceId) { - responseDestination.setInstanceid(parentInstanceId.getValue()); - } - if (parentExecutionId) { - const execIdValue = new StringValue(); - execIdValue.setValue(parentExecutionId.getValue()); - responseDestination.setExecutionid(execIdValue); - } - opInfo.setResponsedestination(responseDestination); - } - operationInfos.push(opInfo); - } - } else { - WorkerLogs.entityUnknownOperationEventType(logger ?? new ConsoleLogger(), eventType.toString()); - } - } - - batchRequest.setOperationsList(operations); - return { batchRequest, operationInfos }; -} - -function checkVersionCompatibility( - req: pb.OrchestratorRequest, - versioning?: VersioningOptions, -): VersionCompatibilityResult { - if (!versioning || versioning.matchStrategy === VersionMatchStrategy.None) { - return { compatible: true, shouldFail: false }; - } - - const orchestrationVersion = getOrchestrationVersion(req); - const workerVersion = versioning.version; - - if (!workerVersion) { - return { compatible: true, shouldFail: false }; - } - - let compatible = false; - let errorType = "VersionMismatch"; - let errorMessage = ""; - - switch (versioning.matchStrategy) { - case VersionMatchStrategy.Strict: - compatible = compareVersions(orchestrationVersion, workerVersion) === 0; - if (!compatible) { - errorMessage = `The orchestration version '${orchestrationVersion ?? ""}' does not match the worker version '${workerVersion}'.`; - } - break; - - case VersionMatchStrategy.CurrentOrOlder: - if (!orchestrationVersion) { - compatible = true; - } else { - compatible = compareVersions(orchestrationVersion, workerVersion) <= 0; - if (!compatible) { - errorMessage = `The orchestration version '${orchestrationVersion}' is greater than the worker version '${workerVersion}'.`; - } - } - break; - - default: - compatible = false; - errorType = "VersionError"; - errorMessage = `The version match strategy '${versioning.matchStrategy}' is unknown.`; - break; - } - - if (!compatible) { - const shouldFail = versioning.failureStrategy === VersionFailureStrategy.Fail; - return { compatible: false, shouldFail, orchestrationVersion, errorType, errorMessage }; - } - - return { compatible: true, shouldFail: false }; -} - -function getOrchestrationVersion(req: pb.OrchestratorRequest): string | undefined { - const allEvents = [...req.getPasteventsList(), ...req.getNeweventsList()]; - - for (const event of allEvents) { - if (event.hasExecutionstarted()) { - return event.getExecutionstarted()?.getVersion()?.getValue(); - } - } - - return undefined; -} - -function createEntityNotFoundResult( - req: pb.EntityBatchRequest, - completionToken: string, - errorMessage: string, -): pb.EntityBatchResult { - const batchResult = new pb.EntityBatchResult(); - batchResult.setCompletiontoken(completionToken); - - const originalState = req.getEntitystate(); - if (originalState) { - batchResult.setEntitystate(originalState); - } - - const results: pb.OperationResult[] = []; - for (let i = 0; i < req.getOperationsList().length; i++) { - const result = new pb.OperationResult(); - const failure = new pb.OperationResultFailure(); - const failureDetails = new pb.TaskFailureDetails(); - - failureDetails.setErrortype("EntityTaskNotFound"); - failureDetails.setErrormessage(errorMessage); - failureDetails.setIsnonretriable(true); - - failure.setFailuredetails(failureDetails); - result.setFailure(failure); - results.push(result); - } - - batchResult.setResultsList(results); - batchResult.setActionsList([]); - - return batchResult; -} - -async function executeRegisteredEntity( - factory: EntityFactory, - entityId: EntityInstanceId, - req: pb.EntityBatchRequest, -): Promise { - const entity = factory(); - const shim = new TaskEntityShim(entity, entityId); - return shim.executeAsync(req); -} diff --git a/packages/durabletask-js/test/client-options.spec.ts b/packages/durabletask-js/test/client-options.spec.ts index 38fa7d1..579d065 100644 --- a/packages/durabletask-js/test/client-options.spec.ts +++ b/packages/durabletask-js/test/client-options.spec.ts @@ -38,6 +38,5 @@ describe("TaskHubGrpcClient", () => { expect(client).toBeDefined(); }); - }); }); diff --git a/packages/durabletask-js/test/functions-grpc-support.spec.ts b/packages/durabletask-js/test/functions-grpc-support.spec.ts index aa4f002..21f6d75 100644 --- a/packages/durabletask-js/test/functions-grpc-support.spec.ts +++ b/packages/durabletask-js/test/functions-grpc-support.spec.ts @@ -1,31 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { - EntityBatchRequest, - EntityBatchResult, - EntityRequest, - OrchestrationContext, - OrchestratorRequest, - OrchestratorResponse, - TaskEntity, - TaskHubGrpcWorker, - TOrchestrator, - decodeEntityBatchRequestFromBase64, - decodeEntityRequestFromBase64, - decodeOrchestratorRequestFromBase64, - encodeEntityBatchResultToBase64, - encodeOrchestratorResponseToBase64, -} from "../src"; +import { OrchestrationContext, TaskEntity, TaskHubGrpcWorker, TOrchestrator } from "../src"; import * as pb from "../src/proto/orchestrator_service_pb"; -import { - newExecutionStartedEvent, - newOrchestratorStartedEvent, -} from "../src/utils/pb-helper.util"; +import { newExecutionStartedEvent, newOrchestratorStartedEvent } from "../src/utils/pb-helper.util"; import { NoOpLogger } from "../src/types/logger.type"; const TEST_INSTANCE_ID = "functions-grpc-instance"; -const COMPLETION_TOKEN = "functions-completion-token"; class CounterEntity extends TaskEntity { increment(): number { @@ -39,144 +20,43 @@ class CounterEntity extends TaskEntity { } describe("Functions gRPC support surface", () => { - it("round-trips orchestration request and response protobufs through base64 helpers", () => { - const request = new OrchestratorRequest(); - request.setInstanceid(TEST_INSTANCE_ID); - - const encodedRequest = Buffer.from(request.serializeBinary()).toString("base64"); - const decodedRequest = decodeOrchestratorRequestFromBase64(encodedRequest); - - expect(decodedRequest).toBeInstanceOf(OrchestratorRequest); - expect(decodedRequest.getInstanceid()).toBe(TEST_INSTANCE_ID); - - const response = new OrchestratorResponse(); - response.setInstanceid(TEST_INSTANCE_ID); - response.setCompletiontoken(COMPLETION_TOKEN); - - const decodedResponse = OrchestratorResponse.deserializeBinary( - Buffer.from(encodeOrchestratorResponseToBase64(response), "base64"), - ); - - expect(decodedResponse.getInstanceid()).toBe(TEST_INSTANCE_ID); - expect(decodedResponse.getCompletiontoken()).toBe(COMPLETION_TOKEN); - }); - - it("round-trips entity request and result protobufs through base64 helpers", () => { - const request = createEntityBatchRequest("counter", "key1"); - - const encodedRequest = Buffer.from(request.serializeBinary()).toString("base64"); - const decodedRequest = decodeEntityBatchRequestFromBase64(encodedRequest); - - expect(decodedRequest).toBeInstanceOf(EntityBatchRequest); - expect(decodedRequest.getInstanceid()).toBe("@counter@key1"); - expect(decodedRequest.getOperationsList()[0].getOperation()).toBe("increment"); - - const result = new EntityBatchResult(); - result.setCompletiontoken(COMPLETION_TOKEN); - - const decodedResult = EntityBatchResult.deserializeBinary( - Buffer.from(encodeEntityBatchResultToBase64(result), "base64"), - ); - - expect(decodedResult.getCompletiontoken()).toBe(COMPLETION_TOKEN); - }); - - it("executes a single orchestration request without using the gRPC worker loop", async () => { + it("processes a single serialized orchestration request without the gRPC worker loop", async () => { const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); const orchestrator: TOrchestrator = async (_ctx: OrchestrationContext) => "done"; const name = worker.addOrchestrator(orchestrator); - const request = new OrchestratorRequest(); + const request = new pb.OrchestratorRequest(); request.setInstanceid(TEST_INSTANCE_ID); request.setNeweventsList([ newOrchestratorStartedEvent(new Date("2026-01-01T00:00:00.000Z")), newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined), ]); - const response = await worker.executeOrchestratorRequest(request, COMPLETION_TOKEN); + const responseBytes = await worker.processOrchestratorRequest(request.serializeBinary()); + const response = pb.OrchestratorResponse.deserializeBinary(responseBytes); expect(response.getInstanceid()).toBe(TEST_INSTANCE_ID); - expect(response.getCompletiontoken()).toBe(COMPLETION_TOKEN); - expect(response.getActionsList()).toHaveLength(1); const completed = response.getActionsList()[0].getCompleteorchestration(); - expect(completed?.getOrchestrationstatus()).toBe( - pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED, - ); + expect(completed?.getOrchestrationstatus()).toBe(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); expect(completed?.getResult()?.getValue()).toBe('"done"'); }); - it("processes serialized orchestration request bytes", async () => { - const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); - const orchestrator: TOrchestrator = async (_ctx: OrchestrationContext) => "done"; - const name = worker.addOrchestrator(orchestrator); - - const request = new OrchestratorRequest(); - request.setInstanceid(TEST_INSTANCE_ID); - request.setNeweventsList([ - newOrchestratorStartedEvent(new Date("2026-01-01T00:00:00.000Z")), - newExecutionStartedEvent(name, TEST_INSTANCE_ID, undefined), - ]); - - const responseBytes = await worker.processOrchestratorRequest(Buffer.from(request.serializeBinary())); - const response = OrchestratorResponse.deserializeBinary(responseBytes); - - expect(response.getInstanceid()).toBe(TEST_INSTANCE_ID); - expect(response.getActionsList()[0].getCompleteorchestration()?.getResult()?.getValue()).toBe('"done"'); - }); - - it("executes a single entity batch request without using the gRPC worker loop", async () => { - const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); - worker.addNamedEntity("counter", () => new CounterEntity()); - - const response = await worker.executeEntityBatchRequest( - createEntityBatchRequest("counter", "key1"), - COMPLETION_TOKEN, - ); - - expect(response.getCompletiontoken()).toBe(COMPLETION_TOKEN); - expect(response.getResultsList()).toHaveLength(1); - expect(response.getResultsList()[0].getSuccess()?.getResult()?.getValue()).toBe("1"); - expect(response.getEntitystate()?.getValue()).toBe("1"); - }); - - it("processes serialized entity batch request bytes", async () => { + it("processes a single serialized entity batch request without the gRPC worker loop", async () => { const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); worker.addNamedEntity("counter", () => new CounterEntity()); const request = createEntityBatchRequest("counter", "key1"); const responseBytes = await worker.processEntityBatchRequest(request.serializeBinary()); - const response = EntityBatchResult.deserializeBinary(responseBytes); + const response = pb.EntityBatchResult.deserializeBinary(responseBytes); + expect(response.getResultsList()).toHaveLength(1); expect(response.getResultsList()[0].getSuccess()?.getResult()?.getValue()).toBe("1"); expect(response.getEntitystate()?.getValue()).toBe("1"); }); - - it("decodes and executes a single V2 entity request", async () => { - const worker = new TaskHubGrpcWorker({ logger: new NoOpLogger() }); - worker.addNamedEntity("counter", () => new CounterEntity()); - - const request = new EntityRequest(); - request.setInstanceid("@counter@key1"); - const operationEvent = new pb.HistoryEvent(); - const operation = new pb.EntityOperationSignaledEvent(); - operation.setOperation("increment"); - operation.setRequestid("req-1"); - operationEvent.setEntityoperationsignaled(operation); - request.setOperationrequestsList([operationEvent]); - - const decodedRequest = decodeEntityRequestFromBase64( - Buffer.from(request.serializeBinary()).toString("base64"), - ); - const response = await worker.executeEntityRequest(decodedRequest, COMPLETION_TOKEN); - - expect(response.getCompletiontoken()).toBe(COMPLETION_TOKEN); - expect(response.getResultsList()[0].getSuccess()?.getResult()?.getValue()).toBe("1"); - expect(response.getOperationinfosList()[0].getRequestid()).toBe("req-1"); - }); }); -function createEntityBatchRequest(entityName: string, entityKey: string): EntityBatchRequest { - const request = new EntityBatchRequest(); +function createEntityBatchRequest(entityName: string, entityKey: string): pb.EntityBatchRequest { + const request = new pb.EntityBatchRequest(); request.setInstanceid(`@${entityName}@${entityKey}`); const operation = new pb.OperationRequest(); From f54138d89d64b35cbf199bfaf6b2260708e100ca Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 1 Jul 2026 15:34:32 -0700 Subject: [PATCH 5/7] Add Azure Functions Durable workspace package Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 72 +++++++ .../azure-functions-durable-js/CHANGELOG.md | 7 + packages/azure-functions-durable-js/LICENSE | 21 ++ packages/azure-functions-durable-js/README.md | 81 ++++++++ .../azure-functions-durable-js/jest.config.js | 12 ++ .../azure-functions-durable-js/package.json | 63 ++++++ .../azure-functions-durable-js/src/client.ts | 182 ++++++++++++++++++ .../src/durable-grpc.ts | 13 ++ .../src/http-management-payload.ts | 42 ++++ .../azure-functions-durable-js/src/index.ts | 13 ++ .../src/metadata.ts | 20 ++ .../src/user-agent.ts | 23 +++ .../azure-functions-durable-js/src/worker.ts | 30 +++ .../test/unit/client.spec.ts | 114 +++++++++++ .../test/unit/durable-grpc.spec.ts | 19 ++ .../test/unit/worker.spec.ts | 48 +++++ .../tsconfig.build.json | 13 ++ .../azure-functions-durable-js/tsconfig.json | 9 + 18 files changed, 782 insertions(+) create mode 100644 packages/azure-functions-durable-js/CHANGELOG.md create mode 100644 packages/azure-functions-durable-js/LICENSE create mode 100644 packages/azure-functions-durable-js/README.md create mode 100644 packages/azure-functions-durable-js/jest.config.js create mode 100644 packages/azure-functions-durable-js/package.json create mode 100644 packages/azure-functions-durable-js/src/client.ts create mode 100644 packages/azure-functions-durable-js/src/durable-grpc.ts create mode 100644 packages/azure-functions-durable-js/src/http-management-payload.ts create mode 100644 packages/azure-functions-durable-js/src/index.ts create mode 100644 packages/azure-functions-durable-js/src/metadata.ts create mode 100644 packages/azure-functions-durable-js/src/user-agent.ts create mode 100644 packages/azure-functions-durable-js/src/worker.ts create mode 100644 packages/azure-functions-durable-js/test/unit/client.spec.ts create mode 100644 packages/azure-functions-durable-js/test/unit/durable-grpc.spec.ts create mode 100644 packages/azure-functions-durable-js/test/unit/worker.spec.ts create mode 100644 packages/azure-functions-durable-js/tsconfig.build.json create mode 100644 packages/azure-functions-durable-js/tsconfig.json diff --git a/package-lock.json b/package-lock.json index edf9cdb..99e7309 100644 --- a/package-lock.json +++ b/package-lock.json @@ -182,6 +182,28 @@ "node": ">=20.0.0" } }, + "node_modules/@azure/functions": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.16.1.tgz", + "integrity": "sha512-A9obwC7IBg4NAmxUfTVfYEd8Xg6Px+o85JRprS3UJZt+GYYzIOmEecnFwTe3rl+aiHDewBk/8fnIVrSjR/fNGQ==", + "license": "MIT", + "dependencies": { + "@azure/functions-extensions-base": "0.3.0", + "cookie": "^0.7.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@azure/functions-extensions-base": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@azure/functions-extensions-base/-/functions-extensions-base-0.3.0.tgz", + "integrity": "sha512-Cux0hLu5ZXlC/Kb+yvJVhRLIdkfFwui2HeT5oGZL00r/GCUUkhGTzRfZUjRN4Bq729mPv3okPucz2z7SMQLStA==", + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, "node_modules/@azure/identity": { "version": "4.13.1", "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", @@ -3182,6 +3204,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -3368,6 +3399,10 @@ "url": "https://dotenvx.com" } }, + "node_modules/durable-functions": { + "resolved": "packages/azure-functions-durable-js", + "link": true + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -7534,6 +7569,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/azure-functions-durable-js": { + "name": "durable-functions", + "version": "4.0.0-alpha.0", + "license": "MIT", + "dependencies": { + "@azure/functions": "^4.16.1", + "@grpc/grpc-js": "^1.14.4", + "@microsoft/durabletask-js": "0.3.0" + }, + "devDependencies": { + "@types/jest": "^29.5.1", + "@types/node": "^18.16.1", + "jest": "^29.5.0", + "ts-jest": "^29.1.0", + "typescript": "^5.0.4" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "packages/azure-functions-durable-js/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "packages/azure-functions-durable-js/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "packages/durabletask-js": { "name": "@microsoft/durabletask-js", "version": "0.3.0", diff --git a/packages/azure-functions-durable-js/CHANGELOG.md b/packages/azure-functions-durable-js/CHANGELOG.md new file mode 100644 index 0000000..bf22902 --- /dev/null +++ b/packages/azure-functions-durable-js/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 4.0.0-alpha.0 + +- Added the initial gRPC-consolidated Azure Functions Durable provider package. +- Added `DurableFunctionsClient`, a direct `TaskHubGrpcClient` subclass for host-provided gRPC client bindings. +- Added Functions HTTP management payload helpers, worker byte-processing adapter, and `durableRequiresGrpc` binding metadata helper. diff --git a/packages/azure-functions-durable-js/LICENSE b/packages/azure-functions-durable-js/LICENSE new file mode 100644 index 0000000..22aed37 --- /dev/null +++ b/packages/azure-functions-durable-js/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/azure-functions-durable-js/README.md b/packages/azure-functions-durable-js/README.md new file mode 100644 index 0000000..007bb2a --- /dev/null +++ b/packages/azure-functions-durable-js/README.md @@ -0,0 +1,81 @@ +# durable-functions + +Azure Functions Durable provider for the Durable Task JavaScript SDK. + +This package is the gRPC-consolidated Durable Functions provider for JavaScript. It builds on `@microsoft/durabletask-js` and is intended to supersede the legacy `durable-functions` package at a new major version. + +## Phase 1 scope + +This preview includes the low-level host integration pieces: + +- `DurableFunctionsClient`, a direct subclass of `TaskHubGrpcClient`. +- HTTP management payload helpers for Durable HTTP starter responses. +- `DurableFunctionsWorker`, which accepts base64-encoded protobuf work-item payloads from the Functions host and delegates execution to the core worker byte processors. +- `addDurableGrpcMetadata`, which stamps `durableRequiresGrpc: true` onto durable trigger and client binding metadata. + +The full Durable Functions JavaScript authoring model is not included yet. Use `@microsoft/durabletask-js` APIs directly for orchestrator, activity, and entity implementations in this phase. + +## Client binding + +The Functions host passes a JSON client-binding payload to the app. Construct the client from that payload: + +```typescript +import { DurableFunctionsClient } from "durable-functions"; + +const client = new DurableFunctionsClient(clientBindingJson); +const instanceId = await client.scheduleNewOrchestration("hello", { name: "Durable" }); +return client.createCheckStatusResponse(request, instanceId); +``` + +`DurableFunctionsClient` extends `TaskHubGrpcClient`, so orchestration and entity management methods come from the core SDK by inheritance. The only Functions-specific public helpers are: + +- `createCheckStatusResponse(request, instanceId)` +- `createHttpManagementPayload(request, instanceId)` + +Both helpers derive the management endpoint from the incoming HTTP request origin: + +```text +{scheme}://{host}/runtime/webhooks/durabletask/instances/{instanceId} +``` + +## gRPC metadata + +The client mirrors the Python Azure Functions Durable provider interceptor by adding per-call gRPC metadata: + +- `taskhub`: the task hub name from the host client-binding payload. +- `x-user-agent`: the package user agent. gRPC reserves `user-agent`, so this package uses `x-user-agent`. + +The host-provided `requiredQueryStringParameters` value is used for HTTP management URLs. Python PR #155 passes it to the interceptor constructor but does not emit it as gRPC metadata; this package keeps the same behavior. + +## Serialization + +This package intentionally does not port Python's `DEFAULT_FUNCTIONS_DATA_CONVERTER`. The JavaScript core client and worker already serialize payloads at the protobuf string boundary with plain `JSON.stringify` and `JSON.parse`, which matches the Azure Functions JavaScript worker's plain JSON payload contract for this gRPC path. + +## Worker adapter + +`DurableFunctionsWorker` extends `TaskHubGrpcWorker` and adds base64 helpers for the Functions host's middleware-passthrough payloads: + +```typescript +const worker = new DurableFunctionsWorker(); +worker.addOrchestrator(myOrchestrator); + +const encodedResponse = await worker.handleOrchestratorRequest(encodedRequest); +``` + +## Phase 2 plan + +Phase 2 will port the full Durable Functions JavaScript authoring surface onto the core SDK. Planned work: + +- `src/app.ts`: add `DFApp` and `Blueprint` equivalents that mirror Python `decorators/durable_app.py` and register Azure Functions v4 handlers. +- `src/decorators/`: add durable trigger and durable client binding helpers that call `addDurableGrpcMetadata` and emit `durableRequiresGrpc: true`. +- `src/orchestrator.ts`: add an `Orchestrator` wrapper that converts Functions invocation payloads into `DurableFunctionsWorker.handleOrchestratorRequest` calls. +- `src/entity.ts`: add entity handler glue over `DurableFunctionsWorker.handleEntityBatchRequest`. +- `src/input.ts`: add a durable client input helper that constructs `DurableFunctionsClient` from the host binding payload. +- `test/authoring/`: add parity tests for `DFApp`, `Blueprint`, orchestration trigger registration, entity trigger registration, durable client input registration, and generated binding metadata. + +Open questions for the Functions extension team: + +- Confirm the exact JavaScript client-binding payload field set and whether `rpcBaseUrl` is always an absolute URL with scheme. +- Confirm whether `requiredQueryStringParameters` always includes any required `taskHub` and `connection` HTTP routing parameters. This Phase 1 port mirrors Python PR #155 and appends only that host-provided string to management URLs. +- Confirm whether `requiredQueryStringParameters` should ever be emitted as gRPC metadata; Python stores it on the interceptor but only sends `taskhub` and `x-user-agent`. +- Confirm whether the local gRPC sidecar remains plaintext-only for JavaScript (`useTLS: false`) in all supported Functions hosting modes. diff --git a/packages/azure-functions-durable-js/jest.config.js b/packages/azure-functions-durable-js/jest.config.js new file mode 100644 index 0000000..a830572 --- /dev/null +++ b/packages/azure-functions-durable-js/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["**/test/**/*.spec.ts"], + moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], + transform: { + "^.+\\.ts$": "ts-jest", + }, + moduleNameMapper: { + "^@microsoft/durabletask-js$": "/../durabletask-js/src/index.ts", + }, +}; diff --git a/packages/azure-functions-durable-js/package.json b/packages/azure-functions-durable-js/package.json new file mode 100644 index 0000000..8bcbc84 --- /dev/null +++ b/packages/azure-functions-durable-js/package.json @@ -0,0 +1,63 @@ +{ + "name": "durable-functions", + "version": "4.0.0-alpha.0", + "description": "Azure Functions Durable provider for the Durable Task JavaScript SDK", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "clean": "node -e \"require('fs').rmSync('dist', {recursive:true, force:true})\"", + "prebuild": "node -p \"'// Auto-generated by prebuild\\nexport const SDK_VERSION = ' + JSON.stringify(require('./package.json').version) + ';\\nexport const SDK_PACKAGE_NAME = ' + JSON.stringify(require('./package.json').name) + ';'\" > src/version.ts", + "build:core": "npm run build -w @microsoft/durabletask-js", + "build": "npm run prebuild && npm run clean && npm run build:core && tsc -p tsconfig.build.json", + "test": "jest --runInBand --detectOpenHandles", + "test:unit": "jest test/unit --runInBand --detectOpenHandles", + "prepublishOnly": "npm run build && npm run test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/durabletask-js.git", + "directory": "packages/azure-functions-durable-js" + }, + "keywords": [ + "azure-functions", + "durable-functions", + "durabletask", + "orchestration", + "workflow", + "grpc" + ], + "author": "Microsoft", + "license": "MIT", + "bugs": { + "url": "https://github.com/microsoft/durabletask-js/issues" + }, + "homepage": "https://github.com/microsoft/durabletask-js/tree/main/packages/azure-functions-durable-js#readme", + "engines": { + "node": ">=22.0.0" + }, + "dependencies": { + "@azure/functions": "^4.16.1", + "@grpc/grpc-js": "^1.14.4", + "@microsoft/durabletask-js": "0.3.0" + }, + "devDependencies": { + "@types/jest": "^29.5.1", + "@types/node": "^18.16.1", + "jest": "^29.5.0", + "ts-jest": "^29.1.0", + "typescript": "^5.0.4" + } +} diff --git a/packages/azure-functions-durable-js/src/client.ts b/packages/azure-functions-durable-js/src/client.ts new file mode 100644 index 0000000..a5aaa87 --- /dev/null +++ b/packages/azure-functions-durable-js/src/client.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { HttpRequest, HttpResponse } from "@azure/functions"; +import { TaskHubGrpcClient } from "@microsoft/durabletask-js"; +import { + HttpManagementPayload, + createHttpManagementPayload as createPayload, +} from "./http-management-payload"; +import { createAzureFunctionsMetadataGenerator } from "./metadata"; + +export interface DurableFunctionsClientConfig { + taskHubName?: string; + connectionName?: string; + creationUrls?: Record; + managementUrls?: Record; + baseUrl?: string; + requiredQueryStringParameters?: string; + rpcBaseUrl?: string; + httpBaseUrl?: string; + maxGrpcMessageSizeInBytes?: number; + grpcHttpClientTimeout?: unknown; +} + +export type DurableFunctionsClientInput = string | DurableFunctionsClientConfig; + +export class DurableFunctionsClient extends TaskHubGrpcClient { + public readonly taskHubName: string; + public readonly connectionName: string; + public readonly creationUrls: Record; + public readonly managementUrls: Record; + public readonly baseUrl: string; + public readonly requiredQueryStringParameters: string; + public readonly rpcBaseUrl: string; + public readonly httpBaseUrl: string; + public readonly maxGrpcMessageSizeInBytes: number; + public readonly grpcHttpClientTimeout: unknown; + + constructor(clientConfig: DurableFunctionsClientInput) { + const config = parseClientConfig(clientConfig); + const taskHubName = config.taskHubName ?? ""; + const requiredQueryStringParameters = config.requiredQueryStringParameters ?? ""; + const rpcBaseUrl = requireString(config.rpcBaseUrl, "rpcBaseUrl"); + + super({ + hostAddress: getGrpcHostAddress(rpcBaseUrl), + useTLS: false, + metadataGenerator: createAzureFunctionsMetadataGenerator( + taskHubName, + requiredQueryStringParameters, + ), + }); + + this.taskHubName = taskHubName; + this.connectionName = config.connectionName ?? ""; + this.creationUrls = config.creationUrls ?? {}; + this.managementUrls = config.managementUrls ?? {}; + this.baseUrl = config.baseUrl ?? ""; + this.requiredQueryStringParameters = requiredQueryStringParameters; + this.rpcBaseUrl = rpcBaseUrl; + this.httpBaseUrl = config.httpBaseUrl ?? ""; + this.maxGrpcMessageSizeInBytes = config.maxGrpcMessageSizeInBytes ?? 0; + this.grpcHttpClientTimeout = config.grpcHttpClientTimeout; + } + + createCheckStatusResponse(request: HttpRequest, instanceId: string): HttpResponse { + const payload = this.createHttpManagementPayload(request, instanceId); + + return new HttpResponse({ + status: 202, + body: JSON.stringify(payload), + headers: { + "content-type": "application/json", + Location: payload.statusQueryGetUri, + }, + }); + } + + createHttpManagementPayload(request: HttpRequest, instanceId: string): HttpManagementPayload { + const instanceStatusUrl = getInstanceStatusUrl(request, instanceId); + return createPayload(instanceId, instanceStatusUrl, this.requiredQueryStringParameters); + } +} + +export function getGrpcHostAddress(rpcBaseUrl: string): string { + try { + const hostAddress = new URL(rpcBaseUrl).host; + if (!hostAddress) { + throw new Error("rpcBaseUrl must include a host."); + } + return hostAddress; + } catch (e) { + throw new Error(`Invalid Durable Functions rpcBaseUrl: ${rpcBaseUrl}`, { cause: e }); + } +} + +function getInstanceStatusUrl(request: HttpRequest, instanceId: string): string { + const requestUrl = new URL(request.url); + const encodedInstanceId = encodeURIComponent(instanceId); + return `${requestUrl.protocol}//${requestUrl.host}/runtime/webhooks/durabletask/instances/${encodedInstanceId}`; +} + +function parseClientConfig(clientConfig: DurableFunctionsClientInput): DurableFunctionsClientConfig { + const value: unknown = typeof clientConfig === "string" ? JSON.parse(clientConfig) : clientConfig; + const record = requireRecord(value, "Durable Functions client configuration"); + + return { + taskHubName: optionalString(record, "taskHubName"), + connectionName: optionalString(record, "connectionName"), + creationUrls: optionalStringRecord(record, "creationUrls"), + managementUrls: optionalStringRecord(record, "managementUrls"), + baseUrl: optionalString(record, "baseUrl"), + requiredQueryStringParameters: optionalString(record, "requiredQueryStringParameters"), + rpcBaseUrl: optionalString(record, "rpcBaseUrl"), + httpBaseUrl: optionalString(record, "httpBaseUrl"), + maxGrpcMessageSizeInBytes: optionalNumber(record, "maxGrpcMessageSizeInBytes"), + grpcHttpClientTimeout: record.grpcHttpClientTimeout, + }; +} + +function requireString(value: string | undefined, name: string): string { + if (!value) { + throw new TypeError(`Durable Functions client configuration is missing ${name}.`); + } + + return value; +} + +function requireRecord(value: unknown, name: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`${name} must be a JSON object.`); + } + + return value as Record; +} + +function optionalString(record: Record, name: string): string | undefined { + const value = record[name]; + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "string") { + throw new TypeError(`Durable Functions client configuration field ${name} must be a string.`); + } + + return value; +} + +function optionalNumber(record: Record, name: string): number | undefined { + const value = record[name]; + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "number") { + throw new TypeError(`Durable Functions client configuration field ${name} must be a number.`); + } + + return value; +} + +function optionalStringRecord( + record: Record, + name: string, +): Record | undefined { + const value = record[name]; + if (value === undefined || value === null) { + return undefined; + } + + const valueRecord = requireRecord(value, `Durable Functions client configuration field ${name}`); + const result: Record = {}; + for (const [key, entry] of Object.entries(valueRecord)) { + if (typeof entry !== "string") { + throw new TypeError( + `Durable Functions client configuration field ${name}.${key} must be a string.`, + ); + } + result[key] = entry; + } + + return result; +} diff --git a/packages/azure-functions-durable-js/src/durable-grpc.ts b/packages/azure-functions-durable-js/src/durable-grpc.ts new file mode 100644 index 0000000..47b5058 --- /dev/null +++ b/packages/azure-functions-durable-js/src/durable-grpc.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export type DurableBindingMetadata = Record; + +export function addDurableGrpcMetadata( + binding: TBinding, +): TBinding & { durableRequiresGrpc: true } { + return { + ...binding, + durableRequiresGrpc: true, + }; +} diff --git a/packages/azure-functions-durable-js/src/http-management-payload.ts b/packages/azure-functions-durable-js/src/http-management-payload.ts new file mode 100644 index 0000000..640d7ad --- /dev/null +++ b/packages/azure-functions-durable-js/src/http-management-payload.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export interface HttpManagementPayload { + id: string; + purgeHistoryDeleteUri: string; + restartPostUri: string; + rewindPostUri: string; + sendEventPostUri: string; + statusQueryGetUri: string; + terminatePostUri: string; + resumePostUri: string; + suspendPostUri: string; +} + +export function createHttpManagementPayload( + instanceId: string, + instanceStatusUrl: string, + requiredQueryStringParameters: string, +): HttpManagementPayload { + const queryString = normalizeQueryString(requiredQueryStringParameters); + const querySuffix = queryString ? `?${queryString}` : ""; + const reasonQuerySuffix = queryString ? `?reason={text}&${queryString}` : "?reason={text}"; + + return { + id: instanceId, + purgeHistoryDeleteUri: `${instanceStatusUrl}${querySuffix}`, + restartPostUri: `${instanceStatusUrl}/restart${querySuffix}`, + rewindPostUri: `${instanceStatusUrl}/rewind${reasonQuerySuffix}`, + sendEventPostUri: `${instanceStatusUrl}/raiseEvent/{eventName}${querySuffix}`, + statusQueryGetUri: `${instanceStatusUrl}${querySuffix}`, + terminatePostUri: `${instanceStatusUrl}/terminate${reasonQuerySuffix}`, + resumePostUri: `${instanceStatusUrl}/resume${reasonQuerySuffix}`, + suspendPostUri: `${instanceStatusUrl}/suspend${reasonQuerySuffix}`, + }; +} + +function normalizeQueryString(requiredQueryStringParameters: string): string { + return requiredQueryStringParameters.startsWith("?") + ? requiredQueryStringParameters.slice(1) + : requiredQueryStringParameters; +} diff --git a/packages/azure-functions-durable-js/src/index.ts b/packages/azure-functions-durable-js/src/index.ts new file mode 100644 index 0000000..d71d89a --- /dev/null +++ b/packages/azure-functions-durable-js/src/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export { + DurableFunctionsClient, + DurableFunctionsClientConfig, + DurableFunctionsClientInput, + getGrpcHostAddress, +} from "./client"; +export { HttpManagementPayload } from "./http-management-payload"; +export { createAzureFunctionsMetadataGenerator } from "./metadata"; +export { DurableFunctionsWorker } from "./worker"; +export { DurableBindingMetadata, addDurableGrpcMetadata } from "./durable-grpc"; diff --git a/packages/azure-functions-durable-js/src/metadata.ts b/packages/azure-functions-durable-js/src/metadata.ts new file mode 100644 index 0000000..b75594c --- /dev/null +++ b/packages/azure-functions-durable-js/src/metadata.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as grpc from "@grpc/grpc-js"; +import { MetadataGenerator } from "@microsoft/durabletask-js"; +import { getUserAgent } from "./user-agent"; + +export function createAzureFunctionsMetadataGenerator( + taskHubName: string, + _requiredQueryStringParameters: string = "", +): MetadataGenerator { + const userAgent = getUserAgent(); + + return async (): Promise => { + const metadata = new grpc.Metadata(); + metadata.set("taskhub", taskHubName); + metadata.set("x-user-agent", userAgent); + return metadata; + }; +} diff --git a/packages/azure-functions-durable-js/src/user-agent.ts b/packages/azure-functions-durable-js/src/user-agent.ts new file mode 100644 index 0000000..fe69113 --- /dev/null +++ b/packages/azure-functions-durable-js/src/user-agent.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const SDK_NAME = "durable-functions"; + +let packageVersion = "unknown"; + +function getPackageVersion(): string { + if (packageVersion === "unknown") { + try { + const pkg = require("../package.json"); + packageVersion = pkg.version ?? "unknown"; + } catch { + // Keep the fallback when package metadata is unavailable. + } + } + + return packageVersion; +} + +export function getUserAgent(): string { + return `${SDK_NAME}/${getPackageVersion()}`; +} diff --git a/packages/azure-functions-durable-js/src/worker.ts b/packages/azure-functions-durable-js/src/worker.ts new file mode 100644 index 0000000..4eb2bc8 --- /dev/null +++ b/packages/azure-functions-durable-js/src/worker.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TaskHubGrpcWorker, TaskHubGrpcWorkerOptions } from "@microsoft/durabletask-js"; + +export class DurableFunctionsWorker extends TaskHubGrpcWorker { + constructor(options: TaskHubGrpcWorkerOptions = {}) { + super(options); + } + + async handleOrchestratorRequest(encodedRequest: string): Promise { + const request = decodeBase64Request(encodedRequest, "orchestrator"); + const response = await this.processOrchestratorRequest(request); + return Buffer.from(response).toString("base64"); + } + + async handleEntityBatchRequest(encodedRequest: string): Promise { + const request = decodeBase64Request(encodedRequest, "entity batch"); + const response = await this.processEntityBatchRequest(request); + return Buffer.from(response).toString("base64"); + } +} + +function decodeBase64Request(encodedRequest: string, requestType: string): Buffer { + if (!encodedRequest) { + throw new TypeError(`${requestType} request must be a non-empty base64 string.`); + } + + return Buffer.from(encodedRequest, "base64"); +} diff --git a/packages/azure-functions-durable-js/test/unit/client.spec.ts b/packages/azure-functions-durable-js/test/unit/client.spec.ts new file mode 100644 index 0000000..e2a27d0 --- /dev/null +++ b/packages/azure-functions-durable-js/test/unit/client.spec.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { HttpRequest } from "@azure/functions"; +import { TaskHubGrpcClient } from "@microsoft/durabletask-js"; +import { DurableFunctionsClient, getGrpcHostAddress } from "../../src/client"; +import { createAzureFunctionsMetadataGenerator } from "../../src/metadata"; + +const CLIENT_CONFIG = { + taskHubName: "functions-taskhub", + rpcBaseUrl: "http://127.0.0.1:4711/rpc", + requiredQueryStringParameters: "code=secret&taskHub=functions-taskhub", + httpBaseUrl: "https://ignored.example/runtime/webhooks/durabletask", +}; + +describe("DurableFunctionsClient", () => { + it("derives the gRPC host address from rpcBaseUrl", () => { + expect(getGrpcHostAddress("http://127.0.0.1:4711/rpc")).toBe("127.0.0.1:4711"); + expect(getGrpcHostAddress("https://localhost:9443")).toBe("localhost:9443"); + }); + + it("extends TaskHubGrpcClient and does not redeclare management methods", async () => { + const client = new DurableFunctionsClient(JSON.stringify(CLIENT_CONFIG)); + + try { + expect(client).toBeInstanceOf(TaskHubGrpcClient); + expect(typeof client.scheduleNewOrchestration).toBe("function"); + expect(typeof client.getOrchestrationState).toBe("function"); + expect(typeof client.raiseOrchestrationEvent).toBe("function"); + expect(typeof client.terminateOrchestration).toBe("function"); + expect(typeof client.suspendOrchestration).toBe("function"); + expect(typeof client.resumeOrchestration).toBe("function"); + expect(typeof client.purgeOrchestration).toBe("function"); + expect(typeof client.signalEntity).toBe("function"); + expect(typeof client.getEntity).toBe("function"); + expect(Object.getOwnPropertyNames(DurableFunctionsClient.prototype).sort()).toEqual([ + "constructor", + "createCheckStatusResponse", + "createHttpManagementPayload", + ]); + } finally { + await client.stop(); + } + }); + + it("creates HTTP management payload URLs from the incoming request", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + const request = new HttpRequest({ + method: "POST", + url: "https://public.example/api/start?ignored=true", + }); + + try { + const payload = client.createHttpManagementPayload(request, "instance 1"); + + expect(payload).toEqual({ + id: "instance 1", + purgeHistoryDeleteUri: + "https://public.example/runtime/webhooks/durabletask/instances/instance%201?code=secret&taskHub=functions-taskhub", + restartPostUri: + "https://public.example/runtime/webhooks/durabletask/instances/instance%201/restart?code=secret&taskHub=functions-taskhub", + rewindPostUri: + "https://public.example/runtime/webhooks/durabletask/instances/instance%201/rewind?reason={text}&code=secret&taskHub=functions-taskhub", + sendEventPostUri: + "https://public.example/runtime/webhooks/durabletask/instances/instance%201/raiseEvent/{eventName}?code=secret&taskHub=functions-taskhub", + statusQueryGetUri: + "https://public.example/runtime/webhooks/durabletask/instances/instance%201?code=secret&taskHub=functions-taskhub", + terminatePostUri: + "https://public.example/runtime/webhooks/durabletask/instances/instance%201/terminate?reason={text}&code=secret&taskHub=functions-taskhub", + resumePostUri: + "https://public.example/runtime/webhooks/durabletask/instances/instance%201/resume?reason={text}&code=secret&taskHub=functions-taskhub", + suspendPostUri: + "https://public.example/runtime/webhooks/durabletask/instances/instance%201/suspend?reason={text}&code=secret&taskHub=functions-taskhub", + }); + } finally { + await client.stop(); + } + }); + + it("creates 202 check status responses with Location and JSON body", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + const request = new HttpRequest({ + method: "POST", + url: "http://localhost:7071/api/orchestrators/hello", + }); + + try { + const response = client.createCheckStatusResponse(request, "abc"); + + expect(response.status).toBe(202); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(response.headers.get("Location")).toBe( + "http://localhost:7071/runtime/webhooks/durabletask/instances/abc?code=secret&taskHub=functions-taskhub", + ); + const body = JSON.parse(await response.text()); + expect(body.statusQueryGetUri).toBe( + "http://localhost:7071/runtime/webhooks/durabletask/instances/abc?code=secret&taskHub=functions-taskhub", + ); + } finally { + await client.stop(); + } + }); + + it("mirrors the Azure Functions gRPC metadata interceptor", async () => { + const metadata = await createAzureFunctionsMetadataGenerator( + "functions-taskhub", + "code=secret", + )(); + + expect(metadata.get("taskhub")).toEqual(["functions-taskhub"]); + expect(metadata.get("x-user-agent")[0]).toMatch(/^durable-functions\//); + expect(metadata.get("requiredQueryStringParameters")).toEqual([]); + }); +}); diff --git a/packages/azure-functions-durable-js/test/unit/durable-grpc.spec.ts b/packages/azure-functions-durable-js/test/unit/durable-grpc.spec.ts new file mode 100644 index 0000000..89d01ac --- /dev/null +++ b/packages/azure-functions-durable-js/test/unit/durable-grpc.spec.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { addDurableGrpcMetadata } from "../../src/durable-grpc"; + +describe("addDurableGrpcMetadata", () => { + it("adds durableRequiresGrpc without mutating the original binding", () => { + const binding = { type: "orchestrationTrigger", name: "context" }; + + const actual = addDurableGrpcMetadata(binding); + + expect(actual).toEqual({ + type: "orchestrationTrigger", + name: "context", + durableRequiresGrpc: true, + }); + expect(binding).toEqual({ type: "orchestrationTrigger", name: "context" }); + }); +}); diff --git a/packages/azure-functions-durable-js/test/unit/worker.spec.ts b/packages/azure-functions-durable-js/test/unit/worker.spec.ts new file mode 100644 index 0000000..7e32302 --- /dev/null +++ b/packages/azure-functions-durable-js/test/unit/worker.spec.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { DurableFunctionsWorker } from "../../src/worker"; + +describe("DurableFunctionsWorker", () => { + it("decodes base64, delegates to processOrchestratorRequest, and re-encodes the response", async () => { + const worker = new DurableFunctionsWorker(); + const responseBytes = Buffer.from("orchestrator response"); + const processOrchestratorRequest = jest + .spyOn(worker, "processOrchestratorRequest") + .mockResolvedValue(responseBytes); + + const actual = await worker.handleOrchestratorRequest( + Buffer.from("orchestrator request").toString("base64"), + ); + + expect(actual).toBe(responseBytes.toString("base64")); + expect(processOrchestratorRequest).toHaveBeenCalledTimes(1); + expect(Buffer.from(processOrchestratorRequest.mock.calls[0][0]).toString()).toBe( + "orchestrator request", + ); + }); + + it("decodes base64, delegates to processEntityBatchRequest, and re-encodes the response", async () => { + const worker = new DurableFunctionsWorker(); + const responseBytes = Buffer.from("entity batch response"); + const processEntityBatchRequest = jest + .spyOn(worker, "processEntityBatchRequest") + .mockResolvedValue(responseBytes); + + const actual = await worker.handleEntityBatchRequest( + Buffer.from("entity batch request").toString("base64"), + ); + + expect(actual).toBe(responseBytes.toString("base64")); + expect(processEntityBatchRequest).toHaveBeenCalledTimes(1); + expect(Buffer.from(processEntityBatchRequest.mock.calls[0][0]).toString()).toBe( + "entity batch request", + ); + }); + + it("rejects empty base64 requests", async () => { + const worker = new DurableFunctionsWorker(); + + await expect(worker.handleOrchestratorRequest("")).rejects.toThrow(TypeError); + }); +}); diff --git a/packages/azure-functions-durable-js/tsconfig.build.json b/packages/azure-functions-durable-js/tsconfig.build.json new file mode 100644 index 0000000..b309342 --- /dev/null +++ b/packages/azure-functions-durable-js/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "baseUrl": ".", + "paths": { + "@microsoft/durabletask-js": ["../durabletask-js/dist/index"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/azure-functions-durable-js/tsconfig.json b/packages/azure-functions-durable-js/tsconfig.json new file mode 100644 index 0000000..0180107 --- /dev/null +++ b/packages/azure-functions-durable-js/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "test"] +} From 93f51220e5a36f516e9dc8d56fea8a87ac83b339 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 1 Jul 2026 15:44:55 -0700 Subject: [PATCH 6/7] Rename Azure Functions Durable package folder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 8 ++++---- .../CHANGELOG.md | 0 .../LICENSE | 0 .../README.md | 0 .../jest.config.js | 0 .../package.json | 4 ++-- .../src/client.ts | 0 .../src/durable-grpc.ts | 0 .../src/http-management-payload.ts | 0 .../src/index.ts | 0 .../src/metadata.ts | 0 .../src/user-agent.ts | 0 .../src/worker.ts | 0 .../test/unit/client.spec.ts | 0 .../test/unit/durable-grpc.spec.ts | 0 .../test/unit/worker.spec.ts | 0 .../tsconfig.build.json | 0 .../tsconfig.json | 0 18 files changed, 6 insertions(+), 6 deletions(-) rename packages/{azure-functions-durable-js => azure-functions-durable}/CHANGELOG.md (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/LICENSE (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/README.md (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/jest.config.js (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/package.json (94%) rename packages/{azure-functions-durable-js => azure-functions-durable}/src/client.ts (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/src/durable-grpc.ts (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/src/http-management-payload.ts (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/src/index.ts (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/src/metadata.ts (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/src/user-agent.ts (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/src/worker.ts (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/test/unit/client.spec.ts (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/test/unit/durable-grpc.spec.ts (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/test/unit/worker.spec.ts (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/tsconfig.build.json (100%) rename packages/{azure-functions-durable-js => azure-functions-durable}/tsconfig.json (100%) diff --git a/package-lock.json b/package-lock.json index 99e7309..f9c04e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3400,7 +3400,7 @@ } }, "node_modules/durable-functions": { - "resolved": "packages/azure-functions-durable-js", + "resolved": "packages/azure-functions-durable", "link": true }, "node_modules/ecdsa-sig-formatter": { @@ -7569,7 +7569,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/azure-functions-durable-js": { + "packages/azure-functions-durable": { "name": "durable-functions", "version": "4.0.0-alpha.0", "license": "MIT", @@ -7589,7 +7589,7 @@ "node": ">=22.0.0" } }, - "packages/azure-functions-durable-js/node_modules/@types/node": { + "packages/azure-functions-durable/node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", @@ -7599,7 +7599,7 @@ "undici-types": "~5.26.4" } }, - "packages/azure-functions-durable-js/node_modules/undici-types": { + "packages/azure-functions-durable/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", diff --git a/packages/azure-functions-durable-js/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md similarity index 100% rename from packages/azure-functions-durable-js/CHANGELOG.md rename to packages/azure-functions-durable/CHANGELOG.md diff --git a/packages/azure-functions-durable-js/LICENSE b/packages/azure-functions-durable/LICENSE similarity index 100% rename from packages/azure-functions-durable-js/LICENSE rename to packages/azure-functions-durable/LICENSE diff --git a/packages/azure-functions-durable-js/README.md b/packages/azure-functions-durable/README.md similarity index 100% rename from packages/azure-functions-durable-js/README.md rename to packages/azure-functions-durable/README.md diff --git a/packages/azure-functions-durable-js/jest.config.js b/packages/azure-functions-durable/jest.config.js similarity index 100% rename from packages/azure-functions-durable-js/jest.config.js rename to packages/azure-functions-durable/jest.config.js diff --git a/packages/azure-functions-durable-js/package.json b/packages/azure-functions-durable/package.json similarity index 94% rename from packages/azure-functions-durable-js/package.json rename to packages/azure-functions-durable/package.json index 8bcbc84..128ca9f 100644 --- a/packages/azure-functions-durable-js/package.json +++ b/packages/azure-functions-durable/package.json @@ -29,7 +29,7 @@ "repository": { "type": "git", "url": "git+https://github.com/microsoft/durabletask-js.git", - "directory": "packages/azure-functions-durable-js" + "directory": "packages/azure-functions-durable" }, "keywords": [ "azure-functions", @@ -44,7 +44,7 @@ "bugs": { "url": "https://github.com/microsoft/durabletask-js/issues" }, - "homepage": "https://github.com/microsoft/durabletask-js/tree/main/packages/azure-functions-durable-js#readme", + "homepage": "https://github.com/microsoft/durabletask-js/tree/main/packages/azure-functions-durable#readme", "engines": { "node": ">=22.0.0" }, diff --git a/packages/azure-functions-durable-js/src/client.ts b/packages/azure-functions-durable/src/client.ts similarity index 100% rename from packages/azure-functions-durable-js/src/client.ts rename to packages/azure-functions-durable/src/client.ts diff --git a/packages/azure-functions-durable-js/src/durable-grpc.ts b/packages/azure-functions-durable/src/durable-grpc.ts similarity index 100% rename from packages/azure-functions-durable-js/src/durable-grpc.ts rename to packages/azure-functions-durable/src/durable-grpc.ts diff --git a/packages/azure-functions-durable-js/src/http-management-payload.ts b/packages/azure-functions-durable/src/http-management-payload.ts similarity index 100% rename from packages/azure-functions-durable-js/src/http-management-payload.ts rename to packages/azure-functions-durable/src/http-management-payload.ts diff --git a/packages/azure-functions-durable-js/src/index.ts b/packages/azure-functions-durable/src/index.ts similarity index 100% rename from packages/azure-functions-durable-js/src/index.ts rename to packages/azure-functions-durable/src/index.ts diff --git a/packages/azure-functions-durable-js/src/metadata.ts b/packages/azure-functions-durable/src/metadata.ts similarity index 100% rename from packages/azure-functions-durable-js/src/metadata.ts rename to packages/azure-functions-durable/src/metadata.ts diff --git a/packages/azure-functions-durable-js/src/user-agent.ts b/packages/azure-functions-durable/src/user-agent.ts similarity index 100% rename from packages/azure-functions-durable-js/src/user-agent.ts rename to packages/azure-functions-durable/src/user-agent.ts diff --git a/packages/azure-functions-durable-js/src/worker.ts b/packages/azure-functions-durable/src/worker.ts similarity index 100% rename from packages/azure-functions-durable-js/src/worker.ts rename to packages/azure-functions-durable/src/worker.ts diff --git a/packages/azure-functions-durable-js/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts similarity index 100% rename from packages/azure-functions-durable-js/test/unit/client.spec.ts rename to packages/azure-functions-durable/test/unit/client.spec.ts diff --git a/packages/azure-functions-durable-js/test/unit/durable-grpc.spec.ts b/packages/azure-functions-durable/test/unit/durable-grpc.spec.ts similarity index 100% rename from packages/azure-functions-durable-js/test/unit/durable-grpc.spec.ts rename to packages/azure-functions-durable/test/unit/durable-grpc.spec.ts diff --git a/packages/azure-functions-durable-js/test/unit/worker.spec.ts b/packages/azure-functions-durable/test/unit/worker.spec.ts similarity index 100% rename from packages/azure-functions-durable-js/test/unit/worker.spec.ts rename to packages/azure-functions-durable/test/unit/worker.spec.ts diff --git a/packages/azure-functions-durable-js/tsconfig.build.json b/packages/azure-functions-durable/tsconfig.build.json similarity index 100% rename from packages/azure-functions-durable-js/tsconfig.build.json rename to packages/azure-functions-durable/tsconfig.build.json diff --git a/packages/azure-functions-durable-js/tsconfig.json b/packages/azure-functions-durable/tsconfig.json similarity index 100% rename from packages/azure-functions-durable-js/tsconfig.json rename to packages/azure-functions-durable/tsconfig.json From 9ca4fce41b49a1ad9261a5d68a0df899f4a98ae8 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 1 Jul 2026 16:28:48 -0700 Subject: [PATCH 7/7] Remove unused requiredQueryStringParameters param from metadata generator Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/azure-functions-durable/src/client.ts | 5 +---- packages/azure-functions-durable/src/metadata.ts | 1 - packages/azure-functions-durable/test/unit/client.spec.ts | 5 +---- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index a5aaa87..1ae7120 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -45,10 +45,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { super({ hostAddress: getGrpcHostAddress(rpcBaseUrl), useTLS: false, - metadataGenerator: createAzureFunctionsMetadataGenerator( - taskHubName, - requiredQueryStringParameters, - ), + metadataGenerator: createAzureFunctionsMetadataGenerator(taskHubName), }); this.taskHubName = taskHubName; diff --git a/packages/azure-functions-durable/src/metadata.ts b/packages/azure-functions-durable/src/metadata.ts index b75594c..6f84a8b 100644 --- a/packages/azure-functions-durable/src/metadata.ts +++ b/packages/azure-functions-durable/src/metadata.ts @@ -7,7 +7,6 @@ import { getUserAgent } from "./user-agent"; export function createAzureFunctionsMetadataGenerator( taskHubName: string, - _requiredQueryStringParameters: string = "", ): MetadataGenerator { const userAgent = getUserAgent(); diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index e2a27d0..d595bc7 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -102,10 +102,7 @@ describe("DurableFunctionsClient", () => { }); it("mirrors the Azure Functions gRPC metadata interceptor", async () => { - const metadata = await createAzureFunctionsMetadataGenerator( - "functions-taskhub", - "code=secret", - )(); + const metadata = await createAzureFunctionsMetadataGenerator("functions-taskhub")(); expect(metadata.get("taskhub")).toEqual(["functions-taskhub"]); expect(metadata.get("x-user-agent")[0]).toMatch(/^durable-functions\//);