From 57b48be73f7e033ad734a99b8d892b28fd3a7ea7 Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 29 Jun 2026 15:30:30 -0700 Subject: [PATCH 01/26] 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 02/26] 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 03/26] 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 04/26] 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 05/26] 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 06/26] 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 07/26] 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\//); From ff3ab8f807c4cc1e6e874b0076b1bbd64ebbd68d Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 7 Jul 2026 09:26:24 -0700 Subject: [PATCH 08/26] Add trigger registration with durableRequiresGrpc opt-in and getClient glue Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/azure-functions-durable/src/app.ts | 150 ++++++++++++++++++ .../azure-functions-durable/src/get-client.ts | 52 ++++++ packages/azure-functions-durable/src/index.ts | 5 + packages/azure-functions-durable/src/input.ts | 14 ++ .../azure-functions-durable/src/trigger.ts | 22 +++ .../test/unit/app.spec.ts | 85 ++++++++++ .../test/unit/get-client.spec.ts | 60 +++++++ .../test/unit/trigger.spec.ts | 39 +++++ 8 files changed, 427 insertions(+) create mode 100644 packages/azure-functions-durable/src/app.ts create mode 100644 packages/azure-functions-durable/src/get-client.ts create mode 100644 packages/azure-functions-durable/src/input.ts create mode 100644 packages/azure-functions-durable/src/trigger.ts create mode 100644 packages/azure-functions-durable/test/unit/app.spec.ts create mode 100644 packages/azure-functions-durable/test/unit/get-client.spec.ts create mode 100644 packages/azure-functions-durable/test/unit/trigger.spec.ts diff --git a/packages/azure-functions-durable/src/app.ts b/packages/azure-functions-durable/src/app.ts new file mode 100644 index 0000000..a302566 --- /dev/null +++ b/packages/azure-functions-durable/src/app.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + FunctionHandler, + FunctionInput, + FunctionOutput, + InvocationContext, + app as azFuncApp, +} from "@azure/functions"; +import { EntityFactory, TOrchestrator } from "@microsoft/durabletask-js"; +import * as trigger from "./trigger"; +import { DurableFunctionsWorker } from "./worker"; + +// A single worker owns the orchestrator/entity registry for the whole function app. It is never +// started (no gRPC channel, no work-item loop); it only replays one work item per invocation via +// the byte-processor methods. Orchestrators and entities are pre-registered at decoration time so +// the core executor can dispatch by name when the host delivers a work item. This mirrors Python's +// provider intent while avoiding a new worker (and re-registration) per invocation. +const sharedWorker = new DurableFunctionsWorker(); + +/** + * Returns the app-wide {@link DurableFunctionsWorker} that holds the orchestrator/entity registry. + * Exposed for host integration and testing; application code does not normally need it. + */ +export function getSharedWorker(): DurableFunctionsWorker { + return sharedWorker; +} + +/** Secondary bindings that pass straight through to `@azure/functions` `app.generic`. */ +interface ExtraRegistrationOptions { + extraInputs?: FunctionInput[]; + extraOutputs?: FunctionOutput[]; +} + +export type OrchestrationHandler = TOrchestrator; +export type OrchestrationOptions = ExtraRegistrationOptions & { handler: OrchestrationHandler }; + +export type EntityHandler = EntityFactory; +export type EntityOptions = ExtraRegistrationOptions & { handler: EntityHandler }; + +export type ActivityHandler = FunctionHandler; +export type ActivityOptions = ExtraRegistrationOptions & { handler: ActivityHandler }; + +/** + * Registers an orchestrator function. The generator is registered on the shared worker under + * `functionName`, and the Azure Function is wired to the gRPC byte-processing handler so the host's + * base64 `OrchestratorRequest` is replayed by the core executor. + */ +export function orchestration( + functionName: string, + handlerOrOptions: OrchestrationHandler | OrchestrationOptions, +): void { + const options = normalizeOptions(handlerOrOptions); + sharedWorker.addNamedOrchestrator(functionName, options.handler); + azFuncApp.generic(functionName, { + ...extraBindings(options), + trigger: trigger.orchestration(), + handler: createOrchestrationHandler(), + }); +} + +/** + * Registers an entity function. The entity factory is registered on the shared worker under + * `functionName`, and the Azure Function is wired to the gRPC byte-processing handler so the host's + * base64 `EntityBatchRequest` is replayed by the core executor. + */ +export function entity(functionName: string, handlerOrOptions: EntityHandler | EntityOptions): void { + const options = normalizeOptions(handlerOrOptions); + sharedWorker.addNamedEntity(functionName, options.handler); + azFuncApp.generic(functionName, { + ...extraBindings(options), + trigger: trigger.entity(), + handler: createEntityHandler(), + }); +} + +/** + * Registers an activity function. Activities are plain Azure Functions: the host delivers the input + * as the trigger payload and takes the return value directly, so the user handler is registered + * as-is (no worker round-trip). The `activityTrigger` still carries `durableRequiresGrpc: true` so + * the extension keeps the gRPC protocol enabled for the app. + */ +export function activity( + functionName: string, + handlerOrOptions: ActivityHandler | ActivityOptions, +): void { + const options = normalizeOptions(handlerOrOptions); + azFuncApp.generic(functionName, { + ...extraBindings(options), + trigger: trigger.activity(), + handler: options.handler, + }); +} + +/** @hidden */ +function createOrchestrationHandler(): FunctionHandler { + return async (triggerInput: unknown, _context: InvocationContext): Promise => { + return sharedWorker.handleOrchestratorRequest(extractBase64Request(triggerInput)); + }; +} + +/** @hidden */ +function createEntityHandler(): FunctionHandler { + return async (triggerInput: unknown, _context: InvocationContext): Promise => { + return sharedWorker.handleEntityBatchRequest(extractBase64Request(triggerInput)); + }; +} + +/** @hidden */ +function normalizeOptions( + handlerOrOptions: THandler | (ExtraRegistrationOptions & { handler: THandler }), +): ExtraRegistrationOptions & { handler: THandler } { + if (typeof handlerOrOptions === "function") { + return { handler: handlerOrOptions as THandler }; + } + return handlerOrOptions as ExtraRegistrationOptions & { handler: THandler }; +} + +/** @hidden */ +function extraBindings(options: ExtraRegistrationOptions): ExtraRegistrationOptions { + const bindings: ExtraRegistrationOptions = {}; + if (options.extraInputs) { + bindings.extraInputs = options.extraInputs; + } + if (options.extraOutputs) { + bindings.extraOutputs = options.extraOutputs; + } + return bindings; +} + +/** + * Extracts the base64-encoded protobuf request the host delivers to a durable trigger. The value + * may arrive as the raw base64 string, or wrapped in an object exposing a string `body` (mirrors + * the Python worker's `getattr(context, "body")` fallback to the context itself). + * + * @hidden + */ +function extractBase64Request(triggerInput: unknown): string { + if (typeof triggerInput === "string") { + return triggerInput; + } + if (triggerInput !== null && typeof triggerInput === "object" && "body" in triggerInput) { + const body = (triggerInput as { body?: unknown }).body; + if (typeof body === "string") { + return body; + } + } + throw new TypeError("Durable trigger did not provide a base64-encoded request body."); +} diff --git a/packages/azure-functions-durable/src/get-client.ts b/packages/azure-functions-durable/src/get-client.ts new file mode 100644 index 0000000..21f5cd9 --- /dev/null +++ b/packages/azure-functions-durable/src/get-client.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { FunctionInput, InvocationContext } from "@azure/functions"; +import { DurableFunctionsClient, DurableFunctionsClientInput } from "./client"; + +/** + * Builds a {@link DurableFunctionsClient} from the `durableClient` input binding that the + * Azure Functions host provides for the current invocation. + * + * Mirrors the Python provider's `_add_rich_client` middleware, which constructs a + * `DurableFunctionsClient(starter)` from the raw client-binding value. The binding value is the + * host-provided client configuration (a JSON string or already-parsed object); the client + * constructor accepts either shape, so it is passed straight through. + * + * @param context - The invocation context for the current function call. + * @returns A rich Durable Functions client bound to the host's local gRPC sidecar. + * @throws If no `durableClient`/`orchestrationClient` input binding is registered, or its value + * is not a valid client configuration. + */ +export function getClient(context: InvocationContext): DurableFunctionsClient { + const clientInput = context.options.extraInputs.find(isDurableClientInput); + if (!clientInput) { + throw new Error( + "Could not find a registered durable client input binding. Check your extraInputs " + + "definition when registering your function.", + ); + } + + const bindingData: unknown = context.extraInputs.get(clientInput); + return new DurableFunctionsClient(asClientInput(bindingData)); +} + +/** @hidden */ +export function isDurableClientInput(input: FunctionInput): boolean { + return input.type === "durableClient" || input.type === "orchestrationClient"; +} + +/** @hidden */ +function asClientInput(bindingData: unknown): DurableFunctionsClientInput { + if (typeof bindingData === "string") { + return bindingData; + } + if (bindingData !== null && typeof bindingData === "object") { + return bindingData as DurableFunctionsClientInput; + } + + throw new Error( + "Received input is not a valid durable client input. Check your extraInputs definition " + + "when registering your function.", + ); +} diff --git a/packages/azure-functions-durable/src/index.ts b/packages/azure-functions-durable/src/index.ts index d71d89a..5e9178e 100644 --- a/packages/azure-functions-durable/src/index.ts +++ b/packages/azure-functions-durable/src/index.ts @@ -1,12 +1,17 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +export * as app from "./app"; +export * as trigger from "./trigger"; +export * as input from "./input"; + export { DurableFunctionsClient, DurableFunctionsClientConfig, DurableFunctionsClientInput, getGrpcHostAddress, } from "./client"; +export { getClient, isDurableClientInput } from "./get-client"; export { HttpManagementPayload } from "./http-management-payload"; export { createAzureFunctionsMetadataGenerator } from "./metadata"; export { DurableFunctionsWorker } from "./worker"; diff --git a/packages/azure-functions-durable/src/input.ts b/packages/azure-functions-durable/src/input.ts new file mode 100644 index 0000000..0b2e8f5 --- /dev/null +++ b/packages/azure-functions-durable/src/input.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { FunctionInput, input as azFuncInput } from "@azure/functions"; +import { addDurableGrpcMetadata } from "./durable-grpc"; + +// The `durableClient` input binding hands the function the host-provided client configuration +// (task hub name, rpcBaseUrl, required query-string parameters, ...). Like the triggers, it opts +// in to the gRPC protocol via `durableRequiresGrpc: true` so the extension exposes its local +// sidecar; without it `rpcBaseUrl` is never populated and `getClient` cannot connect. + +export function durableClient(): FunctionInput { + return azFuncInput.generic(addDurableGrpcMetadata({ type: "durableClient" })); +} diff --git a/packages/azure-functions-durable/src/trigger.ts b/packages/azure-functions-durable/src/trigger.ts new file mode 100644 index 0000000..e518a96 --- /dev/null +++ b/packages/azure-functions-durable/src/trigger.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { FunctionTrigger, trigger as azFuncTrigger } from "@azure/functions"; +import { addDurableGrpcMetadata } from "./durable-grpc"; + +// The `type` strings below are the wire contract expected by the Durable Task extension's +// binding providers; they must not be renamed. Every Durable trigger opts in to the extension's +// local gRPC protocol by carrying `durableRequiresGrpc: true` (see `addDurableGrpcMetadata`), +// which is what makes the extension open its local sidecar and populate `rpcBaseUrl` for the client. + +export function orchestration(): FunctionTrigger { + return azFuncTrigger.generic(addDurableGrpcMetadata({ type: "orchestrationTrigger" })); +} + +export function activity(): FunctionTrigger { + return azFuncTrigger.generic(addDurableGrpcMetadata({ type: "activityTrigger" })); +} + +export function entity(): FunctionTrigger { + return azFuncTrigger.generic(addDurableGrpcMetadata({ type: "entityTrigger" })); +} diff --git a/packages/azure-functions-durable/test/unit/app.spec.ts b/packages/azure-functions-durable/test/unit/app.spec.ts new file mode 100644 index 0000000..8c5369a --- /dev/null +++ b/packages/azure-functions-durable/test/unit/app.spec.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { GenericFunctionOptions, InvocationContext, app as azFuncApp } from "@azure/functions"; +import * as app from "../../src/app"; +import { DurableFunctionsWorker } from "../../src/worker"; + +describe("app registration", () => { + let genericSpy: jest.SpyInstance; + + beforeEach(() => { + genericSpy = jest.spyOn(azFuncApp, "generic").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function lastRegistration(): { name: string; options: GenericFunctionOptions } { + const call = genericSpy.mock.calls[genericSpy.mock.calls.length - 1]; + return { name: call[0] as string, options: call[1] as GenericFunctionOptions }; + } + + it("registers an orchestration whose trigger opts in to gRPC and whose handler forwards base64", async () => { + const handleSpy = jest + .spyOn(DurableFunctionsWorker.prototype, "handleOrchestratorRequest") + .mockResolvedValue("encoded-orchestrator-response"); + + app.orchestration("orchestration-test", () => undefined); + + const { name, options } = lastRegistration(); + expect(name).toBe("orchestration-test"); + expect(options.trigger.type).toBe("orchestrationTrigger"); + expect(options.trigger.durableRequiresGrpc).toBe(true); + + const result = await options.handler("base64-orchestrator-request", {} as InvocationContext); + + expect(result).toBe("encoded-orchestrator-response"); + expect(handleSpy).toHaveBeenCalledWith("base64-orchestrator-request"); + }); + + it("registers an entity whose trigger opts in to gRPC and whose handler forwards base64", async () => { + const handleSpy = jest + .spyOn(DurableFunctionsWorker.prototype, "handleEntityBatchRequest") + .mockResolvedValue("encoded-entity-response"); + + app.entity("entity-test", () => ({}) as never); + + const { name, options } = lastRegistration(); + expect(name).toBe("entity-test"); + expect(options.trigger.type).toBe("entityTrigger"); + expect(options.trigger.durableRequiresGrpc).toBe(true); + + const result = await options.handler("base64-entity-request", {} as InvocationContext); + + expect(result).toBe("encoded-entity-response"); + expect(handleSpy).toHaveBeenCalledWith("base64-entity-request"); + }); + + it("registers an activity as a plain pass-through handler that still opts in to gRPC", async () => { + const userHandler = jest.fn().mockResolvedValue("activity-result"); + + app.activity("activity-test", userHandler); + + const { name, options } = lastRegistration(); + expect(name).toBe("activity-test"); + expect(options.trigger.type).toBe("activityTrigger"); + expect(options.trigger.durableRequiresGrpc).toBe(true); + expect(options.handler).toBe(userHandler); + }); + + it("throws when a durable trigger delivers no base64 request body", async () => { + jest + .spyOn(DurableFunctionsWorker.prototype, "handleOrchestratorRequest") + .mockResolvedValue("unused"); + + app.orchestration("orchestration-missing-body", { + handler: () => undefined, + }); + + const { options } = lastRegistration(); + + await expect(options.handler(undefined, {} as InvocationContext)).rejects.toThrow(TypeError); + }); +}); diff --git a/packages/azure-functions-durable/test/unit/get-client.spec.ts b/packages/azure-functions-durable/test/unit/get-client.spec.ts new file mode 100644 index 0000000..9dcfa01 --- /dev/null +++ b/packages/azure-functions-durable/test/unit/get-client.spec.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { InvocationContext } from "@azure/functions"; +import { TaskHubGrpcClient } from "@microsoft/durabletask-js"; +import { DurableFunctionsClient } from "../../src/client"; +import { getClient } from "../../src/get-client"; +import * as input from "../../src/input"; + +const CLIENT_CONFIG = { + taskHubName: "functions-taskhub", + rpcBaseUrl: "http://127.0.0.1:4711/rpc", + requiredQueryStringParameters: "code=secret&taskHub=functions-taskhub", +}; + +describe("getClient", () => { + it("builds a DurableFunctionsClient from the durableClient binding value", async () => { + const clientInput = input.durableClient(); + const context = new InvocationContext({ options: { extraInputs: [clientInput] } }); + context.extraInputs.set(clientInput, JSON.stringify(CLIENT_CONFIG)); + + const client = getClient(context); + + try { + expect(client).toBeInstanceOf(DurableFunctionsClient); + expect(client).toBeInstanceOf(TaskHubGrpcClient); + expect(client.taskHubName).toBe("functions-taskhub"); + } finally { + await client.stop(); + } + }); + + it("accepts an already-parsed binding object", async () => { + const clientInput = input.durableClient(); + const context = new InvocationContext({ options: { extraInputs: [clientInput] } }); + context.extraInputs.set(clientInput, CLIENT_CONFIG); + + const client = getClient(context); + + try { + expect(client).toBeInstanceOf(DurableFunctionsClient); + } finally { + await client.stop(); + } + }); + + it("throws when no durable client input binding is registered", () => { + const context = new InvocationContext({ options: { extraInputs: [] } }); + + expect(() => getClient(context)).toThrow(/durable client input binding/i); + }); + + it("throws when the binding value is not a valid client input", () => { + const clientInput = input.durableClient(); + const context = new InvocationContext({ options: { extraInputs: [clientInput] } }); + context.extraInputs.set(clientInput, 42); + + expect(() => getClient(context)).toThrow(/not a valid durable client input/i); + }); +}); diff --git a/packages/azure-functions-durable/test/unit/trigger.spec.ts b/packages/azure-functions-durable/test/unit/trigger.spec.ts new file mode 100644 index 0000000..882337c --- /dev/null +++ b/packages/azure-functions-durable/test/unit/trigger.spec.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as input from "../../src/input"; +import * as trigger from "../../src/trigger"; + +function flag(binding: Record): unknown { + return binding.durableRequiresGrpc; +} + +describe("durable triggers and inputs", () => { + it("orchestration trigger uses the extension type string and opts in to gRPC", () => { + const orchestration = trigger.orchestration(); + + expect(orchestration.type).toBe("orchestrationTrigger"); + expect(flag(orchestration)).toBe(true); + }); + + it("activity trigger uses the extension type string and opts in to gRPC", () => { + const activity = trigger.activity(); + + expect(activity.type).toBe("activityTrigger"); + expect(flag(activity)).toBe(true); + }); + + it("entity trigger uses the extension type string and opts in to gRPC", () => { + const entity = trigger.entity(); + + expect(entity.type).toBe("entityTrigger"); + expect(flag(entity)).toBe(true); + }); + + it("durableClient input uses the extension type string and opts in to gRPC", () => { + const clientInput = input.durableClient(); + + expect(clientInput.type).toBe("durableClient"); + expect(flag(clientInput)).toBe(true); + }); +}); From 4d26c6b3b16b5818b008ead3d4baae0b3019f66b Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 7 Jul 2026 15:33:04 -0700 Subject: [PATCH 09/26] Add classic (v3) backward-compat layer, v3 client query APIs, and release pipeline - Add DurableOrchestrationContext/DurableEntityContext (context.df.* adapters) + wrapOrchestrator/wrapEntity - Add RetryOptions, callHttp (throws), parentInstanceId; align newGuid/callSubOrchestrator with v3 signatures - Add v3 client query-return types (DurableOrchestrationStatus, OrchestrationRuntimeStatus, EntityStateResponse, PurgeHistoryResult) - Add client getStatus/getStatusAll/getStatusBy/readEntityState/purgeInstanceHistory/startNew/waitForCompletionOrCreateCheckStatusResponse - Add deprecated DurableOrchestrationClient alias - Wire host-provided maxGrpcMessageSizeInBytes into gRPC channel options (Python parity) - Remove dead CapturingSidecarStub.abandonRequest field (keep required no-op method) - Add durable-functions package to build/release pipelines --- eng/ci/release.yml | 31 +++ eng/templates/build.yml | 14 +- packages/azure-functions-durable/CHANGELOG.md | 4 + packages/azure-functions-durable/README.md | 67 +++++- packages/azure-functions-durable/src/app.ts | 12 +- .../azure-functions-durable/src/client.ts | 175 +++++++++++++- .../src/entity-context.ts | 114 +++++++++ .../src/entity-state-response.ts | 19 ++ packages/azure-functions-durable/src/index.ts | 24 ++ .../src/orchestration-context.ts | 225 ++++++++++++++++++ .../src/orchestration-status.ts | 127 ++++++++++ .../src/purge-history-result.ts | 13 + .../src/retry-options.ts | 46 ++++ .../test/unit/client-query.spec.ts | 208 ++++++++++++++++ .../test/unit/client.spec.ts | 8 + .../test/unit/entity-context.spec.ts | 132 ++++++++++ .../test/unit/orchestration-context.spec.ts | 189 +++++++++++++++ .../test/unit/query-types.spec.ts | 100 ++++++++ .../test/unit/retry-options.spec.ts | 31 +++ .../src/worker/task-hub-grpc-worker.ts | 7 +- 20 files changed, 1527 insertions(+), 19 deletions(-) create mode 100644 packages/azure-functions-durable/src/entity-context.ts create mode 100644 packages/azure-functions-durable/src/entity-state-response.ts create mode 100644 packages/azure-functions-durable/src/orchestration-context.ts create mode 100644 packages/azure-functions-durable/src/orchestration-status.ts create mode 100644 packages/azure-functions-durable/src/purge-history-result.ts create mode 100644 packages/azure-functions-durable/src/retry-options.ts create mode 100644 packages/azure-functions-durable/test/unit/client-query.spec.ts create mode 100644 packages/azure-functions-durable/test/unit/entity-context.spec.ts create mode 100644 packages/azure-functions-durable/test/unit/orchestration-context.spec.ts create mode 100644 packages/azure-functions-durable/test/unit/query-types.spec.ts create mode 100644 packages/azure-functions-durable/test/unit/retry-options.spec.ts diff --git a/eng/ci/release.yml b/eng/ci/release.yml index 0ac44d8..084871d 100644 --- a/eng/ci/release.yml +++ b/eng/ci/release.yml @@ -84,3 +84,34 @@ extends: serviceendpointurl: 'https://api.esrp.microsoft.com' mainpublisher: 'durabletask-java' domaintenantid: '33e01921-4d64-4f8c-a055-5bdaffd5e33d' + + - job: durable_functions + displayName: 'Release durable-functions' + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + pipeline: DurableTaskJSBuildPipeline + artifactName: drop + targetPath: $(System.DefaultWorkingDirectory)/drop + + steps: + - task: SFP.release-tasks.custom-build-release-task.EsrpRelease@9 + displayName: 'ESRP Release durable-functions' + inputs: + connectedservicename: 'dtfx-internal-esrp-prod' + usemanagedidentity: true + keyvaultname: 'durable-esrp-akv' + signcertname: 'dts-esrp-cert' + clientid: '0b3ed1a4-0727-4a50-b82a-02c2bd9dec89' + intent: 'PackageDistribution' + contenttype: 'npm' + contentsource: 'Folder' + folderlocation: '$(System.DefaultWorkingDirectory)/drop/buildoutputs/azure-functions-durable' + waitforreleasecompletion: true + owners: 'wangbill@microsoft.com' + approvers: 'kaibocai@microsoft.com' + serviceendpointurl: 'https://api.esrp.microsoft.com' + mainpublisher: 'durabletask-java' + domaintenantid: '33e01921-4d64-4f8c-a055-5bdaffd5e33d' diff --git a/eng/templates/build.yml b/eng/templates/build.yml index b59cdb0..0d69813 100644 --- a/eng/templates/build.yml +++ b/eng/templates/build.yml @@ -44,6 +44,12 @@ jobs: npm pack displayName: "pack @microsoft/durabletask-js-azuremanaged" + # Pack durable-functions + - script: | + cd packages/azure-functions-durable + npm pack + displayName: "pack durable-functions" + # Copy each package to its own staging subfolder - task: CopyFiles@2 displayName: "Copy durabletask-js to staging" @@ -56,4 +62,10 @@ jobs: inputs: SourceFolder: $(System.DefaultWorkingDirectory)/packages/durabletask-js-azuremanaged Contents: "*.tgz" - TargetFolder: $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask-js-azuremanaged \ No newline at end of file + TargetFolder: $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask-js-azuremanaged + - task: CopyFiles@2 + displayName: "Copy azure-functions-durable to staging" + inputs: + SourceFolder: $(System.DefaultWorkingDirectory)/packages/azure-functions-durable + Contents: "*.tgz" + TargetFolder: $(Build.ArtifactStagingDirectory)/buildoutputs/azure-functions-durable \ No newline at end of file diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index bf22902..d398fd4 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -5,3 +5,7 @@ - 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. +- Added the authoring model (`app.orchestration` / `app.activity` / `app.entity`, `input.durableClient`, `getClient`). +- Added a classic (v3) backward-compatibility layer: `context.df.*` orchestration and entity context adapters (`wrapOrchestrator` / `wrapEntity`), `RetryOptions`, and the deprecated `DurableOrchestrationClient` alias. Classic single-parameter and core-native two-parameter handlers are both supported. +- Added classic (v3) client query-return types (`DurableOrchestrationStatus`, `OrchestrationRuntimeStatus`, `EntityStateResponse`, `PurgeHistoryResult`) and `DurableFunctionsClient` methods `getStatus` / `readEntityState` / `purgeInstanceHistory` that map from the core client. Added `context.df.callHttp`, which throws (the durabletask engine has no durable-HTTP equivalent). +- Added the remaining v3 client surface: `startNew`, `getStatusAll`, `getStatusBy`, and `waitForCompletionOrCreateCheckStatusResponse`, plus `context.df.parentInstanceId`. The client now honors the host-provided `maxGrpcMessageSizeInBytes` by wiring it into the gRPC channel options (parity with the Python provider). diff --git a/packages/azure-functions-durable/README.md b/packages/azure-functions-durable/README.md index 007bb2a..01df584 100644 --- a/packages/azure-functions-durable/README.md +++ b/packages/azure-functions-durable/README.md @@ -12,8 +12,9 @@ This preview includes the low-level host integration pieces: - 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 authoring model (`app.orchestration` / `app.activity` / `app.entity`, `input.durableClient`, `getClient`) and a **classic (v3) backward-compatibility** layer (see below). -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. +Orchestrator, activity, and entity bodies can be written either in the core `@microsoft/durabletask-js` style or in the classic `durable-functions` v3 style; both run on the gRPC engine. ## Client binding @@ -62,16 +63,64 @@ worker.addOrchestrator(myOrchestrator); const encodedResponse = await worker.handleOrchestratorRequest(encodedRequest); ``` -## Phase 2 plan +## Classic (v3) backward compatibility -Phase 2 will port the full Durable Functions JavaScript authoring surface onto the core SDK. Planned work: +Orchestrators and entities may be written in the legacy `durable-functions` v3 style. The style is +detected by parameter count and adapted onto the core engine — the classic API surface forwards to +the core context, it does not re-implement the v3 replay engine. -- `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. +- **Classic orchestrator** — a single-parameter generator using `context.df.*`: + + ```typescript + import * as df from "durable-functions"; + + df.app.orchestration("helloOrchestrator", function* (context) { + const input = context.df.getInput(); + const retry = new df.RetryOptions(5000, 3); + const r = yield context.df.callActivityWithRetry("sayHello", retry, input); + const all = yield context.df.Task.all([context.df.callActivity("a"), context.df.callActivity("b")]); + return [r, all]; + }); + ``` + +- **Core-native orchestrator** — a two-parameter `(ctx, input)` generator using `ctx.*`, passed through unchanged: + + ```typescript + df.app.orchestration("helloOrchestrator", async function* (ctx, input) { + return yield ctx.callActivity("sayHello", input); + }); + ``` + +- **Classic entity** — a single-parameter function using `context.df.*`; core-native zero-argument + `EntityFactory` handlers pass through unchanged: + + ```typescript + df.app.entity("Counter", (context) => { + const current = context.df.getState(() => 0); + switch (context.df.operationName) { + case "add": context.df.setState(current + context.df.getInput()); break; + case "get": context.df.setResult(current); break; + } + }); + ``` + +`RetryOptions` and the deprecated `DurableOrchestrationClient` alias are also provided for source +compatibility. + +## Phase 2 status + +Implemented in this phase: + +- `src/app.ts`: `app.orchestration` / `app.activity` / `app.entity` registration over a shared worker. +- `src/trigger.ts` / `src/input.ts`: durable triggers and the `durableClient` input binding, all emitting `durableRequiresGrpc: true`. +- `src/get-client.ts`: `getClient(context)` constructs a `DurableFunctionsClient` from the host binding. +- `src/orchestration-context.ts` / `src/entity-context.ts`: classic (v3) `context.df.*` adapters plus `wrapOrchestrator` / `wrapEntity`. +- `src/retry-options.ts`: classic `RetryOptions` mapping to the core `RetryPolicy`. +- `src/orchestration-status.ts` / `src/entity-state-response.ts` / `src/purge-history-result.ts`: classic (v3) client query-return types (`DurableOrchestrationStatus`, `OrchestrationRuntimeStatus`, `EntityStateResponse`, `PurgeHistoryResult`), surfaced through `DurableFunctionsClient.getStatus` / `readEntityState` / `purgeInstanceHistory`. `context.df.callHttp` is present but throws (no durabletask durable-HTTP equivalent). + +Deferred: + +- **Functions data converter.** The core SDK serializes at the protobuf string boundary with plain `JSON.stringify` / `JSON.parse` and exposes no pluggable converter injection point, so the Python provider's `FunctionsDataConverter` (custom-object `{__class__,__module__,__data__}` envelope) cannot be ported here without a core change. This package keeps the plain-JSON contract for now. Open questions for the Functions extension team: diff --git a/packages/azure-functions-durable/src/app.ts b/packages/azure-functions-durable/src/app.ts index a302566..c8e0040 100644 --- a/packages/azure-functions-durable/src/app.ts +++ b/packages/azure-functions-durable/src/app.ts @@ -10,6 +10,8 @@ import { } from "@azure/functions"; import { EntityFactory, TOrchestrator } from "@microsoft/durabletask-js"; import * as trigger from "./trigger"; +import { ClassicEntity, wrapEntity } from "./entity-context"; +import { ClassicOrchestrator, wrapOrchestrator } from "./orchestration-context"; import { DurableFunctionsWorker } from "./worker"; // A single worker owns the orchestrator/entity registry for the whole function app. It is never @@ -33,10 +35,12 @@ interface ExtraRegistrationOptions { extraOutputs?: FunctionOutput[]; } -export type OrchestrationHandler = TOrchestrator; +// Handlers accept both the core-native form and the classic Durable Functions (v3) form; the +// wrappers below detect which was provided and adapt the classic form onto the core engine. +export type OrchestrationHandler = TOrchestrator | ClassicOrchestrator; export type OrchestrationOptions = ExtraRegistrationOptions & { handler: OrchestrationHandler }; -export type EntityHandler = EntityFactory; +export type EntityHandler = EntityFactory | ClassicEntity; export type EntityOptions = ExtraRegistrationOptions & { handler: EntityHandler }; export type ActivityHandler = FunctionHandler; @@ -52,7 +56,7 @@ export function orchestration( handlerOrOptions: OrchestrationHandler | OrchestrationOptions, ): void { const options = normalizeOptions(handlerOrOptions); - sharedWorker.addNamedOrchestrator(functionName, options.handler); + sharedWorker.addNamedOrchestrator(functionName, wrapOrchestrator(options.handler)); azFuncApp.generic(functionName, { ...extraBindings(options), trigger: trigger.orchestration(), @@ -67,7 +71,7 @@ export function orchestration( */ export function entity(functionName: string, handlerOrOptions: EntityHandler | EntityOptions): void { const options = normalizeOptions(handlerOrOptions); - sharedWorker.addNamedEntity(functionName, options.handler); + sharedWorker.addNamedEntity(functionName, wrapEntity(options.handler)); azFuncApp.generic(functionName, { ...extraBindings(options), trigger: trigger.entity(), diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index 1ae7120..e6d57e6 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -2,12 +2,25 @@ // Licensed under the MIT License. import { HttpRequest, HttpResponse } from "@azure/functions"; -import { TaskHubGrpcClient } from "@microsoft/durabletask-js"; +import { + EntityInstanceId, + OrchestrationQuery, + OrchestrationStatus, + TaskHubGrpcClient, +} from "@microsoft/durabletask-js"; import { HttpManagementPayload, createHttpManagementPayload as createPayload, } from "./http-management-payload"; +import { EntityStateResponse } from "./entity-state-response"; import { createAzureFunctionsMetadataGenerator } from "./metadata"; +import { + DurableOrchestrationStatus, + OrchestrationRuntimeStatus, + fromOrchestrationRuntimeStatus, + toDurableOrchestrationStatus, +} from "./orchestration-status"; +import { PurgeHistoryResult } from "./purge-history-result"; export interface DurableFunctionsClientConfig { taskHubName?: string; @@ -41,11 +54,21 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { const taskHubName = config.taskHubName ?? ""; const requiredQueryStringParameters = config.requiredQueryStringParameters ?? ""; const rpcBaseUrl = requireString(config.rpcBaseUrl, "rpcBaseUrl"); + const maxGrpcMessageSizeInBytes = config.maxGrpcMessageSizeInBytes ?? 0; super({ hostAddress: getGrpcHostAddress(rpcBaseUrl), useTLS: false, metadataGenerator: createAzureFunctionsMetadataGenerator(taskHubName), + // Honor the host-provided gRPC message size limit, matching the Python provider. When unset + // (0), the gRPC library defaults are left in place. + options: + maxGrpcMessageSizeInBytes > 0 + ? { + "grpc.max_receive_message_length": maxGrpcMessageSizeInBytes, + "grpc.max_send_message_length": maxGrpcMessageSizeInBytes, + } + : undefined, }); this.taskHubName = taskHubName; @@ -56,7 +79,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { this.requiredQueryStringParameters = requiredQueryStringParameters; this.rpcBaseUrl = rpcBaseUrl; this.httpBaseUrl = config.httpBaseUrl ?? ""; - this.maxGrpcMessageSizeInBytes = config.maxGrpcMessageSizeInBytes ?? 0; + this.maxGrpcMessageSizeInBytes = maxGrpcMessageSizeInBytes; this.grpcHttpClientTimeout = config.grpcHttpClientTimeout; } @@ -77,8 +100,156 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { const instanceStatusUrl = getInstanceStatusUrl(request, instanceId); return createPayload(instanceId, instanceStatusUrl, this.requiredQueryStringParameters); } + + /** + * Starts a new orchestration instance (classic Durable Functions v3 `startNew` alias). + * + * @param orchestratorName - The name of the orchestrator to start. + * @param options - Optional input and instance ID. + * @returns The instance ID of the started orchestration. + */ + async startNew( + orchestratorName: string, + options?: { input?: unknown; instanceId?: string }, + ): Promise { + return this.scheduleNewOrchestration( + orchestratorName, + options?.input, + options?.instanceId !== undefined ? { instanceId: options.instanceId } : undefined, + ); + } + + /** + * Gets the status of an orchestration instance in the classic Durable Functions (v3) shape. + * + * @param instanceId - The ID of the orchestration instance to query. + * @param options - When `showInput` is `false`, input/output payloads are not fetched. + * @returns The instance status, or `undefined` if the instance does not exist. + */ + async getStatus( + instanceId: string, + options?: { showInput?: boolean }, + ): Promise { + const state = await this.getOrchestrationState(instanceId, options?.showInput ?? true); + return state ? toDurableOrchestrationStatus(state) : undefined; + } + + /** + * Gets the status of all orchestration instances (classic Durable Functions v3 shape). + */ + async getStatusAll(): Promise { + return this.collectStatuses({ fetchInputsAndOutputs: true }); + } + + /** + * Gets the status of orchestration instances matching a filter (classic Durable Functions v3 shape). + * + * @param filter - Creation-time window and/or runtime-status filter. + */ + async getStatusBy(filter: { + createdTimeFrom?: Date; + createdTimeTo?: Date; + runtimeStatus?: OrchestrationRuntimeStatus[]; + }): Promise { + return this.collectStatuses({ + createdFrom: filter.createdTimeFrom, + createdTo: filter.createdTimeTo, + statuses: filter.runtimeStatus?.map(fromOrchestrationRuntimeStatus), + fetchInputsAndOutputs: true, + }); + } + + /** + * Waits up to a timeout for an orchestration to complete; if it does, returns an HTTP response with + * its output/status, otherwise returns the same 202 check-status response as + * {@link createCheckStatusResponse}. Classic Durable Functions v3 behavior. + * + * @param request - The incoming HTTP request (used to build management URLs on timeout). + * @param instanceId - The orchestration instance to wait for. + * @param waitOptions - Optional total wait timeout in milliseconds (default 10s). + */ + async waitForCompletionOrCreateCheckStatusResponse( + request: HttpRequest, + instanceId: string, + waitOptions?: { timeoutInMilliseconds?: number }, + ): Promise { + const timeoutSeconds = Math.max( + 1, + Math.ceil((waitOptions?.timeoutInMilliseconds ?? 10000) / 1000), + ); + try { + const state = await this.waitForOrchestrationCompletion(instanceId, true, timeoutSeconds); + if (state) { + const headers = { "content-type": "application/json" }; + if (state.runtimeStatus === OrchestrationStatus.COMPLETED) { + return new HttpResponse({ status: 200, body: state.serializedOutput ?? "null", headers }); + } + if (state.runtimeStatus === OrchestrationStatus.FAILED) { + return new HttpResponse({ + status: 500, + body: JSON.stringify(toDurableOrchestrationStatus(state)), + headers, + }); + } + if (state.runtimeStatus === OrchestrationStatus.TERMINATED) { + return new HttpResponse({ + status: 200, + body: JSON.stringify(toDurableOrchestrationStatus(state)), + headers, + }); + } + } + } catch { + // Timed out (or not yet terminal) waiting for completion: fall through to the check-status + // response so the caller can poll the management endpoints. + } + return this.createCheckStatusResponse(request, instanceId); + } + + /** + * Reads the state of a durable entity in the classic Durable Functions (v3) shape. + * + * @param entityId - The target entity instance ID. + * @param includeState - Whether to include the entity state in the response (default `true`). + */ + async readEntityState( + entityId: EntityInstanceId, + includeState = true, + ): Promise> { + const metadata = await this.getEntity(entityId, includeState); + if (!metadata) { + return new EntityStateResponse(false); + } + return new EntityStateResponse(true, metadata.state); + } + + /** + * Purges the history of a single orchestration instance, returning the classic Durable Functions + * (v3) {@link PurgeHistoryResult}. + * + * @param instanceId - The ID of the orchestration instance to purge. + */ + async purgeInstanceHistory(instanceId: string): Promise { + const result = await this.purgeOrchestration(instanceId); + return new PurgeHistoryResult(result?.deletedInstanceCount ?? 0); + } + + /** @hidden Iterates the core paged query and maps each instance to the v3 status shape. */ + private async collectStatuses(query: OrchestrationQuery): Promise { + const results: DurableOrchestrationStatus[] = []; + for await (const state of this.getAllInstances(query)) { + results.push(toDurableOrchestrationStatus(state)); + } + return results; + } } +/** + * @deprecated Use {@link DurableFunctionsClient} instead. Retained as a classic Durable Functions + * (v3) alias so existing code that imports `DurableOrchestrationClient` keeps working. + */ +export class DurableOrchestrationClient extends DurableFunctionsClient {} + export function getGrpcHostAddress(rpcBaseUrl: string): string { try { const hostAddress = new URL(rpcBaseUrl).host; diff --git a/packages/azure-functions-durable/src/entity-context.ts b/packages/azure-functions-durable/src/entity-context.ts new file mode 100644 index 0000000..af8370d --- /dev/null +++ b/packages/azure-functions-durable/src/entity-context.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { EntityFactory, ITaskEntity, TaskEntityOperation } from "@microsoft/durabletask-js"; + +/** + * Classic Durable Functions (v3) entity context, exposed to migrating entity functions as + * `context.df`. + * + * @remarks + * This is an adapter over the core durabletask {@link TaskEntityOperation}. It lets entity bodies + * written against the legacy `durable-functions` `context.df.*` API run unchanged on the + * gRPC/durabletask engine. + */ +export class DurableEntityContext { + private _result: unknown; + private _resultSet = false; + + constructor(private readonly _operation: TaskEntityOperation) {} + + /** The name of the current operation. */ + get operationName(): string { + return this._operation.name; + } + + /** The entity type name (lowercased). */ + get entityName(): string { + return this._operation.context.id.name; + } + + /** The entity instance key (case-preserved). */ + get entityKey(): string { + return this._operation.context.id.key; + } + + /** The entity instance ID in `@name@key` form. */ + get instanceId(): string { + return this._operation.context.id.toString(); + } + + /** Gets the input for the current operation. */ + getInput(): T | undefined { + return this._operation.getInput(); + } + + /** + * Gets the current entity state. + * + * @param initializer - Optional zero-argument callable providing the initial state when none exists. + */ + getState(initializer?: () => T): T | undefined { + const defaultValue = typeof initializer === "function" ? initializer() : undefined; + return this._operation.state.getState(defaultValue); + } + + /** Sets the entity state. Passing `null`/`undefined` deletes the entity. */ + setState(state: unknown): void { + this._operation.state.setState(state); + } + + /** Sets the result (return value) of the current operation. */ + return(result: unknown): void { + this._result = result; + this._resultSet = true; + } + + /** Deletes this entity after the operation completes. */ + destructOnExit(): void { + this._operation.state.setState(undefined); + } + + /** @hidden Returns the explicitly-set result, or the provided fallback when none was set. */ + resolveResult(fallback: unknown): unknown { + return this._resultSet ? this._result : fallback; + } +} + +/** The object passed to a classic (v3) entity function; its `df` is the durable entity context. */ +export interface ClassicEntityContext { + df: DurableEntityContext; +} + +/** + * A classic Durable Functions (v3) entity: a single-argument function that reads and mutates state + * through `context.df.*`. + */ +export type ClassicEntity = (context: ClassicEntityContext) => unknown | Promise; + +/** + * Adapts an entity handler for registration on the core worker. + * + * @remarks + * Core-native entities are zero-argument factories (`() => ITaskEntity`) and are returned + * unchanged. Classic v3 entities declare a single `context` parameter and use `context.df.*`; those + * are wrapped in a factory whose operation dispatch forwards to the core operation. + */ +export function wrapEntity(handler: EntityFactory | ClassicEntity): EntityFactory { + if (typeof handler === "function" && handler.length >= 1) { + const classic = handler as ClassicEntity; + return () => new ClassicEntityAdapter(classic); + } + return handler as EntityFactory; +} + +/** @hidden Bridges a classic v3 entity function to the core {@link ITaskEntity} contract. */ +class ClassicEntityAdapter implements ITaskEntity { + constructor(private readonly _fn: ClassicEntity) {} + + async run(operation: TaskEntityOperation): Promise { + const df = new DurableEntityContext(operation); + const returned = await Promise.resolve(this._fn({ df })); + return df.resolveResult(returned); + } +} diff --git a/packages/azure-functions-durable/src/entity-state-response.ts b/packages/azure-functions-durable/src/entity-state-response.ts new file mode 100644 index 0000000..399ffef --- /dev/null +++ b/packages/azure-functions-durable/src/entity-state-response.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * The response returned by {@link DurableFunctionsClient.readEntityState}, matching the classic + * Durable Functions v3 `EntityStateResponse` shape. + * + * @typeParam T - The entity state type. + */ +export class EntityStateResponse { + /** + * @param entityExists - Whether the entity exists (has state). + * @param entityState - The entity state, present only when {@link entityExists} is true. + */ + constructor( + public readonly entityExists: boolean, + public readonly entityState?: T, + ) {} +} diff --git a/packages/azure-functions-durable/src/index.ts b/packages/azure-functions-durable/src/index.ts index 5e9178e..fc1c615 100644 --- a/packages/azure-functions-durable/src/index.ts +++ b/packages/azure-functions-durable/src/index.ts @@ -9,6 +9,7 @@ export { DurableFunctionsClient, DurableFunctionsClientConfig, DurableFunctionsClientInput, + DurableOrchestrationClient, getGrpcHostAddress, } from "./client"; export { getClient, isDurableClientInput } from "./get-client"; @@ -16,3 +17,26 @@ export { HttpManagementPayload } from "./http-management-payload"; export { createAzureFunctionsMetadataGenerator } from "./metadata"; export { DurableFunctionsWorker } from "./worker"; export { DurableBindingMetadata, addDurableGrpcMetadata } from "./durable-grpc"; +export { RetryOptions } from "./retry-options"; +export { + DurableOrchestrationContext, + ClassicOrchestrationContext, + ClassicOrchestrator, + wrapOrchestrator, +} from "./orchestration-context"; +export { + DurableEntityContext, + ClassicEntityContext, + ClassicEntity, + wrapEntity, +} from "./entity-context"; +export { + DurableOrchestrationStatus, + DurableOrchestrationStatusInit, + OrchestrationRuntimeStatus, + fromOrchestrationRuntimeStatus, + toDurableOrchestrationStatus, + toOrchestrationRuntimeStatus, +} from "./orchestration-status"; +export { EntityStateResponse } from "./entity-state-response"; +export { PurgeHistoryResult } from "./purge-history-result"; diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts new file mode 100644 index 0000000..b58f00c --- /dev/null +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + EntityInstanceId, + OrchestrationContext, + Task, + TOrchestrator, + whenAll, + whenAny, +} from "@microsoft/durabletask-js"; +import { RetryOptions } from "./retry-options"; + +/** + * Classic Durable Functions (v3) orchestration context, exposed to migrating orchestrators as + * `context.df`. + * + * @remarks + * This is an adapter, not a re-implementation: every method forwards to the core durabletask + * {@link OrchestrationContext}. It lets orchestrator bodies written against the legacy + * `durable-functions` `context.df.*` API run unchanged on the gRPC/durabletask engine. + */ +export class DurableOrchestrationContext { + constructor( + private readonly _ctx: OrchestrationContext, + private readonly _input: unknown, + ) {} + + /** The instance ID of the current orchestration. */ + get instanceId(): string { + return this._ctx.instanceId; + } + + /** The ID of the parent orchestration, or `undefined` if this is a top-level orchestration. */ + get parentInstanceId(): string | undefined { + return this._ctx.parent?.instanceId; + } + + /** Whether the orchestrator is currently replaying from history. */ + get isReplaying(): boolean { + return this._ctx.isReplaying; + } + + /** The replay-safe current UTC date/time. */ + get currentUtcDateTime(): Date { + return this._ctx.currentUtcDateTime; + } + + /** The version assigned to the current orchestration instance (empty string if none). */ + get version(): string { + return this._ctx.version; + } + + /** + * `Task.all` / `Task.any` fan-out/fan-in helpers (v3 shape), forwarding to the core + * `whenAll` / `whenAny` combinators. + */ + readonly Task = { + all: (tasks: Task[]): Task => whenAll(tasks), + any: (tasks: Task[]): Task> => whenAny(tasks) as unknown as Task>, + }; + + /** Gets the orchestration input. */ + getInput(): T { + return this._input as T; + } + + /** Schedules an activity for execution. */ + callActivity(name: string, input?: unknown): Task { + return this._ctx.callActivity(name, input); + } + + /** Schedules an activity for execution with a retry policy. */ + callActivityWithRetry( + name: string, + retryOptions: RetryOptions, + input?: unknown, + ): Task { + return this._ctx.callActivity(name, input, { + retry: retryOptions.toRetryPolicy(), + }); + } + + /** Schedules a sub-orchestrator for execution. */ + callSubOrchestrator( + name: string, + input?: unknown, + instanceId?: string, + version?: string, + ): Task { + return this._ctx.callSubOrchestrator( + name, + input, + instanceId !== undefined || version !== undefined ? { instanceId, version } : undefined, + ); + } + + /** Schedules a sub-orchestrator for execution with a retry policy. */ + callSubOrchestratorWithRetry( + name: string, + retryOptions: RetryOptions, + input?: unknown, + instanceId?: string, + version?: string, + ): Task { + return this._ctx.callSubOrchestrator(name, input, { + retry: retryOptions.toRetryPolicy(), + instanceId, + version, + }); + } + + /** Creates a durable timer that fires at the specified time. */ + createTimer(fireAt: Date | number): Task { + return this._ctx.createTimer(fireAt); + } + + /** Waits for an external event with the given name. */ + waitForExternalEvent(name: string): Task { + return this._ctx.waitForExternalEvent(name) as Task; + } + + /** Restarts the orchestration with a new input. */ + continueAsNew(input: unknown, saveEvents = true): void { + this._ctx.continueAsNew(input, saveEvents); + } + + /** Sets the orchestration's custom status payload. */ + setCustomStatus(customStatus: unknown): void { + this._ctx.setCustomStatus(customStatus); + } + + /** + * Creates a new replay-safe UUID string. + * + * @param _instanceId - Accepted for classic v3 signature compatibility; ignored. The core engine + * derives the deterministic UUID from the instance ID and an internal sequence counter. + */ + newGuid(_instanceId?: string): string { + return this._ctx.newGuid(); + } + + /** + * Schedules a durable HTTP call (classic v3 API). + * + * @remarks + * Not supported: the durabletask engine has no durable-HTTP (`callHttp`) equivalent, so this + * throws. This mirrors the Python provider, which raises for the same reason. + */ + callHttp(_options: unknown): never { + throw new Error( + "callHttp is not supported: the durabletask engine has no durable-HTTP (callHttp) equivalent.", + ); + } + + /** Calls an entity operation and waits for its result. */ + callEntity( + entityId: EntityInstanceId, + operationName: string, + operationInput?: unknown, + ): Task { + return this._ctx.entities.callEntity(entityId, operationName, operationInput); + } + + /** Signals an entity operation (fire-and-forget). */ + signalEntity( + entityId: EntityInstanceId, + operationName: string, + operationInput?: unknown, + ): void { + this._ctx.entities.signalEntity(entityId, operationName, operationInput); + } +} + +/** The object passed to a classic (v3) orchestrator function; its `df` is the durable context. */ +export interface ClassicOrchestrationContext { + df: DurableOrchestrationContext; +} + +/** + * A classic Durable Functions (v3) orchestrator: a single-argument generator function that reads + * and schedules work through `context.df.*`. + */ +export type ClassicOrchestrator = ( + context: ClassicOrchestrationContext, +) => Generator, unknown, unknown> | unknown; + +/** + * Adapts an orchestrator handler for registration on the core worker. + * + * @remarks + * Core-native orchestrators declare `(ctx, input)` and are returned unchanged. Classic v3 + * orchestrators declare a single `context` parameter and use `context.df.*`; those are wrapped so + * the core engine drives them while `context.df` forwards to the core {@link OrchestrationContext}. + * + * Detection is by arity: only single-parameter functions are treated as classic. Declare your + * core-native orchestrator as `(ctx, input)` so it passes through. + */ +export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): TOrchestrator { + if (typeof handler === "function" && handler.length === 1) { + const classic = handler as ClassicOrchestrator; + const wrapped = function* ( + ctx: OrchestrationContext, + input: unknown, + ): Generator, unknown, unknown> { + const df = new DurableOrchestrationContext(ctx, input); + const result = classic({ df }); + if (isGenerator(result)) { + return yield* result; + } + return result; + }; + return wrapped as TOrchestrator; + } + return handler as TOrchestrator; +} + +/** @hidden */ +function isGenerator(value: unknown): value is Generator, unknown, unknown> { + return ( + value != null && + typeof (value as Iterator).next === "function" && + typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === "function" + ); +} diff --git a/packages/azure-functions-durable/src/orchestration-status.ts b/packages/azure-functions-durable/src/orchestration-status.ts new file mode 100644 index 0000000..afd7678 --- /dev/null +++ b/packages/azure-functions-durable/src/orchestration-status.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { OrchestrationState, OrchestrationStatus } from "@microsoft/durabletask-js"; + +/** + * The runtime status of an orchestration instance (classic Durable Functions v3 shape). + */ +export enum OrchestrationRuntimeStatus { + Running = "Running", + Completed = "Completed", + ContinuedAsNew = "ContinuedAsNew", + Failed = "Failed", + Canceled = "Canceled", + Terminated = "Terminated", + Pending = "Pending", + Suspended = "Suspended", +} + +/** Fields used to construct a {@link DurableOrchestrationStatus}. */ +export interface DurableOrchestrationStatusInit { + name: string; + instanceId: string; + createdTime: Date; + lastUpdatedTime: Date; + input: unknown; + output: unknown; + runtimeStatus: OrchestrationRuntimeStatus; + customStatus?: unknown; + history?: unknown[]; +} + +/** + * Represents the status of a durable orchestration instance, matching the classic Durable Functions + * v3 `DurableOrchestrationStatus` shape. Returned by {@link DurableFunctionsClient.getStatus}. + */ +export class DurableOrchestrationStatus { + readonly name: string; + readonly instanceId: string; + readonly createdTime: Date; + readonly lastUpdatedTime: Date; + readonly input: unknown; + readonly output: unknown; + readonly runtimeStatus: OrchestrationRuntimeStatus; + readonly customStatus?: unknown; + readonly history?: unknown[]; + + constructor(init: DurableOrchestrationStatusInit) { + this.name = init.name; + this.instanceId = init.instanceId; + this.createdTime = init.createdTime; + this.lastUpdatedTime = init.lastUpdatedTime; + this.input = init.input; + this.output = init.output; + this.runtimeStatus = init.runtimeStatus; + this.customStatus = init.customStatus; + this.history = init.history; + } +} + +/** Maps the core {@link OrchestrationStatus} enum to the classic v3 {@link OrchestrationRuntimeStatus}. */ +export function toOrchestrationRuntimeStatus(status: OrchestrationStatus): OrchestrationRuntimeStatus { + switch (status) { + case OrchestrationStatus.RUNNING: + return OrchestrationRuntimeStatus.Running; + case OrchestrationStatus.COMPLETED: + return OrchestrationRuntimeStatus.Completed; + case OrchestrationStatus.CONTINUED_AS_NEW: + return OrchestrationRuntimeStatus.ContinuedAsNew; + case OrchestrationStatus.FAILED: + return OrchestrationRuntimeStatus.Failed; + case OrchestrationStatus.TERMINATED: + return OrchestrationRuntimeStatus.Terminated; + case OrchestrationStatus.PENDING: + return OrchestrationRuntimeStatus.Pending; + case OrchestrationStatus.SUSPENDED: + return OrchestrationRuntimeStatus.Suspended; + default: + return OrchestrationRuntimeStatus.Pending; + } +} + +/** Maps a classic v3 {@link OrchestrationRuntimeStatus} back to the core {@link OrchestrationStatus}. */ +export function fromOrchestrationRuntimeStatus(status: OrchestrationRuntimeStatus): OrchestrationStatus { + switch (status) { + case OrchestrationRuntimeStatus.Running: + return OrchestrationStatus.RUNNING; + case OrchestrationRuntimeStatus.Completed: + return OrchestrationStatus.COMPLETED; + case OrchestrationRuntimeStatus.ContinuedAsNew: + return OrchestrationStatus.CONTINUED_AS_NEW; + case OrchestrationRuntimeStatus.Failed: + return OrchestrationStatus.FAILED; + case OrchestrationRuntimeStatus.Terminated: + case OrchestrationRuntimeStatus.Canceled: + return OrchestrationStatus.TERMINATED; + case OrchestrationRuntimeStatus.Suspended: + return OrchestrationStatus.SUSPENDED; + case OrchestrationRuntimeStatus.Pending: + default: + return OrchestrationStatus.PENDING; + } +} + +/** + * Maps a core {@link OrchestrationState} into the classic v3 {@link DurableOrchestrationStatus}. + * + * Payloads are deserialized with `JSON.parse`, matching the plain-JSON wire contract the core client + * and worker use. + */ +export function toDurableOrchestrationStatus(state: OrchestrationState): DurableOrchestrationStatus { + return new DurableOrchestrationStatus({ + name: state.name, + instanceId: state.instanceId, + createdTime: state.createdAt, + lastUpdatedTime: state.lastUpdatedAt, + input: parseJson(state.serializedInput), + output: parseJson(state.serializedOutput), + runtimeStatus: toOrchestrationRuntimeStatus(state.runtimeStatus), + customStatus: parseJson(state.serializedCustomStatus), + }); +} + +/** @hidden */ +function parseJson(value: string | undefined): unknown { + return value === undefined ? undefined : JSON.parse(value); +} diff --git a/packages/azure-functions-durable/src/purge-history-result.ts b/packages/azure-functions-durable/src/purge-history-result.ts new file mode 100644 index 0000000..987c68b --- /dev/null +++ b/packages/azure-functions-durable/src/purge-history-result.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Statistics about a purge-history operation, matching the classic Durable Functions v3 + * `PurgeHistoryResult` shape. Returned by {@link DurableFunctionsClient.purgeInstanceHistory}. + */ +export class PurgeHistoryResult { + /** + * @param instancesDeleted - The number of orchestration instances that were purged. + */ + constructor(public readonly instancesDeleted: number) {} +} diff --git a/packages/azure-functions-durable/src/retry-options.ts b/packages/azure-functions-durable/src/retry-options.ts new file mode 100644 index 0000000..d86b1d2 --- /dev/null +++ b/packages/azure-functions-durable/src/retry-options.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { RetryPolicy } from "@microsoft/durabletask-js"; + +/** + * Classic Durable Functions (v3) retry configuration for `callActivityWithRetry` / + * `callSubOrchestratorWithRetry`. + * + * @remarks + * This mirrors the legacy `durable-functions` `RetryOptions` shape so existing orchestrator code + * migrates without changes. It is a thin adapter: {@link toRetryPolicy} converts it to the core + * `RetryPolicy` that the durabletask engine actually understands. + */ +export class RetryOptions { + /** Exponential backoff coefficient applied between retries. Defaults to 1 (no backoff). */ + public backoffCoefficient = 1; + + /** Maximum delay between retries, in milliseconds. When unset, the core default (1 hour) applies. */ + public maxRetryIntervalInMilliseconds?: number; + + /** Overall retry timeout, in milliseconds. When unset, retries are not time-bounded. */ + public retryTimeoutInMilliseconds?: number; + + /** + * @param firstRetryIntervalInMilliseconds - Delay before the first retry, in milliseconds. + * @param maxNumberOfAttempts - Maximum number of attempts (including the first call). + */ + constructor( + public readonly firstRetryIntervalInMilliseconds: number, + public readonly maxNumberOfAttempts: number, + ) {} + + /** + * Converts this classic `RetryOptions` into the core {@link RetryPolicy}. + */ + toRetryPolicy(): RetryPolicy { + return new RetryPolicy({ + firstRetryIntervalInMilliseconds: this.firstRetryIntervalInMilliseconds, + maxNumberOfAttempts: this.maxNumberOfAttempts, + backoffCoefficient: this.backoffCoefficient, + maxRetryIntervalInMilliseconds: this.maxRetryIntervalInMilliseconds, + retryTimeoutInMilliseconds: this.retryTimeoutInMilliseconds, + }); + } +} diff --git a/packages/azure-functions-durable/test/unit/client-query.spec.ts b/packages/azure-functions-durable/test/unit/client-query.spec.ts new file mode 100644 index 0000000..b51a977 --- /dev/null +++ b/packages/azure-functions-durable/test/unit/client-query.spec.ts @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { EntityInstanceId, OrchestrationState, OrchestrationStatus } from "@microsoft/durabletask-js"; +import { HttpRequest } from "@azure/functions"; +import { DurableFunctionsClient } from "../../src/client"; +import { DurableOrchestrationStatus, OrchestrationRuntimeStatus } from "../../src/orchestration-status"; +import { EntityStateResponse } from "../../src/entity-state-response"; +import { PurgeHistoryResult } from "../../src/purge-history-result"; + +const CLIENT_CONFIG = { + taskHubName: "functions-taskhub", + rpcBaseUrl: "http://127.0.0.1:4711/rpc", + requiredQueryStringParameters: "code=secret", +}; + +function stateOf(instanceId: string, status = OrchestrationStatus.RUNNING): OrchestrationState { + return new OrchestrationState( + instanceId, + "orch", + status, + new Date("2026-01-01T00:00:00.000Z"), + new Date("2026-01-01T00:00:00.000Z"), + ); +} + +async function* asyncGen(items: T[]): AsyncGenerator { + for (const item of items) { + yield item; + } +} + +describe("DurableFunctionsClient query methods", () => { + it("getStatus maps the core state to a DurableOrchestrationStatus", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + try { + const state = new OrchestrationState( + "abc", + "helloOrchestrator", + OrchestrationStatus.RUNNING, + new Date("2026-01-01T00:00:00.000Z"), + new Date("2026-01-01T00:00:00.000Z"), + JSON.stringify({ n: 1 }), + ); + const spy = jest.spyOn(client, "getOrchestrationState").mockResolvedValue(state); + + const status = await client.getStatus("abc"); + + expect(spy).toHaveBeenCalledWith("abc", true); + expect(status).toBeInstanceOf(DurableOrchestrationStatus); + expect(status?.instanceId).toBe("abc"); + expect(status?.input).toEqual({ n: 1 }); + expect(status?.runtimeStatus).toBe(OrchestrationRuntimeStatus.Running); + } finally { + await client.stop(); + } + }); + + it("getStatus returns undefined when the instance does not exist", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + try { + jest.spyOn(client, "getOrchestrationState").mockResolvedValue(undefined); + await expect(client.getStatus("missing")).resolves.toBeUndefined(); + } finally { + await client.stop(); + } + }); + + it("readEntityState maps present and absent entities", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + const entityId = new EntityInstanceId("Counter", "k1"); + try { + const getEntity = jest.spyOn(client, "getEntity"); + + getEntity.mockResolvedValueOnce({ + id: entityId, + lastModifiedTime: new Date(), + backlogQueueSize: 0, + includesState: true, + state: 42, + }); + const present = await client.readEntityState(entityId); + expect(present).toBeInstanceOf(EntityStateResponse); + expect(present.entityExists).toBe(true); + expect(present.entityState).toBe(42); + + getEntity.mockResolvedValueOnce(undefined); + const absent = await client.readEntityState(entityId); + expect(absent.entityExists).toBe(false); + expect(absent.entityState).toBeUndefined(); + } finally { + await client.stop(); + } + }); + + it("purgeInstanceHistory maps the core purge result", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + try { + jest + .spyOn(client, "purgeOrchestration") + .mockResolvedValue({ deletedInstanceCount: 1 } as never); + + const result = await client.purgeInstanceHistory("abc"); + expect(result).toBeInstanceOf(PurgeHistoryResult); + expect(result.instancesDeleted).toBe(1); + } finally { + await client.stop(); + } + }); + + it("startNew forwards to scheduleNewOrchestration with input and instanceId", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + try { + const schedule = jest.spyOn(client, "scheduleNewOrchestration").mockResolvedValue("new-id"); + + await client.startNew("hello", { input: { city: "Tokyo" }, instanceId: "id-1" }); + expect(schedule).toHaveBeenCalledWith("hello", { city: "Tokyo" }, { instanceId: "id-1" }); + + await client.startNew("hello"); + expect(schedule).toHaveBeenLastCalledWith("hello", undefined, undefined); + } finally { + await client.stop(); + } + }); + + it("getStatusAll maps every instance from the paged query", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + try { + const getAll = jest + .spyOn(client, "getAllInstances") + .mockReturnValue(asyncGen([stateOf("a"), stateOf("b")]) as never); + + const all = await client.getStatusAll(); + expect(getAll).toHaveBeenCalledWith({ fetchInputsAndOutputs: true }); + expect(all.map((s) => s.instanceId)).toEqual(["a", "b"]); + expect(all[0]).toBeInstanceOf(DurableOrchestrationStatus); + } finally { + await client.stop(); + } + }); + + it("getStatusBy maps the v3 filter to a core query", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + try { + const getAll = jest + .spyOn(client, "getAllInstances") + .mockReturnValue(asyncGen([stateOf("a", OrchestrationStatus.COMPLETED)]) as never); + + const from = new Date("2026-01-01T00:00:00.000Z"); + const to = new Date("2026-01-02T00:00:00.000Z"); + const result = await client.getStatusBy({ + createdTimeFrom: from, + createdTimeTo: to, + runtimeStatus: [OrchestrationRuntimeStatus.Completed], + }); + + expect(getAll).toHaveBeenCalledWith({ + createdFrom: from, + createdTo: to, + statuses: [OrchestrationStatus.COMPLETED], + fetchInputsAndOutputs: true, + }); + expect(result[0].runtimeStatus).toBe(OrchestrationRuntimeStatus.Completed); + } finally { + await client.stop(); + } + }); + + it("waitForCompletionOrCreateCheckStatusResponse returns 200 with output when completed", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + const request = new HttpRequest({ method: "GET", url: "http://localhost:7071/api/x" }); + try { + const completed = new OrchestrationState( + "abc", + "orch", + OrchestrationStatus.COMPLETED, + new Date(), + new Date(), + undefined, + JSON.stringify("the-output"), + ); + jest.spyOn(client, "waitForOrchestrationCompletion").mockResolvedValue(completed); + + const response = await client.waitForCompletionOrCreateCheckStatusResponse(request, "abc"); + expect(response.status).toBe(200); + expect(await response.text()).toBe(JSON.stringify("the-output")); + } finally { + await client.stop(); + } + }); + + it("waitForCompletionOrCreateCheckStatusResponse falls back to 202 on timeout", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + const request = new HttpRequest({ method: "GET", url: "http://localhost:7071/api/x" }); + try { + jest + .spyOn(client, "waitForOrchestrationCompletion") + .mockRejectedValue(new Error("Timed out")); + + const response = await client.waitForCompletionOrCreateCheckStatusResponse(request, "abc", { + timeoutInMilliseconds: 1000, + }); + expect(response.status).toBe(202); + } finally { + await client.stop(); + } + }); +}); diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index d595bc7..8fc0748 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -34,9 +34,17 @@ describe("DurableFunctionsClient", () => { expect(typeof client.signalEntity).toBe("function"); expect(typeof client.getEntity).toBe("function"); expect(Object.getOwnPropertyNames(DurableFunctionsClient.prototype).sort()).toEqual([ + "collectStatuses", "constructor", "createCheckStatusResponse", "createHttpManagementPayload", + "getStatus", + "getStatusAll", + "getStatusBy", + "purgeInstanceHistory", + "readEntityState", + "startNew", + "waitForCompletionOrCreateCheckStatusResponse", ]); } finally { await client.stop(); diff --git a/packages/azure-functions-durable/test/unit/entity-context.spec.ts b/packages/azure-functions-durable/test/unit/entity-context.spec.ts new file mode 100644 index 0000000..9c34726 --- /dev/null +++ b/packages/azure-functions-durable/test/unit/entity-context.spec.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { EntityFactory, EntityInstanceId, TaskEntityOperation } from "@microsoft/durabletask-js"; +import { + ClassicEntityContext, + DurableEntityContext, + wrapEntity, +} from "../../src/entity-context"; + +/** Builds a fake core TaskEntityOperation backed by an in-memory state cell. */ +function createFakeOperation(options: { + name: string; + input?: unknown; + initialState?: unknown; +}): { operation: TaskEntityOperation; getStoredState: () => unknown } { + let stored: unknown = options.initialState; + let hasState = options.initialState !== undefined; + + const operation = { + name: options.name, + context: { + id: new EntityInstanceId("Counter", "user-1"), + signalEntity: jest.fn(), + scheduleNewOrchestration: jest.fn(), + }, + state: { + get hasState() { + return hasState; + }, + getState(defaultValue?: T): T | undefined { + return (hasState ? (stored as T) : defaultValue) as T | undefined; + }, + setState(state: unknown): void { + stored = state; + hasState = state !== undefined && state !== null; + }, + }, + hasInput: options.input !== undefined, + getInput(): T | undefined { + return options.input as T | undefined; + }, + }; + + return { operation: operation as unknown as TaskEntityOperation, getStoredState: () => stored }; +} + +describe("DurableEntityContext", () => { + it("exposes identity, operation name, input, and state accessors", () => { + const { operation, getStoredState } = createFakeOperation({ + name: "add", + input: 5, + initialState: 10, + }); + const df = new DurableEntityContext(operation); + + expect(df.operationName).toBe("add"); + expect(df.entityName).toBe("counter"); + expect(df.entityKey).toBe("user-1"); + expect(df.instanceId).toBe("@counter@user-1"); + expect(df.getInput()).toBe(5); + expect(df.getState()).toBe(10); + + df.setState(15); + expect(getStoredState()).toBe(15); + }); + + it("returns the initializer default when there is no state", () => { + const { operation } = createFakeOperation({ name: "get" }); + const df = new DurableEntityContext(operation); + + expect(df.getState(() => 0)).toBe(0); + }); + + it("destructOnExit clears the state", () => { + const { operation, getStoredState } = createFakeOperation({ name: "delete", initialState: 42 }); + const df = new DurableEntityContext(operation); + + df.destructOnExit(); + expect(getStoredState()).toBeUndefined(); + }); + + it("resolveResult prefers an explicitly set result over the fallback", () => { + const { operation } = createFakeOperation({ name: "op" }); + const df = new DurableEntityContext(operation); + + expect(df.resolveResult("fallback")).toBe("fallback"); + df.return("explicit"); + expect(df.resolveResult("fallback")).toBe("explicit"); + }); +}); + +describe("wrapEntity", () => { + it("returns a zero-argument core factory unchanged", () => { + const factory: EntityFactory = () => ({ run: jest.fn() }); + expect(wrapEntity(factory)).toBe(factory); + }); + + it("wraps a classic entity function and dispatches operations through context.df", async () => { + const classic = (context: ClassicEntityContext) => { + const current = context.df.getState(() => 0) ?? 0; + const next = current + (context.df.getInput() ?? 0); + context.df.setState(next); + return next; + }; + + const factory = wrapEntity(classic); + expect(factory).not.toBe(classic); + + const { operation, getStoredState } = createFakeOperation({ + name: "add", + input: 7, + initialState: 3, + }); + const result = await factory().run(operation); + + expect(result).toBe(10); + expect(getStoredState()).toBe(10); + }); + + it("uses an explicitly set result over the function return value", async () => { + const classic = (context: ClassicEntityContext) => { + context.df.return("explicit-result"); + return "ignored-return"; + }; + + const factory = wrapEntity(classic); + const { operation } = createFakeOperation({ name: "op" }); + + await expect(factory().run(operation)).resolves.toBe("explicit-result"); + }); +}); diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts new file mode 100644 index 0000000..0c6222b --- /dev/null +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as durabletask from "@microsoft/durabletask-js"; +import { + EntityInstanceId, + OrchestrationContext, + RetryPolicy, + Task, + TOrchestrator, +} from "@microsoft/durabletask-js"; +import { + ClassicOrchestrationContext, + DurableOrchestrationContext, + wrapOrchestrator, +} from "../../src/orchestration-context"; +import { RetryOptions } from "../../src/retry-options"; + +/** Builds a fake core OrchestrationContext whose methods return sentinel values via jest mocks. */ +function createFakeCoreContext() { + const entities = { + callEntity: jest.fn().mockReturnValue("callEntity-task"), + signalEntity: jest.fn(), + }; + const ctx = { + instanceId: "instance-1", + isReplaying: true, + currentUtcDateTime: new Date("2026-01-02T03:04:05.000Z"), + version: "1.2.3", + callActivity: jest.fn().mockReturnValue("callActivity-task"), + callSubOrchestrator: jest.fn().mockReturnValue("callSub-task"), + createTimer: jest.fn().mockReturnValue("timer-task"), + waitForExternalEvent: jest.fn().mockReturnValue("event-task"), + continueAsNew: jest.fn(), + setCustomStatus: jest.fn(), + sendEvent: jest.fn(), + newGuid: jest.fn().mockReturnValue("guid-1"), + entities, + }; + return { ctx: ctx as unknown as OrchestrationContext, raw: ctx, entities }; +} + +describe("DurableOrchestrationContext", () => { + it("exposes identity/replay properties and getInput from the core context", () => { + const { ctx } = createFakeCoreContext(); + const df = new DurableOrchestrationContext(ctx, { city: "Tokyo" }); + + expect(df.instanceId).toBe("instance-1"); + expect(df.isReplaying).toBe(true); + expect(df.currentUtcDateTime).toEqual(new Date("2026-01-02T03:04:05.000Z")); + expect(df.version).toBe("1.2.3"); + expect(df.getInput<{ city: string }>()).toEqual({ city: "Tokyo" }); + }); + + it("exposes parentInstanceId from the core context (undefined at top level)", () => { + const { ctx } = createFakeCoreContext(); + expect(new DurableOrchestrationContext(ctx, undefined).parentInstanceId).toBeUndefined(); + + const childCtx = { + parent: { name: "p", instanceId: "parent-1", taskScheduledId: 1 }, + } as unknown as OrchestrationContext; + expect(new DurableOrchestrationContext(childCtx, undefined).parentInstanceId).toBe("parent-1"); + }); + + it("forwards callActivity and callActivityWithRetry to the core context", () => { + const { ctx, raw } = createFakeCoreContext(); + const df = new DurableOrchestrationContext(ctx, undefined); + + expect(df.callActivity("sayHello", "Tokyo")).toBe("callActivity-task"); + expect(raw.callActivity).toHaveBeenCalledWith("sayHello", "Tokyo"); + + const retry = new RetryOptions(1000, 3); + df.callActivityWithRetry("flaky", retry, "input"); + const retryCall = raw.callActivity.mock.calls[1]; + expect(retryCall[0]).toBe("flaky"); + expect(retryCall[1]).toBe("input"); + expect(retryCall[2].retry).toBeInstanceOf(RetryPolicy); + expect(retryCall[2].retry.maxNumberOfAttempts).toBe(3); + expect(retryCall[2].retry.firstRetryIntervalInMilliseconds).toBe(1000); + }); + + it("forwards sub-orchestration calls with instanceId and retry options", () => { + const { ctx, raw } = createFakeCoreContext(); + const df = new DurableOrchestrationContext(ctx, undefined); + + df.callSubOrchestrator("child", "in", "child-id"); + expect(raw.callSubOrchestrator).toHaveBeenCalledWith("child", "in", { instanceId: "child-id" }); + + df.callSubOrchestrator("child2", "in2"); + expect(raw.callSubOrchestrator).toHaveBeenLastCalledWith("child2", "in2", undefined); + + const retry = new RetryOptions(1000, 2); + df.callSubOrchestratorWithRetry("child3", retry, "in3", "id3"); + const subCalls = raw.callSubOrchestrator.mock.calls; + const lastSub = subCalls[subCalls.length - 1]; + expect(lastSub[0]).toBe("child3"); + expect(lastSub[1]).toBe("in3"); + expect(lastSub[2].instanceId).toBe("id3"); + expect(lastSub[2].retry).toBeInstanceOf(RetryPolicy); + expect(lastSub[2].retry.maxNumberOfAttempts).toBe(2); + }); + + it("forwards timers, events, status, and entity operations", () => { + const { ctx, raw, entities } = createFakeCoreContext(); + const df = new DurableOrchestrationContext(ctx, undefined); + const entityId = new EntityInstanceId("Counter", "k1"); + + df.createTimer(123); + expect(raw.createTimer).toHaveBeenCalledWith(123); + + df.waitForExternalEvent("Approval"); + expect(raw.waitForExternalEvent).toHaveBeenCalledWith("Approval"); + + df.continueAsNew("next"); + expect(raw.continueAsNew).toHaveBeenCalledWith("next", true); + + df.setCustomStatus({ stage: "x" }); + expect(raw.setCustomStatus).toHaveBeenCalledWith({ stage: "x" }); + + expect(df.newGuid("ignored-instance-id")).toBe("guid-1"); + expect(raw.newGuid).toHaveBeenCalledTimes(1); + + df.callEntity(entityId, "add", 5); + expect(entities.callEntity).toHaveBeenCalledWith(entityId, "add", 5); + + df.signalEntity(entityId, "reset"); + expect(entities.signalEntity).toHaveBeenCalledWith(entityId, "reset", undefined); + }); + + it("throws for callHttp, which has no durabletask equivalent", () => { + const { ctx } = createFakeCoreContext(); + const df = new DurableOrchestrationContext(ctx, undefined); + + expect(() => df.callHttp({ method: "GET", uri: "https://example.test" })).toThrow(/callHttp/); + }); + + it("exposes Task.all / Task.any that forward to the core combinators", () => { + const { ctx } = createFakeCoreContext(); + const df = new DurableOrchestrationContext(ctx, undefined); + const tasks = ["t1", "t2"] as unknown as Task[]; + + const whenAllSpy = jest.spyOn(durabletask, "whenAll").mockReturnValue("all-result" as never); + const whenAnySpy = jest.spyOn(durabletask, "whenAny").mockReturnValue("any-result" as never); + try { + expect(df.Task.all(tasks)).toBe("all-result"); + expect(whenAllSpy).toHaveBeenCalledWith(tasks); + expect(df.Task.any(tasks)).toBe("any-result"); + expect(whenAnySpy).toHaveBeenCalledWith(tasks); + } finally { + whenAllSpy.mockRestore(); + whenAnySpy.mockRestore(); + } + }); +}); + +describe("wrapOrchestrator", () => { + it("returns a two-parameter core-native orchestrator unchanged", () => { + // A core-native orchestrator declares (ctx, input); a non-generator that returns a value is a + // valid TOrchestrator and keeps the arity at 2 so it passes through unwrapped. + const native: TOrchestrator = (_ctx, _input) => "native"; + expect(wrapOrchestrator(native)).toBe(native); + }); + + it("wraps a single-parameter classic orchestrator and drives it through context.df", () => { + const classic = function* ( + context: ClassicOrchestrationContext, + ): Generator, string, unknown> { + const input = context.df.getInput(); + const first = (yield context.df.callActivity("a", input)) as string; + return `done:${first}`; + }; + + const wrapped = wrapOrchestrator(classic); + // The wrapper is core-native shape (ctx, input). + expect(wrapped).not.toBe(classic); + + const { ctx, raw } = createFakeCoreContext(); + const gen = wrapped(ctx, "INPUT") as Generator; + + const firstYield = gen.next(); + expect(raw.callActivity).toHaveBeenCalledWith("a", "INPUT"); + expect(firstYield.value).toBe("callActivity-task"); + expect(firstYield.done).toBe(false); + + const final = gen.next("ACTIVITY_RESULT"); + expect(final.done).toBe(true); + expect(final.value).toBe("done:ACTIVITY_RESULT"); + }); +}); diff --git a/packages/azure-functions-durable/test/unit/query-types.spec.ts b/packages/azure-functions-durable/test/unit/query-types.spec.ts new file mode 100644 index 0000000..40718e2 --- /dev/null +++ b/packages/azure-functions-durable/test/unit/query-types.spec.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { OrchestrationState, OrchestrationStatus } from "@microsoft/durabletask-js"; +import { EntityStateResponse } from "../../src/entity-state-response"; +import { + DurableOrchestrationStatus, + OrchestrationRuntimeStatus, + toDurableOrchestrationStatus, + toOrchestrationRuntimeStatus, +} from "../../src/orchestration-status"; +import { PurgeHistoryResult } from "../../src/purge-history-result"; + +describe("toOrchestrationRuntimeStatus", () => { + it("maps core statuses to the classic v3 runtime status strings", () => { + expect(toOrchestrationRuntimeStatus(OrchestrationStatus.RUNNING)).toBe( + OrchestrationRuntimeStatus.Running, + ); + expect(toOrchestrationRuntimeStatus(OrchestrationStatus.COMPLETED)).toBe( + OrchestrationRuntimeStatus.Completed, + ); + expect(toOrchestrationRuntimeStatus(OrchestrationStatus.CONTINUED_AS_NEW)).toBe( + OrchestrationRuntimeStatus.ContinuedAsNew, + ); + expect(toOrchestrationRuntimeStatus(OrchestrationStatus.FAILED)).toBe( + OrchestrationRuntimeStatus.Failed, + ); + expect(toOrchestrationRuntimeStatus(OrchestrationStatus.TERMINATED)).toBe( + OrchestrationRuntimeStatus.Terminated, + ); + expect(toOrchestrationRuntimeStatus(OrchestrationStatus.PENDING)).toBe( + OrchestrationRuntimeStatus.Pending, + ); + expect(toOrchestrationRuntimeStatus(OrchestrationStatus.SUSPENDED)).toBe( + OrchestrationRuntimeStatus.Suspended, + ); + }); +}); + +describe("toDurableOrchestrationStatus", () => { + it("maps a core OrchestrationState and deserializes JSON payloads", () => { + const created = new Date("2026-01-01T00:00:00.000Z"); + const updated = new Date("2026-01-02T00:00:00.000Z"); + const state = new OrchestrationState( + "instance-1", + "helloOrchestrator", + OrchestrationStatus.COMPLETED, + created, + updated, + JSON.stringify({ city: "Tokyo" }), + JSON.stringify("Hello Tokyo"), + JSON.stringify({ stage: "done" }), + ); + + const status = toDurableOrchestrationStatus(state); + + expect(status).toBeInstanceOf(DurableOrchestrationStatus); + expect(status.name).toBe("helloOrchestrator"); + expect(status.instanceId).toBe("instance-1"); + expect(status.createdTime).toBe(created); + expect(status.lastUpdatedTime).toBe(updated); + expect(status.input).toEqual({ city: "Tokyo" }); + expect(status.output).toBe("Hello Tokyo"); + expect(status.customStatus).toEqual({ stage: "done" }); + expect(status.runtimeStatus).toBe(OrchestrationRuntimeStatus.Completed); + }); + + it("leaves payloads undefined when the core state omits them", () => { + const state = new OrchestrationState( + "instance-2", + "orch", + OrchestrationStatus.RUNNING, + new Date(), + new Date(), + ); + + const status = toDurableOrchestrationStatus(state); + + expect(status.input).toBeUndefined(); + expect(status.output).toBeUndefined(); + expect(status.customStatus).toBeUndefined(); + expect(status.runtimeStatus).toBe(OrchestrationRuntimeStatus.Running); + }); +}); + +describe("EntityStateResponse / PurgeHistoryResult", () => { + it("EntityStateResponse carries existence and state", () => { + const present = new EntityStateResponse(true, 42); + expect(present.entityExists).toBe(true); + expect(present.entityState).toBe(42); + + const absent = new EntityStateResponse(false); + expect(absent.entityExists).toBe(false); + expect(absent.entityState).toBeUndefined(); + }); + + it("PurgeHistoryResult carries the deleted count", () => { + expect(new PurgeHistoryResult(3).instancesDeleted).toBe(3); + }); +}); diff --git a/packages/azure-functions-durable/test/unit/retry-options.spec.ts b/packages/azure-functions-durable/test/unit/retry-options.spec.ts new file mode 100644 index 0000000..f55793e --- /dev/null +++ b/packages/azure-functions-durable/test/unit/retry-options.spec.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { RetryPolicy } from "@microsoft/durabletask-js"; +import { RetryOptions } from "../../src/retry-options"; + +describe("RetryOptions", () => { + it("maps the classic (firstRetryInterval, maxAttempts) shape onto a core RetryPolicy", () => { + const options = new RetryOptions(5000, 3); + + const policy = options.toRetryPolicy(); + + expect(policy).toBeInstanceOf(RetryPolicy); + expect(policy.firstRetryIntervalInMilliseconds).toBe(5000); + expect(policy.maxNumberOfAttempts).toBe(3); + expect(policy.backoffCoefficient).toBe(1); + }); + + it("forwards backoff and interval overrides to the core RetryPolicy", () => { + const options = new RetryOptions(1000, 5); + options.backoffCoefficient = 2; + options.maxRetryIntervalInMilliseconds = 60000; + options.retryTimeoutInMilliseconds = 300000; + + const policy = options.toRetryPolicy(); + + expect(policy.backoffCoefficient).toBe(2); + expect(policy.maxRetryIntervalInMilliseconds).toBe(60000); + expect(policy.retryTimeoutInMilliseconds).toBe(300000); + }); +}); 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 1ce8cf3..e4f43f4 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -1223,7 +1223,6 @@ export class TaskHubGrpcWorker { class CapturingSidecarStub { orchestratorResponse?: pb.OrchestratorResponse; entityResult?: pb.EntityBatchResult; - abandonRequest?: pb.AbandonOrchestrationTaskRequest; completeOrchestratorTask( request: pb.OrchestratorResponse, @@ -1244,11 +1243,13 @@ class CapturingSidecarStub { } abandonTaskOrchestratorWorkItem( - request: pb.AbandonOrchestrationTaskRequest, + _request: pb.AbandonOrchestrationTaskRequest, _metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: Empty) => void, ): void { - this.abandonRequest = request; + // Abandon is a no-op for the single-work-item host path: the version-mismatch abandon branch + // in _executeOrchestratorInternal calls this, but processOrchestratorRequest only surfaces a + // completion response. Matches the Python provider, whose null stub no-ops abandon. callback(null, new Empty()); } } From 91ba76b7e7caabbb290261917f911a71d0d52c0b Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 7 Jul 2026 16:25:08 -0700 Subject: [PATCH 10/26] Simplify azure-functions-durable tsconfig.build.json Drop rootDir, outDir, include, and exclude since they are inherited from ./tsconfig.json. Keep only the baseUrl + paths override that points @microsoft/durabletask-js imports at the built dist output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/azure-functions-durable/tsconfig.build.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/azure-functions-durable/tsconfig.build.json b/packages/azure-functions-durable/tsconfig.build.json index b309342..f891744 100644 --- a/packages/azure-functions-durable/tsconfig.build.json +++ b/packages/azure-functions-durable/tsconfig.build.json @@ -1,13 +1,9 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", "baseUrl": ".", "paths": { "@microsoft/durabletask-js": ["../durabletask-js/dist/index"] } - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "test"] + } } From 11a042dce9ed096f085d401f7e65ca9b5d0d8c8c Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 7 Jul 2026 18:33:20 -0700 Subject: [PATCH 11/26] Enhance createHttpManagementPayload to support classic v3 single-argument call style and update tests --- .../azure-functions-durable/src/client.ts | 47 +++++++++++++++++-- .../test/unit/client.spec.ts | 39 +++++++++++++++ .../src/proto/orchestrator_service_pb.d.ts | 2 +- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index e6d57e6..4fdf6d0 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -96,8 +96,35 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { }); } - createHttpManagementPayload(request: HttpRequest, instanceId: string): HttpManagementPayload { - const instanceStatusUrl = getInstanceStatusUrl(request, instanceId); + /** + * Builds the {@link HttpManagementPayload} of management URLs for an orchestration instance. + * + * @remarks + * Two call styles are supported (mirroring the Python provider's backward-compatible surface): + * - `createHttpManagementPayload(request, instanceId)` (recommended): builds the URLs relative to + * the incoming request's origin. + * - `createHttpManagementPayload(instanceId)` (classic Durable Functions v3 style): builds the + * URLs from the client binding's `baseUrl` when no request is available. + */ + createHttpManagementPayload(instanceId: string): HttpManagementPayload; + createHttpManagementPayload(request: HttpRequest, instanceId: string): HttpManagementPayload; + createHttpManagementPayload( + requestOrInstanceId: HttpRequest | string, + instanceId?: string, + ): HttpManagementPayload { + // Classic Durable Functions (v3) accepted a single positional `instanceId`. Detect that call + // style (a lone string argument) and fall back to the client binding's `baseUrl` when building + // the payload URLs. + let request: HttpRequest | undefined; + if (typeof requestOrInstanceId === "string") { + instanceId = requestOrInstanceId; + } else { + request = requestOrInstanceId; + } + if (instanceId === undefined) { + throw new TypeError("instanceId is required."); + } + const instanceStatusUrl = getInstanceStatusUrl(request, instanceId, this.baseUrl); return createPayload(instanceId, instanceStatusUrl, this.requiredQueryStringParameters); } @@ -262,10 +289,20 @@ export function getGrpcHostAddress(rpcBaseUrl: string): string { } } -function getInstanceStatusUrl(request: HttpRequest, instanceId: string): string { - const requestUrl = new URL(request.url); +function getInstanceStatusUrl( + request: HttpRequest | undefined, + instanceId: string, + baseUrl: string, +): string { const encodedInstanceId = encodeURIComponent(instanceId); - return `${requestUrl.protocol}//${requestUrl.host}/runtime/webhooks/durabletask/instances/${encodedInstanceId}`; + if (request !== undefined) { + const requestUrl = new URL(request.url); + return `${requestUrl.protocol}//${requestUrl.host}/runtime/webhooks/durabletask/instances/${encodedInstanceId}`; + } + // No request (classic Durable Functions v3 single-argument call): fall back to the base URL + // supplied in the client binding configuration. + const trimmedBaseUrl = baseUrl.replace(/\/+$/, ""); + return `${trimmedBaseUrl}/instances/${encodedInstanceId}`; } function parseClientConfig(clientConfig: DurableFunctionsClientInput): DurableFunctionsClientConfig { diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index 8fc0748..7015a8e 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -85,6 +85,45 @@ describe("DurableFunctionsClient", () => { } }); + it("creates HTTP management payload URLs from baseUrl for the classic v3 single-argument call", async () => { + const client = new DurableFunctionsClient({ + ...CLIENT_CONFIG, + baseUrl: "https://public.example/runtime/webhooks/durabletask/", + }); + + try { + const payload = client.createHttpManagementPayload("instance 1"); + + expect(payload.id).toBe("instance 1"); + expect(payload.statusQueryGetUri).toBe( + "https://public.example/runtime/webhooks/durabletask/instances/instance%201?code=secret&taskHub=functions-taskhub", + ); + expect(payload.terminatePostUri).toBe( + "https://public.example/runtime/webhooks/durabletask/instances/instance%201/terminate?reason={text}&code=secret&taskHub=functions-taskhub", + ); + } finally { + await client.stop(); + } + }); + + it("throws when createHttpManagementPayload is called without an instance id", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + const request = new HttpRequest({ + method: "POST", + url: "https://public.example/api/start", + }); + + try { + expect(() => + (client.createHttpManagementPayload as unknown as (request: HttpRequest) => unknown)( + request, + ), + ).toThrow(TypeError); + } 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({ diff --git a/packages/durabletask-js/src/proto/orchestrator_service_pb.d.ts b/packages/durabletask-js/src/proto/orchestrator_service_pb.d.ts index 1505288..f3076db 100644 --- a/packages/durabletask-js/src/proto/orchestrator_service_pb.d.ts +++ b/packages/durabletask-js/src/proto/orchestrator_service_pb.d.ts @@ -1756,8 +1756,8 @@ export class OrchestratorRequest extends jspb.Message { static extensions: {[key: number]: jspb.ExtensionFieldInfo}; static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; static serializeBinaryToWriter(message: OrchestratorRequest, writer: jspb.BinaryWriter): void; - static deserializeBinary(bytes: Uint8Array): OrchestratorRequest; static deserializeBinaryFromReader(message: OrchestratorRequest, reader: jspb.BinaryReader): OrchestratorRequest; + static deserializeBinary(bytes: Uint8Array): OrchestratorRequest; } export namespace OrchestratorRequest { From b98583aec8e7c9c51dd22c0386a0b5b99ef79915 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 7 Jul 2026 19:18:54 -0700 Subject: [PATCH 12/26] Add classic (v3) lifecycle aliases and purgeInstanceHistoryBy method to DurableFunctionsClient --- packages/azure-functions-durable/CHANGELOG.md | 1 + .../azure-functions-durable/src/client.ts | 95 +++++++++++++++++++ .../test/unit/client.spec.ts | 62 +++++++++++- 3 files changed, 157 insertions(+), 1 deletion(-) diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index d398fd4..42fc805 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -9,3 +9,4 @@ - Added a classic (v3) backward-compatibility layer: `context.df.*` orchestration and entity context adapters (`wrapOrchestrator` / `wrapEntity`), `RetryOptions`, and the deprecated `DurableOrchestrationClient` alias. Classic single-parameter and core-native two-parameter handlers are both supported. - Added classic (v3) client query-return types (`DurableOrchestrationStatus`, `OrchestrationRuntimeStatus`, `EntityStateResponse`, `PurgeHistoryResult`) and `DurableFunctionsClient` methods `getStatus` / `readEntityState` / `purgeInstanceHistory` that map from the core client. Added `context.df.callHttp`, which throws (the durabletask engine has no durable-HTTP equivalent). - Added the remaining v3 client surface: `startNew`, `getStatusAll`, `getStatusBy`, and `waitForCompletionOrCreateCheckStatusResponse`, plus `context.df.parentInstanceId`. The client now honors the host-provided `maxGrpcMessageSizeInBytes` by wiring it into the gRPC channel options (parity with the Python provider). +- Added the classic (v3) client lifecycle aliases `raiseEvent`, `terminate`, `suspend`, `resume`, `rewind`, `restart`, and `purgeInstanceHistoryBy` as deprecated thin wrappers over the core `TaskHubGrpcClient` methods, so existing v3 management code keeps working. (`signalEntity` is already inherited under the same name.) diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index 4fdf6d0..45274b7 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -6,6 +6,7 @@ import { EntityInstanceId, OrchestrationQuery, OrchestrationStatus, + PurgeInstanceCriteria, TaskHubGrpcClient, } from "@microsoft/durabletask-js"; import { @@ -261,6 +262,100 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { return new PurgeHistoryResult(result?.deletedInstanceCount ?? 0); } + /** + * Purges the history of orchestration instances matching a filter (classic Durable Functions v3 + * shape), returning a {@link PurgeHistoryResult}. + * + * @deprecated Use {@link purgeOrchestration} with a {@link PurgeInstanceCriteria} instead. + * @param createdTimeFrom - Lower bound (inclusive) of the instance creation-time window. + * @param createdTimeTo - Optional upper bound of the creation-time window. + * @param runtimeStatus - Optional runtime-status filter. + */ + async purgeInstanceHistoryBy( + createdTimeFrom: Date, + createdTimeTo?: Date, + runtimeStatus?: OrchestrationRuntimeStatus[], + ): Promise { + const criteria = new PurgeInstanceCriteria(); + criteria.setCreatedTimeFrom(createdTimeFrom); + criteria.setCreatedTimeTo(createdTimeTo); + if (runtimeStatus) { + criteria.setRuntimeStatusList(runtimeStatus.map(fromOrchestrationRuntimeStatus)); + } + const result = await this.purgeOrchestration(criteria); + return new PurgeHistoryResult(result?.deletedInstanceCount ?? 0); + } + + /** + * Raises an external event to an orchestration instance (classic Durable Functions v3 alias). + * + * @deprecated Use {@link raiseOrchestrationEvent} instead. + * @param instanceId - The orchestration instance to raise the event on. + * @param eventName - The name of the event (case-insensitive). + * @param eventData - Optional JSON-serializable event payload. + */ + async raiseEvent(instanceId: string, eventName: string, eventData?: unknown): Promise { + await this.raiseOrchestrationEvent(instanceId, eventName, eventData ?? null); + } + + /** + * Terminates an orchestration instance (classic Durable Functions v3 alias). + * + * @deprecated Use {@link terminateOrchestration} instead. + * @param instanceId - The orchestration instance to terminate. + * @param reason - Optional reason recorded as the terminated instance's output. + */ + async terminate(instanceId: string, reason?: unknown): Promise { + await this.terminateOrchestration(instanceId, reason ?? null); + } + + /** + * Suspends a running orchestration instance (classic Durable Functions v3 alias). + * + * @deprecated Use {@link suspendOrchestration} instead. + * @param instanceId - The orchestration instance to suspend. + * @param _reason - Accepted for classic v3 signature compatibility; ignored (the core engine does + * not record a suspend reason). + */ + async suspend(instanceId: string, _reason?: string): Promise { + await this.suspendOrchestration(instanceId); + } + + /** + * Resumes a suspended orchestration instance (classic Durable Functions v3 alias). + * + * @deprecated Use {@link resumeOrchestration} instead. + * @param instanceId - The orchestration instance to resume. + * @param _reason - Accepted for classic v3 signature compatibility; ignored. + */ + async resume(instanceId: string, _reason?: string): Promise { + await this.resumeOrchestration(instanceId); + } + + /** + * Rewinds a failed orchestration instance so it retries from its point of failure (classic + * Durable Functions v3 alias). + * + * @deprecated Use {@link rewindInstance} instead. + * @param instanceId - The failed orchestration instance to rewind. + * @param reason - Optional reason describing why the instance is being rewound. + */ + async rewind(instanceId: string, reason?: string): Promise { + await this.rewindInstance(instanceId, reason ?? ""); + } + + /** + * Restarts an orchestration instance with its original input (classic Durable Functions v3 alias). + * + * @deprecated Use {@link restartOrchestration} instead. + * @param instanceId - The orchestration instance to restart. + * @param restartWithNewInstanceId - When `true`, the restarted instance is assigned a new ID. + * @returns The instance ID of the restarted orchestration. + */ + async restart(instanceId: string, restartWithNewInstanceId = false): Promise { + return this.restartOrchestration(instanceId, restartWithNewInstanceId); + } + /** @hidden Iterates the core paged query and maps each instance to the v3 status shape. */ private async collectStatuses(query: OrchestrationQuery): Promise { const results: DurableOrchestrationStatus[] = []; diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index 7015a8e..6765d96 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -2,9 +2,10 @@ // Licensed under the MIT License. import { HttpRequest } from "@azure/functions"; -import { TaskHubGrpcClient } from "@microsoft/durabletask-js"; +import { PurgeInstanceCriteria, TaskHubGrpcClient } from "@microsoft/durabletask-js"; import { DurableFunctionsClient, getGrpcHostAddress } from "../../src/client"; import { createAzureFunctionsMetadataGenerator } from "../../src/metadata"; +import { OrchestrationRuntimeStatus } from "../../src/orchestration-status"; const CLIENT_CONFIG = { taskHubName: "functions-taskhub", @@ -33,6 +34,13 @@ describe("DurableFunctionsClient", () => { expect(typeof client.purgeOrchestration).toBe("function"); expect(typeof client.signalEntity).toBe("function"); expect(typeof client.getEntity).toBe("function"); + expect(typeof client.raiseEvent).toBe("function"); + expect(typeof client.terminate).toBe("function"); + expect(typeof client.suspend).toBe("function"); + expect(typeof client.resume).toBe("function"); + expect(typeof client.rewind).toBe("function"); + expect(typeof client.restart).toBe("function"); + expect(typeof client.purgeInstanceHistoryBy).toBe("function"); expect(Object.getOwnPropertyNames(DurableFunctionsClient.prototype).sort()).toEqual([ "collectStatuses", "constructor", @@ -42,8 +50,15 @@ describe("DurableFunctionsClient", () => { "getStatusAll", "getStatusBy", "purgeInstanceHistory", + "purgeInstanceHistoryBy", + "raiseEvent", "readEntityState", + "restart", + "resume", + "rewind", "startNew", + "suspend", + "terminate", "waitForCompletionOrCreateCheckStatusResponse", ]); } finally { @@ -51,6 +66,51 @@ describe("DurableFunctionsClient", () => { } }); + it("forwards classic Durable Functions v3 lifecycle aliases to the core methods", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + + try { + const raise = jest.spyOn(client, "raiseOrchestrationEvent").mockResolvedValue(undefined); + await client.raiseEvent("id-1", "evt", { a: 1 }); + expect(raise).toHaveBeenCalledWith("id-1", "evt", { a: 1 }); + + const terminate = jest.spyOn(client, "terminateOrchestration").mockResolvedValue(undefined); + await client.terminate("id-1", "cancelled"); + expect(terminate).toHaveBeenCalledWith("id-1", "cancelled"); + + const suspend = jest.spyOn(client, "suspendOrchestration").mockResolvedValue(undefined); + await client.suspend("id-1", "ignored-reason"); + expect(suspend).toHaveBeenCalledWith("id-1"); + + const resume = jest.spyOn(client, "resumeOrchestration").mockResolvedValue(undefined); + await client.resume("id-1", "ignored-reason"); + expect(resume).toHaveBeenCalledWith("id-1"); + + const rewind = jest.spyOn(client, "rewindInstance").mockResolvedValue(undefined); + await client.rewind("id-1", "retrying"); + expect(rewind).toHaveBeenCalledWith("id-1", "retrying"); + + const restart = jest.spyOn(client, "restartOrchestration").mockResolvedValue("id-2"); + await expect(client.restart("id-1", true)).resolves.toBe("id-2"); + expect(restart).toHaveBeenCalledWith("id-1", true); + + const purge = jest.spyOn(client, "purgeOrchestration").mockResolvedValue(undefined); + const purgeResult = await client.purgeInstanceHistoryBy( + new Date("2020-01-01T00:00:00.000Z"), + new Date("2020-02-01T00:00:00.000Z"), + [OrchestrationRuntimeStatus.Completed], + ); + expect(purgeResult.instancesDeleted).toBe(0); + expect(purge).toHaveBeenCalledTimes(1); + const criteria = purge.mock.calls[0][0] as PurgeInstanceCriteria; + expect(criteria.getCreatedTimeFrom()).toEqual(new Date("2020-01-01T00:00:00.000Z")); + expect(criteria.getCreatedTimeTo()).toEqual(new Date("2020-02-01T00:00:00.000Z")); + expect(criteria.getRuntimeStatusList()).toHaveLength(1); + } 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({ From 8d863def1bdd6888ee99442317eb854345633ee8 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Jul 2026 08:18:05 -0700 Subject: [PATCH 13/26] Add classic (v3) entity support and custom status handling in Durable Functions - Introduced `EntityId` class for classic v3 entity identifiers. - Enhanced `DurableEntityContext` to include `isNewlyConstructed` and `entityId` properties. - Implemented `signalEntity` method in `DurableEntityContext` for signaling other entities. - Updated `DurableOrchestrationContext` to track and set custom status. - Added unit tests for new features and behaviors. --- packages/azure-functions-durable/CHANGELOG.md | 1 + .../src/entity-context.ts | 37 ++++++++++++++++++- .../azure-functions-durable/src/entity-id.ts | 15 ++++++++ packages/azure-functions-durable/src/index.ts | 1 + .../src/orchestration-context.ts | 8 ++++ .../test/unit/entity-context.spec.ts | 22 +++++++++++ .../test/unit/entity-id.spec.ts | 19 ++++++++++ .../test/unit/orchestration-context.spec.ts | 10 +++++ 8 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 packages/azure-functions-durable/src/entity-id.ts create mode 100644 packages/azure-functions-durable/test/unit/entity-id.spec.ts diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index 42fc805..dc80c02 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -10,3 +10,4 @@ - Added classic (v3) client query-return types (`DurableOrchestrationStatus`, `OrchestrationRuntimeStatus`, `EntityStateResponse`, `PurgeHistoryResult`) and `DurableFunctionsClient` methods `getStatus` / `readEntityState` / `purgeInstanceHistory` that map from the core client. Added `context.df.callHttp`, which throws (the durabletask engine has no durable-HTTP equivalent). - Added the remaining v3 client surface: `startNew`, `getStatusAll`, `getStatusBy`, and `waitForCompletionOrCreateCheckStatusResponse`, plus `context.df.parentInstanceId`. The client now honors the host-provided `maxGrpcMessageSizeInBytes` by wiring it into the gRPC channel options (parity with the Python provider). - Added the classic (v3) client lifecycle aliases `raiseEvent`, `terminate`, `suspend`, `resume`, `rewind`, `restart`, and `purgeInstanceHistoryBy` as deprecated thin wrappers over the core `TaskHubGrpcClient` methods, so existing v3 management code keeps working. (`signalEntity` is already inherited under the same name.) +- Added the classic (v3) `EntityId` class (`new EntityId(name, key)`) so existing entity code that constructs entity ids keeps working, plus `context.df.entityId` / `context.df.isNewlyConstructed` / `context.df.signalEntity` on the entity context and a `context.df.customStatus` getter on the orchestration context. diff --git a/packages/azure-functions-durable/src/entity-context.ts b/packages/azure-functions-durable/src/entity-context.ts index af8370d..a130488 100644 --- a/packages/azure-functions-durable/src/entity-context.ts +++ b/packages/azure-functions-durable/src/entity-context.ts @@ -1,7 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { EntityFactory, ITaskEntity, TaskEntityOperation } from "@microsoft/durabletask-js"; +import { + EntityFactory, + EntityInstanceId, + ITaskEntity, + TaskEntityOperation, +} from "@microsoft/durabletask-js"; /** * Classic Durable Functions (v3) entity context, exposed to migrating entity functions as @@ -15,8 +20,11 @@ import { EntityFactory, ITaskEntity, TaskEntityOperation } from "@microsoft/dura export class DurableEntityContext { private _result: unknown; private _resultSet = false; + private readonly _isNewlyConstructed: boolean; - constructor(private readonly _operation: TaskEntityOperation) {} + constructor(private readonly _operation: TaskEntityOperation) { + this._isNewlyConstructed = !_operation.state.hasState; + } /** The name of the current operation. */ get operationName(): string { @@ -38,6 +46,16 @@ export class DurableEntityContext { return this._operation.context.id.toString(); } + /** The entity instance ID as an {@link EntityInstanceId} object. */ + get entityId(): EntityInstanceId { + return this._operation.context.id; + } + + /** Whether this entity had no state prior to the current operation (freshly constructed). */ + get isNewlyConstructed(): boolean { + return this._isNewlyConstructed; + } + /** Gets the input for the current operation. */ getInput(): T | undefined { return this._operation.getInput(); @@ -69,6 +87,21 @@ export class DurableEntityContext { this._operation.state.setState(undefined); } + /** + * Signals another entity operation without waiting for a response (fire-and-forget). + * + * @param entityId - The target entity. + * @param operationName - The name of the operation to invoke. + * @param operationInput - Optional input for the operation. + */ + signalEntity( + entityId: EntityInstanceId, + operationName: string, + operationInput?: unknown, + ): void { + this._operation.context.signalEntity(entityId, operationName, operationInput); + } + /** @hidden Returns the explicitly-set result, or the provided fallback when none was set. */ resolveResult(fallback: unknown): unknown { return this._resultSet ? this._result : fallback; diff --git a/packages/azure-functions-durable/src/entity-id.ts b/packages/azure-functions-durable/src/entity-id.ts new file mode 100644 index 0000000..7b9b860 --- /dev/null +++ b/packages/azure-functions-durable/src/entity-id.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { EntityInstanceId } from "@microsoft/durabletask-js"; + +/** + * Classic Durable Functions (v3) entity identifier, constructed as `new EntityId(name, key)`. + * + * @remarks + * A thin subclass of the core {@link EntityInstanceId}, retained so existing `durable-functions` + * code that builds `new df.EntityId(name, key)` and passes it to `callEntity` / `signalEntity` / + * `readEntityState` keeps working. The entity name is normalized to lowercase and the key is + * preserved, matching the core behavior. + */ +export class EntityId extends EntityInstanceId {} diff --git a/packages/azure-functions-durable/src/index.ts b/packages/azure-functions-durable/src/index.ts index fc1c615..dc4f1cc 100644 --- a/packages/azure-functions-durable/src/index.ts +++ b/packages/azure-functions-durable/src/index.ts @@ -38,5 +38,6 @@ export { toDurableOrchestrationStatus, toOrchestrationRuntimeStatus, } from "./orchestration-status"; +export { EntityId } from "./entity-id"; export { EntityStateResponse } from "./entity-state-response"; export { PurgeHistoryResult } from "./purge-history-result"; diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index b58f00c..88ec6a6 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -21,6 +21,8 @@ import { RetryOptions } from "./retry-options"; * `durable-functions` `context.df.*` API run unchanged on the gRPC/durabletask engine. */ export class DurableOrchestrationContext { + private _customStatus: unknown; + constructor( private readonly _ctx: OrchestrationContext, private readonly _input: unknown, @@ -51,6 +53,11 @@ export class DurableOrchestrationContext { return this._ctx.version; } + /** The custom status set during this execution via {@link setCustomStatus} (or `undefined`). */ + get customStatus(): unknown { + return this._customStatus; + } + /** * `Task.all` / `Task.any` fan-out/fan-in helpers (v3 shape), forwarding to the core * `whenAll` / `whenAny` combinators. @@ -127,6 +134,7 @@ export class DurableOrchestrationContext { /** Sets the orchestration's custom status payload. */ setCustomStatus(customStatus: unknown): void { + this._customStatus = customStatus; this._ctx.setCustomStatus(customStatus); } diff --git a/packages/azure-functions-durable/test/unit/entity-context.spec.ts b/packages/azure-functions-durable/test/unit/entity-context.spec.ts index 9c34726..2044ad7 100644 --- a/packages/azure-functions-durable/test/unit/entity-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/entity-context.spec.ts @@ -65,6 +65,28 @@ describe("DurableEntityContext", () => { expect(getStoredState()).toBe(15); }); + it("reports isNewlyConstructed based on whether prior state existed", () => { + const fresh = new DurableEntityContext(createFakeOperation({ name: "add" }).operation); + expect(fresh.isNewlyConstructed).toBe(true); + + const existing = new DurableEntityContext( + createFakeOperation({ name: "add", initialState: 1 }).operation, + ); + expect(existing.isNewlyConstructed).toBe(false); + }); + + it("exposes the entity id object and signals other entities through the core context", () => { + const { operation } = createFakeOperation({ name: "op", initialState: 1 }); + const df = new DurableEntityContext(operation); + + expect(df.entityId).toBe(operation.context.id); + expect(df.entityId.toString()).toBe("@counter@user-1"); + + const target = new EntityInstanceId("Other", "k-9"); + df.signalEntity(target, "poke", 42); + expect(operation.context.signalEntity).toHaveBeenCalledWith(target, "poke", 42); + }); + it("returns the initializer default when there is no state", () => { const { operation } = createFakeOperation({ name: "get" }); const df = new DurableEntityContext(operation); diff --git a/packages/azure-functions-durable/test/unit/entity-id.spec.ts b/packages/azure-functions-durable/test/unit/entity-id.spec.ts new file mode 100644 index 0000000..5b64040 --- /dev/null +++ b/packages/azure-functions-durable/test/unit/entity-id.spec.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { EntityInstanceId } from "@microsoft/durabletask-js"; +import { EntityId } from "../../src/entity-id"; + +describe("EntityId", () => { + it("constructs a classic v3 entity id, lowercasing the name and preserving the key", () => { + const id = new EntityId("Counter", "User-1"); + + expect(id.name).toBe("counter"); + expect(id.key).toBe("User-1"); + expect(id.toString()).toBe("@counter@User-1"); + }); + + it("is an EntityInstanceId so it works with the core entity APIs", () => { + expect(new EntityId("Counter", "k")).toBeInstanceOf(EntityInstanceId); + }); +}); diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts index 0c6222b..a1a4279 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -62,6 +62,16 @@ describe("DurableOrchestrationContext", () => { expect(new DurableOrchestrationContext(childCtx, undefined).parentInstanceId).toBe("parent-1"); }); + it("tracks the custom status locally and forwards it to the core context", () => { + const { ctx, raw } = createFakeCoreContext(); + const df = new DurableOrchestrationContext(ctx, undefined); + + expect(df.customStatus).toBeUndefined(); + df.setCustomStatus({ stage: "processing" }); + expect(df.customStatus).toEqual({ stage: "processing" }); + expect(raw.setCustomStatus).toHaveBeenCalledWith({ stage: "processing" }); + }); + it("forwards callActivity and callActivityWithRetry to the core context", () => { const { ctx, raw } = createFakeCoreContext(); const df = new DurableOrchestrationContext(ctx, undefined); From 708a9500afa85eb1fec126cfc77580a475b1f816 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Jul 2026 08:27:13 -0700 Subject: [PATCH 14/26] Add deprecated alias getClientResponseLinks for createHttpManagementPayload in DurableFunctionsClient --- .../azure-functions-durable/src/client.ts | 40 ++++++++++++++++--- .../test/unit/client.spec.ts | 23 +++++++++-- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index 45274b7..73283a3 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -129,9 +129,27 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { return createPayload(instanceId, instanceStatusUrl, this.requiredQueryStringParameters); } + /** + * Deprecated alias for {@link createHttpManagementPayload} (classic Durable Functions v3 shape). + * + * @deprecated Use {@link createHttpManagementPayload} instead. + * @param request - The incoming HTTP request, or `undefined` to build the URLs from the client + * binding's `baseUrl`. + * @param instanceId - The orchestration instance to build management URLs for. + */ + getClientResponseLinks( + request: HttpRequest | undefined, + instanceId: string, + ): HttpManagementPayload { + return request === undefined + ? this.createHttpManagementPayload(instanceId) + : this.createHttpManagementPayload(request, instanceId); + } + /** * Starts a new orchestration instance (classic Durable Functions v3 `startNew` alias). * + * @deprecated Use {@link scheduleNewOrchestration} instead. * @param orchestratorName - The name of the orchestrator to start. * @param options - Optional input and instance ID. * @returns The instance ID of the started orchestration. @@ -150,6 +168,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { /** * Gets the status of an orchestration instance in the classic Durable Functions (v3) shape. * + * @deprecated Use {@link getOrchestrationState} instead. * @param instanceId - The ID of the orchestration instance to query. * @param options - When `showInput` is `false`, input/output payloads are not fetched. * @returns The instance status, or `undefined` if the instance does not exist. @@ -164,6 +183,8 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { /** * Gets the status of all orchestration instances (classic Durable Functions v3 shape). + * + * @deprecated Use {@link getAllInstances} instead. */ async getStatusAll(): Promise { return this.collectStatuses({ fetchInputsAndOutputs: true }); @@ -172,6 +193,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { /** * Gets the status of orchestration instances matching a filter (classic Durable Functions v3 shape). * + * @deprecated Use {@link getAllInstances} instead. * @param filter - Creation-time window and/or runtime-status filter. */ async getStatusBy(filter: { @@ -192,6 +214,8 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * its output/status, otherwise returns the same 202 check-status response as * {@link createCheckStatusResponse}. Classic Durable Functions v3 behavior. * + * @deprecated Use {@link waitForOrchestrationCompletion} together with + * {@link createCheckStatusResponse} instead. * @param request - The incoming HTTP request (used to build management URLs on timeout). * @param instanceId - The orchestration instance to wait for. * @param waitOptions - Optional total wait timeout in milliseconds (default 10s). @@ -237,6 +261,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { /** * Reads the state of a durable entity in the classic Durable Functions (v3) shape. * + * @deprecated Use {@link getEntity} instead. * @param entityId - The target entity instance ID. * @param includeState - Whether to include the entity state in the response (default `true`). */ @@ -255,6 +280,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * Purges the history of a single orchestration instance, returning the classic Durable Functions * (v3) {@link PurgeHistoryResult}. * + * @deprecated Use {@link purgeOrchestration} instead. * @param instanceId - The ID of the orchestration instance to purge. */ async purgeInstanceHistory(instanceId: string): Promise { @@ -336,12 +362,16 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * Rewinds a failed orchestration instance so it retries from its point of failure (classic * Durable Functions v3 alias). * - * @deprecated Use {@link rewindInstance} instead. - * @param instanceId - The failed orchestration instance to rewind. - * @param reason - Optional reason describing why the instance is being rewound. + * @remarks + * Not supported: the durabletask engine has no rewind equivalent yet. This mirrors the Python + * provider, which raises for the same reason. + * @deprecated Not supported; rewind has no durabletask equivalent yet. + * @param _instanceId - The failed orchestration instance to rewind. + * @param _reason - Optional reason describing why the instance is being rewound. + * @throws Always throws: rewind is not yet supported. */ - async rewind(instanceId: string, reason?: string): Promise { - await this.rewindInstance(instanceId, reason ?? ""); + async rewind(_instanceId: string, _reason?: string): Promise { + throw new Error("rewind is not yet supported by durabletask."); } /** diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index 6765d96..2521431 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -46,6 +46,7 @@ describe("DurableFunctionsClient", () => { "constructor", "createCheckStatusResponse", "createHttpManagementPayload", + "getClientResponseLinks", "getStatus", "getStatusAll", "getStatusBy", @@ -86,9 +87,9 @@ describe("DurableFunctionsClient", () => { await client.resume("id-1", "ignored-reason"); expect(resume).toHaveBeenCalledWith("id-1"); - const rewind = jest.spyOn(client, "rewindInstance").mockResolvedValue(undefined); - await client.rewind("id-1", "retrying"); - expect(rewind).toHaveBeenCalledWith("id-1", "retrying"); + await expect(client.rewind("id-1", "retrying")).rejects.toThrow( + "rewind is not yet supported", + ); const restart = jest.spyOn(client, "restartOrchestration").mockResolvedValue("id-2"); await expect(client.restart("id-1", true)).resolves.toBe("id-2"); @@ -184,6 +185,22 @@ describe("DurableFunctionsClient", () => { } }); + it("exposes getClientResponseLinks as a deprecated alias of createHttpManagementPayload", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + const request = new HttpRequest({ + method: "POST", + url: "https://public.example/api/start", + }); + + try { + expect(client.getClientResponseLinks(request, "abc")).toEqual( + client.createHttpManagementPayload(request, "abc"), + ); + } 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({ From 00d705e0d5699bb11ed284fdcbe519635ef18809 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Jul 2026 09:44:45 -0700 Subject: [PATCH 15/26] Simplify Azure Functions Durable CHANGELOG and README per review Collapse the 4.0.0-alpha.0 changelog to a single bullet and rewrite the README to focus on what the package supports and why it is needed, dropping the implementation-plan / phase-status / open-questions sections. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/azure-functions-durable/CHANGELOG.md | 8 - packages/azure-functions-durable/README.md | 150 ++++-------------- 2 files changed, 35 insertions(+), 123 deletions(-) diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index dc80c02..b2c1a44 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -3,11 +3,3 @@ ## 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. -- Added the authoring model (`app.orchestration` / `app.activity` / `app.entity`, `input.durableClient`, `getClient`). -- Added a classic (v3) backward-compatibility layer: `context.df.*` orchestration and entity context adapters (`wrapOrchestrator` / `wrapEntity`), `RetryOptions`, and the deprecated `DurableOrchestrationClient` alias. Classic single-parameter and core-native two-parameter handlers are both supported. -- Added classic (v3) client query-return types (`DurableOrchestrationStatus`, `OrchestrationRuntimeStatus`, `EntityStateResponse`, `PurgeHistoryResult`) and `DurableFunctionsClient` methods `getStatus` / `readEntityState` / `purgeInstanceHistory` that map from the core client. Added `context.df.callHttp`, which throws (the durabletask engine has no durable-HTTP equivalent). -- Added the remaining v3 client surface: `startNew`, `getStatusAll`, `getStatusBy`, and `waitForCompletionOrCreateCheckStatusResponse`, plus `context.df.parentInstanceId`. The client now honors the host-provided `maxGrpcMessageSizeInBytes` by wiring it into the gRPC channel options (parity with the Python provider). -- Added the classic (v3) client lifecycle aliases `raiseEvent`, `terminate`, `suspend`, `resume`, `rewind`, `restart`, and `purgeInstanceHistoryBy` as deprecated thin wrappers over the core `TaskHubGrpcClient` methods, so existing v3 management code keeps working. (`signalEntity` is already inherited under the same name.) -- Added the classic (v3) `EntityId` class (`new EntityId(name, key)`) so existing entity code that constructs entity ids keeps working, plus `context.df.entityId` / `context.df.isNewlyConstructed` / `context.df.signalEntity` on the entity context and a `context.df.customStatus` getter on the orchestration context. diff --git a/packages/azure-functions-durable/README.md b/packages/azure-functions-durable/README.md index 01df584..7dfd1ce 100644 --- a/packages/azure-functions-durable/README.md +++ b/packages/azure-functions-durable/README.md @@ -1,130 +1,50 @@ # durable-functions -Azure Functions Durable provider for the Durable Task JavaScript SDK. +Write [Azure Durable Functions](https://learn.microsoft.com/azure/azure-functions/durable/) orchestrations, activities, and entities in JavaScript and TypeScript, running on the [Durable Task JavaScript SDK](https://github.com/microsoft/durabletask-js). -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. +## What this package is -## Phase 1 scope +`durable-functions` is the Azure Functions Durable provider for JavaScript, built on `@microsoft/durabletask-js`. You author Durable Functions apps with the familiar `app.orchestration` / `app.activity` / `app.entity` model, and the provider talks to the Durable Task backend over the Functions host's gRPC channel. -This preview includes the low-level host integration pieces: +This is a new major version that supersedes the legacy [`durable-functions`](https://github.com/Azure/azure-functions-durable-js) package. New and existing (classic v3) orchestration, activity, and entity code all run on the same gRPC engine. -- `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 authoring model (`app.orchestration` / `app.activity` / `app.entity`, `input.durableClient`, `getClient`) and a **classic (v3) backward-compatibility** layer (see below). +## Why it is needed -Orchestrator, activity, and entity bodies can be written either in the core `@microsoft/durabletask-js` style or in the classic `durable-functions` v3 style; both run on the gRPC engine. +- **One gRPC protocol.** Durable work items flow between the Functions host and your app over a single gRPC channel instead of the legacy out-of-proc HTTP protocol, keeping the JavaScript provider aligned with the .NET, Python, and Java Durable providers. +- **Shared core engine.** Orchestration replay, activities, entities, retries, and instance-management APIs all come from `@microsoft/durabletask-js`, so behavior matches the other Durable Task SDKs. +- **Backward compatible.** Existing v3 Durable Functions orchestrators and entities keep working through a compatibility layer, so you can move to this provider without rewriting your functions. -## Client binding +## What it supports -The Functions host passes a JSON client-binding payload to the app. Construct the client from that payload: +- **Authoring** — `app.orchestration`, `app.activity`, and `app.entity` register durable functions (each trigger opts into the host's gRPC protocol automatically). +- **Client** — `getClient(context)` returns a `DurableFunctionsClient` for scheduling, querying, signaling, and managing instances, plus HTTP management-payload helpers (`createCheckStatusResponse`, `createHttpManagementPayload`) for durable HTTP starters. +- **Classic (v3) compatibility** — orchestrators and entities written in the legacy `context.df.*` style, `RetryOptions`, `EntityId`, and the deprecated client aliases are adapted onto the core engine. -```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: +## Getting started ```typescript -const worker = new DurableFunctionsWorker(); -worker.addOrchestrator(myOrchestrator); - -const encodedResponse = await worker.handleOrchestratorRequest(encodedRequest); +import * as df from "durable-functions"; +import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions"; + +df.app.orchestration("helloOrchestrator", async function* (ctx, input) { + return yield ctx.callActivity("sayHello", input); +}); + +df.app.activity("sayHello", { + handler: (name: string) => `Hello, ${name}!`, +}); + +app.http("startHello", { + route: "orchestrators/helloOrchestrator", + extraInputs: [df.input.durableClient()], + handler: async (request: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + const instanceId = await client.scheduleNewOrchestration("helloOrchestrator", "Durable"); + return client.createCheckStatusResponse(request, instanceId); + }, +}); ``` -## Classic (v3) backward compatibility - -Orchestrators and entities may be written in the legacy `durable-functions` v3 style. The style is -detected by parameter count and adapted onto the core engine — the classic API surface forwards to -the core context, it does not re-implement the v3 replay engine. - -- **Classic orchestrator** — a single-parameter generator using `context.df.*`: - - ```typescript - import * as df from "durable-functions"; - - df.app.orchestration("helloOrchestrator", function* (context) { - const input = context.df.getInput(); - const retry = new df.RetryOptions(5000, 3); - const r = yield context.df.callActivityWithRetry("sayHello", retry, input); - const all = yield context.df.Task.all([context.df.callActivity("a"), context.df.callActivity("b")]); - return [r, all]; - }); - ``` - -- **Core-native orchestrator** — a two-parameter `(ctx, input)` generator using `ctx.*`, passed through unchanged: - - ```typescript - df.app.orchestration("helloOrchestrator", async function* (ctx, input) { - return yield ctx.callActivity("sayHello", input); - }); - ``` - -- **Classic entity** — a single-parameter function using `context.df.*`; core-native zero-argument - `EntityFactory` handlers pass through unchanged: - - ```typescript - df.app.entity("Counter", (context) => { - const current = context.df.getState(() => 0); - switch (context.df.operationName) { - case "add": context.df.setState(current + context.df.getInput()); break; - case "get": context.df.setResult(current); break; - } - }); - ``` - -`RetryOptions` and the deprecated `DurableOrchestrationClient` alias are also provided for source -compatibility. - -## Phase 2 status - -Implemented in this phase: - -- `src/app.ts`: `app.orchestration` / `app.activity` / `app.entity` registration over a shared worker. -- `src/trigger.ts` / `src/input.ts`: durable triggers and the `durableClient` input binding, all emitting `durableRequiresGrpc: true`. -- `src/get-client.ts`: `getClient(context)` constructs a `DurableFunctionsClient` from the host binding. -- `src/orchestration-context.ts` / `src/entity-context.ts`: classic (v3) `context.df.*` adapters plus `wrapOrchestrator` / `wrapEntity`. -- `src/retry-options.ts`: classic `RetryOptions` mapping to the core `RetryPolicy`. -- `src/orchestration-status.ts` / `src/entity-state-response.ts` / `src/purge-history-result.ts`: classic (v3) client query-return types (`DurableOrchestrationStatus`, `OrchestrationRuntimeStatus`, `EntityStateResponse`, `PurgeHistoryResult`), surfaced through `DurableFunctionsClient.getStatus` / `readEntityState` / `purgeInstanceHistory`. `context.df.callHttp` is present but throws (no durabletask durable-HTTP equivalent). - -Deferred: - -- **Functions data converter.** The core SDK serializes at the protobuf string boundary with plain `JSON.stringify` / `JSON.parse` and exposes no pluggable converter injection point, so the Python provider's `FunctionsDataConverter` (custom-object `{__class__,__module__,__data__}` envelope) cannot be ported here without a core change. This package keeps the plain-JSON contract for now. - -Open questions for the Functions extension team: +## Status -- 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. +This package is an early preview (`4.0.0-alpha.0`); APIs may change before the stable release. \ No newline at end of file From 073bf2527ed1b34f408c77a6098a924e95133915 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Jul 2026 15:42:32 -0700 Subject: [PATCH 16/26] Export v3-compatible ActivityHandler/OrchestrationHandler/OrchestrationContext type aliases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/azure-functions-durable/src/index.ts | 8 ++++++++ .../test/unit/compat-exports.spec.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 packages/azure-functions-durable/test/unit/compat-exports.spec.ts diff --git a/packages/azure-functions-durable/src/index.ts b/packages/azure-functions-durable/src/index.ts index dc4f1cc..cb68d9d 100644 --- a/packages/azure-functions-durable/src/index.ts +++ b/packages/azure-functions-durable/src/index.ts @@ -41,3 +41,11 @@ export { export { EntityId } from "./entity-id"; export { EntityStateResponse } from "./entity-state-response"; export { PurgeHistoryResult } from "./purge-history-result"; + +// Legacy durable-functions v3 API compatibility aliases (types only). These let orchestrator/ +// activity code written against the classic `durable-functions` v3 API type-check unchanged. +export type { ActivityHandler } from "./app"; +export type { + ClassicOrchestrator as OrchestrationHandler, + ClassicOrchestrationContext as OrchestrationContext, +} from "./orchestration-context"; diff --git a/packages/azure-functions-durable/test/unit/compat-exports.spec.ts b/packages/azure-functions-durable/test/unit/compat-exports.spec.ts new file mode 100644 index 0000000..d37eae9 --- /dev/null +++ b/packages/azure-functions-durable/test/unit/compat-exports.spec.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import type { ActivityHandler, OrchestrationContext, OrchestrationHandler } from "../../src"; + +describe("v3 compatibility type aliases", () => { + it("exposes ActivityHandler / OrchestrationHandler / OrchestrationContext", () => { + const activity: ActivityHandler = (input: unknown) => input; + const orchestrator: OrchestrationHandler = function* (context: OrchestrationContext) { + yield context.df.callActivity("noop"); + return context.df.getInput(); + }; + expect(typeof activity).toBe("function"); + expect(typeof orchestrator).toBe("function"); + }); +}); From cd2b6ff9b2c12ec80b8d162cd33e3d253107e21b Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Jul 2026 17:13:34 -0700 Subject: [PATCH 17/26] Fix wrapOrchestrator to drive classic v3 orchestrators via async generator Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/orchestration-context.ts | 10 +++++++--- .../test/unit/orchestration-context.spec.ts | 15 ++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index 88ec6a6..690dfb8 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -207,10 +207,14 @@ export type ClassicOrchestrator = ( export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): TOrchestrator { if (typeof handler === "function" && handler.length === 1) { const classic = handler as ClassicOrchestrator; - const wrapped = function* ( + // The core engine only drives orchestrators whose invocation returns an ASYNC generator + // (it gates on Symbol.asyncIterator). Classic v3 orchestrators are SYNC generators, so the + // wrapper must itself be an async generator that delegates to the classic one via `yield*`; + // that forwards each core Task to the engine and pipes sent results back into the classic body. + const wrapped = async function* ( ctx: OrchestrationContext, input: unknown, - ): Generator, unknown, unknown> { + ): AsyncGenerator, unknown, unknown> { const df = new DurableOrchestrationContext(ctx, input); const result = classic({ df }); if (isGenerator(result)) { @@ -218,7 +222,7 @@ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): } return result; }; - return wrapped as TOrchestrator; + return wrapped as unknown as TOrchestrator; } return handler as TOrchestrator; } diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts index a1a4279..bb3b87a 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -171,7 +171,7 @@ describe("wrapOrchestrator", () => { expect(wrapOrchestrator(native)).toBe(native); }); - it("wraps a single-parameter classic orchestrator and drives it through context.df", () => { + it("wraps a single-parameter classic orchestrator and drives it through context.df", async () => { const classic = function* ( context: ClassicOrchestrationContext, ): Generator, string, unknown> { @@ -185,14 +185,19 @@ describe("wrapOrchestrator", () => { expect(wrapped).not.toBe(classic); const { ctx, raw } = createFakeCoreContext(); - const gen = wrapped(ctx, "INPUT") as Generator; - - const firstYield = gen.next(); + // The engine gates on Symbol.asyncIterator: the wrapper MUST return an async generator or the + // core executor never drives it (it would complete immediately with the raw return value). + const gen = wrapped(ctx, "INPUT") as AsyncGenerator; + expect(typeof (gen as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator]).toBe( + "function", + ); + + const firstYield = await gen.next(); expect(raw.callActivity).toHaveBeenCalledWith("a", "INPUT"); expect(firstYield.value).toBe("callActivity-task"); expect(firstYield.done).toBe(false); - const final = gen.next("ACTIVITY_RESULT"); + const final = await gen.next("ACTIVITY_RESULT"); expect(final.done).toBe(true); expect(final.value).toBe("done:ACTIVITY_RESULT"); }); From bfc4e3b24dd5651389370247ec5a5aaf3571dc51 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Jul 2026 20:52:34 -0700 Subject: [PATCH 18/26] Add EntityContext/EntityHandler/DurableClient aliases and replay-safe context.log to classic orchestration context Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/azure-functions-durable/src/index.ts | 14 ++++++++ .../src/orchestration-context.ts | 28 +++++++++++++++- .../test/unit/compat-exports.spec.ts | 23 ++++++++++++- .../test/unit/orchestration-context.spec.ts | 33 ++++++++++++++++++- 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/azure-functions-durable/src/index.ts b/packages/azure-functions-durable/src/index.ts index cb68d9d..3214b85 100644 --- a/packages/azure-functions-durable/src/index.ts +++ b/packages/azure-functions-durable/src/index.ts @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import type { ClassicEntityContext, ClassicEntity } from "./entity-context"; +import type { DurableFunctionsClient } from "./client"; + export * as app from "./app"; export * as trigger from "./trigger"; export * as input from "./input"; @@ -49,3 +52,14 @@ export type { ClassicOrchestrator as OrchestrationHandler, ClassicOrchestrationContext as OrchestrationContext, } from "./orchestration-context"; + +/** v3-compat: client returned by {@link getClient}. */ +export type DurableClient = DurableFunctionsClient; +/** + * v3-compat: entity context. `TState` is accepted for source compatibility with the legacy generic + * API; the underlying `df.getState()`/`getInput()` are per-call generic, so the type param is + * intentionally ignored here. + */ +export type EntityContext<_TState = unknown> = ClassicEntityContext; +/** v3-compat: entity handler. `TState` accepted for source compatibility (see {@link EntityContext}). */ +export type EntityHandler<_TState = unknown> = ClassicEntity; diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index 690dfb8..631f0c0 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { + ConsoleLogger, EntityInstanceId, OrchestrationContext, Task, @@ -183,6 +184,18 @@ export class DurableOrchestrationContext { /** The object passed to a classic (v3) orchestrator function; its `df` is the durable context. */ export interface ClassicOrchestrationContext { df: DurableOrchestrationContext; + /** Replay-safe log at info level (suppressed during replay). Alias of {@link info}. */ + log(...args: unknown[]): void; + /** Replay-safe error log (suppressed during replay). */ + error(...args: unknown[]): void; + /** Replay-safe warning log (suppressed during replay). */ + warn(...args: unknown[]): void; + /** Replay-safe info log (suppressed during replay). */ + info(...args: unknown[]): void; + /** Replay-safe debug log (suppressed during replay). */ + debug(...args: unknown[]): void; + /** Replay-safe trace log (mapped to debug; suppressed during replay). */ + trace(...args: unknown[]): void; } /** @@ -216,7 +229,20 @@ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): input: unknown, ): AsyncGenerator, unknown, unknown> { const df = new DurableOrchestrationContext(ctx, input); - const result = classic({ df }); + // Replay-safe logger: output is suppressed while the engine replays history, matching the + // .NET/Python providers. Core Logger has no `trace`/plain `log`, so log→info and trace→debug. + const logger = ctx.createReplaySafeLogger(new ConsoleLogger()); + const toArgs = (a: unknown[]) => a as [string, ...unknown[]]; + const classicCtx: ClassicOrchestrationContext = { + df, + log: (...a) => logger.info(...toArgs(a)), + info: (...a) => logger.info(...toArgs(a)), + warn: (...a) => logger.warn(...toArgs(a)), + error: (...a) => logger.error(...toArgs(a)), + debug: (...a) => logger.debug(...toArgs(a)), + trace: (...a) => logger.debug(...toArgs(a)), + }; + const result = classic(classicCtx); if (isGenerator(result)) { return yield* result; } diff --git a/packages/azure-functions-durable/test/unit/compat-exports.spec.ts b/packages/azure-functions-durable/test/unit/compat-exports.spec.ts index d37eae9..918e962 100644 --- a/packages/azure-functions-durable/test/unit/compat-exports.spec.ts +++ b/packages/azure-functions-durable/test/unit/compat-exports.spec.ts @@ -1,7 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import type { ActivityHandler, OrchestrationContext, OrchestrationHandler } from "../../src"; +import type { + ActivityHandler, + DurableClient, + EntityContext, + EntityHandler, + OrchestrationContext, + OrchestrationHandler, +} from "../../src"; describe("v3 compatibility type aliases", () => { it("exposes ActivityHandler / OrchestrationHandler / OrchestrationContext", () => { @@ -13,4 +20,18 @@ describe("v3 compatibility type aliases", () => { expect(typeof activity).toBe("function"); expect(typeof orchestrator).toBe("function"); }); + + it("exposes generic EntityContext / EntityHandler and DurableClient", () => { + // Compile-guard: the generic aliases must accept a type argument (the legacy v3 surface uses + // e.g. EntityHandler), even though our underlying types are non-generic. + type _E1 = EntityContext<{ x: number }>; + type _E2 = EntityHandler; + const handler: EntityHandler = (context: EntityContext) => { + context.df.return(0); + }; + // DurableClient is the type returned by getClient(); assert it's usable as a type annotation. + const client: DurableClient | undefined = undefined; + expect(typeof handler).toBe("function"); + expect(client).toBeUndefined(); + }); }); diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts index bb3b87a..3d1df67 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -22,6 +22,12 @@ function createFakeCoreContext() { callEntity: jest.fn().mockReturnValue("callEntity-task"), signalEntity: jest.fn(), }; + const replaySafeLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }; const ctx = { instanceId: "instance-1", isReplaying: true, @@ -35,9 +41,10 @@ function createFakeCoreContext() { setCustomStatus: jest.fn(), sendEvent: jest.fn(), newGuid: jest.fn().mockReturnValue("guid-1"), + createReplaySafeLogger: jest.fn(() => replaySafeLogger), entities, }; - return { ctx: ctx as unknown as OrchestrationContext, raw: ctx, entities }; + return { ctx: ctx as unknown as OrchestrationContext, raw: ctx, entities, replaySafeLogger }; } describe("DurableOrchestrationContext", () => { @@ -201,4 +208,28 @@ describe("wrapOrchestrator", () => { expect(final.done).toBe(true); expect(final.value).toBe("done:ACTIVITY_RESULT"); }); + + it("exposes a replay-safe logger as context.log/error on the classic context", async () => { + // A classic orchestrator may be a plain (non-generator) function that returns a value; the + // wrapper still invokes it with the full classic context, so log wiring is exercised here. + const classic = (context: ClassicOrchestrationContext): string => { + context.log("hi"); + context.error("boom"); + return "logged"; + }; + + const { ctx, replaySafeLogger } = createFakeCoreContext(); + const wrapped = wrapOrchestrator(classic); + const gen = wrapped(ctx, undefined) as AsyncGenerator; + + const done = await gen.next(); + expect(done.done).toBe(true); + expect(done.value).toBe("logged"); + // The wrapper builds the classic context's log methods from the CORE replay-safe logger. + expect( + (ctx as unknown as { createReplaySafeLogger: jest.Mock }).createReplaySafeLogger, + ).toHaveBeenCalledTimes(1); + expect(replaySafeLogger.info).toHaveBeenCalledWith("hi"); + expect(replaySafeLogger.error).toHaveBeenCalledWith("boom"); + }); }); From 701cd13f8de6d7d6546abc1c965f06dae1f0e8a7 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Jul 2026 21:28:13 -0700 Subject: [PATCH 19/26] Fix purgeInstanceHistoryBy to accept an OrchestrationFilter object (v3-compatible) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../azure-functions-durable/src/client.ts | 22 +++++++++---------- .../test/unit/client.spec.ts | 10 ++++----- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index 73283a3..f68d7b7 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -293,20 +293,18 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * shape), returning a {@link PurgeHistoryResult}. * * @deprecated Use {@link purgeOrchestration} with a {@link PurgeInstanceCriteria} instead. - * @param createdTimeFrom - Lower bound (inclusive) of the instance creation-time window. - * @param createdTimeTo - Optional upper bound of the creation-time window. - * @param runtimeStatus - Optional runtime-status filter. + * @param filter - Creation-time window and/or runtime-status filter. */ - async purgeInstanceHistoryBy( - createdTimeFrom: Date, - createdTimeTo?: Date, - runtimeStatus?: OrchestrationRuntimeStatus[], - ): Promise { + async purgeInstanceHistoryBy(filter: { + createdTimeFrom?: Date; + createdTimeTo?: Date; + runtimeStatus?: OrchestrationRuntimeStatus[]; + }): Promise { const criteria = new PurgeInstanceCriteria(); - criteria.setCreatedTimeFrom(createdTimeFrom); - criteria.setCreatedTimeTo(createdTimeTo); - if (runtimeStatus) { - criteria.setRuntimeStatusList(runtimeStatus.map(fromOrchestrationRuntimeStatus)); + criteria.setCreatedTimeFrom(filter.createdTimeFrom); + criteria.setCreatedTimeTo(filter.createdTimeTo); + if (filter.runtimeStatus) { + criteria.setRuntimeStatusList(filter.runtimeStatus.map(fromOrchestrationRuntimeStatus)); } const result = await this.purgeOrchestration(criteria); return new PurgeHistoryResult(result?.deletedInstanceCount ?? 0); diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index 2521431..f68ff83 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -96,11 +96,11 @@ describe("DurableFunctionsClient", () => { expect(restart).toHaveBeenCalledWith("id-1", true); const purge = jest.spyOn(client, "purgeOrchestration").mockResolvedValue(undefined); - const purgeResult = await client.purgeInstanceHistoryBy( - new Date("2020-01-01T00:00:00.000Z"), - new Date("2020-02-01T00:00:00.000Z"), - [OrchestrationRuntimeStatus.Completed], - ); + const purgeResult = await client.purgeInstanceHistoryBy({ + createdTimeFrom: new Date("2020-01-01T00:00:00.000Z"), + createdTimeTo: new Date("2020-02-01T00:00:00.000Z"), + runtimeStatus: [OrchestrationRuntimeStatus.Completed], + }); expect(purgeResult.instancesDeleted).toBe(0); expect(purge).toHaveBeenCalledTimes(1); const criteria = purge.mock.calls[0][0] as PurgeInstanceCriteria; From 834d3475989a0eb9b5885eec709c62d550a2c2b9 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 9 Jul 2026 08:41:42 -0700 Subject: [PATCH 20/26] Extract named OrchestrationFilter type and reuse in getStatusBy/purgeInstanceHistoryBy (v3-compat) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/azure-functions-durable/src/client.ts | 14 +++----------- packages/azure-functions-durable/src/index.ts | 1 + .../src/orchestration-filter.ts | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 packages/azure-functions-durable/src/orchestration-filter.ts diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index f68d7b7..804d785 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -17,10 +17,10 @@ import { EntityStateResponse } from "./entity-state-response"; import { createAzureFunctionsMetadataGenerator } from "./metadata"; import { DurableOrchestrationStatus, - OrchestrationRuntimeStatus, fromOrchestrationRuntimeStatus, toDurableOrchestrationStatus, } from "./orchestration-status"; +import { OrchestrationFilter } from "./orchestration-filter"; import { PurgeHistoryResult } from "./purge-history-result"; export interface DurableFunctionsClientConfig { @@ -196,11 +196,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * @deprecated Use {@link getAllInstances} instead. * @param filter - Creation-time window and/or runtime-status filter. */ - async getStatusBy(filter: { - createdTimeFrom?: Date; - createdTimeTo?: Date; - runtimeStatus?: OrchestrationRuntimeStatus[]; - }): Promise { + async getStatusBy(filter: OrchestrationFilter): Promise { return this.collectStatuses({ createdFrom: filter.createdTimeFrom, createdTo: filter.createdTimeTo, @@ -295,11 +291,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * @deprecated Use {@link purgeOrchestration} with a {@link PurgeInstanceCriteria} instead. * @param filter - Creation-time window and/or runtime-status filter. */ - async purgeInstanceHistoryBy(filter: { - createdTimeFrom?: Date; - createdTimeTo?: Date; - runtimeStatus?: OrchestrationRuntimeStatus[]; - }): Promise { + async purgeInstanceHistoryBy(filter: OrchestrationFilter): Promise { const criteria = new PurgeInstanceCriteria(); criteria.setCreatedTimeFrom(filter.createdTimeFrom); criteria.setCreatedTimeTo(filter.createdTimeTo); diff --git a/packages/azure-functions-durable/src/index.ts b/packages/azure-functions-durable/src/index.ts index 3214b85..c7f1ad1 100644 --- a/packages/azure-functions-durable/src/index.ts +++ b/packages/azure-functions-durable/src/index.ts @@ -44,6 +44,7 @@ export { export { EntityId } from "./entity-id"; export { EntityStateResponse } from "./entity-state-response"; export { PurgeHistoryResult } from "./purge-history-result"; +export { OrchestrationFilter } from "./orchestration-filter"; // Legacy durable-functions v3 API compatibility aliases (types only). These let orchestrator/ // activity code written against the classic `durable-functions` v3 API type-check unchanged. diff --git a/packages/azure-functions-durable/src/orchestration-filter.ts b/packages/azure-functions-durable/src/orchestration-filter.ts new file mode 100644 index 0000000..e78bda6 --- /dev/null +++ b/packages/azure-functions-durable/src/orchestration-filter.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { OrchestrationRuntimeStatus } from "./orchestration-status"; + +/** + * Filter for selecting orchestration instances by creation time and/or runtime status, + * matching the classic Durable Functions v3 `OrchestrationFilter` shape. Used by + * {@link DurableFunctionsClient.getStatusBy} and {@link DurableFunctionsClient.purgeInstanceHistoryBy}. + */ +export interface OrchestrationFilter { + /** Select orchestration instances created at or after this time. */ + createdTimeFrom?: Date; + /** Select orchestration instances created at or before this time. */ + createdTimeTo?: Date; + /** Select orchestration instances whose runtime status matches any value in this array. */ + runtimeStatus?: OrchestrationRuntimeStatus[]; +} From c7114a933d16313f77ac9a994d324d437ba67221 Mon Sep 17 00:00:00 2001 From: e2e local Date: Mon, 13 Jul 2026 16:23:01 -0700 Subject: [PATCH 21/26] fix(functions): wire compat rewind() to core rewindInstance() + guard empty-string parseJson Two self-contained fixes in the durable-functions v4 compat package: - rewind(): delegate to core TaskHubGrpcClient.rewindInstance() instead of throwing 'rewind is not yet supported'; the optional reason defaults to an empty string. - parseJson(): treat empty-string serialized input/output/customStatus as undefined so getStatusAll/getStatusBy no longer throw 'Unexpected end of JSON input'. Adds one regression test per fix (durable-functions unit suite: 67 tests, all green). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../azure-functions-durable/src/client.ts | 13 +++------- .../src/orchestration-status.ts | 2 +- .../test/unit/client.spec.ts | 26 ++++++++++++++++--- .../test/unit/query-types.spec.ts | 24 +++++++++++++++++ 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index 804d785..3afdd4c 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -352,16 +352,11 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * Rewinds a failed orchestration instance so it retries from its point of failure (classic * Durable Functions v3 alias). * - * @remarks - * Not supported: the durabletask engine has no rewind equivalent yet. This mirrors the Python - * provider, which raises for the same reason. - * @deprecated Not supported; rewind has no durabletask equivalent yet. - * @param _instanceId - The failed orchestration instance to rewind. - * @param _reason - Optional reason describing why the instance is being rewound. - * @throws Always throws: rewind is not yet supported. + * @param instanceId - The failed orchestration instance to rewind. + * @param reason - Optional reason describing why the instance is being rewound. */ - async rewind(_instanceId: string, _reason?: string): Promise { - throw new Error("rewind is not yet supported by durabletask."); + async rewind(instanceId: string, reason?: string): Promise { + await this.rewindInstance(instanceId, reason ?? ""); } /** diff --git a/packages/azure-functions-durable/src/orchestration-status.ts b/packages/azure-functions-durable/src/orchestration-status.ts index afd7678..1ce3b76 100644 --- a/packages/azure-functions-durable/src/orchestration-status.ts +++ b/packages/azure-functions-durable/src/orchestration-status.ts @@ -123,5 +123,5 @@ export function toDurableOrchestrationStatus(state: OrchestrationState): Durable /** @hidden */ function parseJson(value: string | undefined): unknown { - return value === undefined ? undefined : JSON.parse(value); + return value === undefined || value === "" ? undefined : JSON.parse(value); } diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index f68ff83..9a2ebcb 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -87,9 +87,9 @@ describe("DurableFunctionsClient", () => { await client.resume("id-1", "ignored-reason"); expect(resume).toHaveBeenCalledWith("id-1"); - await expect(client.rewind("id-1", "retrying")).rejects.toThrow( - "rewind is not yet supported", - ); + const rewind = jest.spyOn(client, "rewindInstance").mockResolvedValue(undefined); + await client.rewind("id-1", "retrying"); + expect(rewind).toHaveBeenCalledWith("id-1", "retrying"); const restart = jest.spyOn(client, "restartOrchestration").mockResolvedValue("id-2"); await expect(client.restart("id-1", true)).resolves.toBe("id-2"); @@ -112,6 +112,26 @@ describe("DurableFunctionsClient", () => { } }); + it("rewind() delegates to core rewindInstance() and no longer throws", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + + try { + const rewindInstance = jest + .spyOn(client, "rewindInstance") + .mockResolvedValue(undefined); + + // Regression: previously threw "rewind is not yet supported by durabletask." + await expect(client.rewind("inst-1", "because")).resolves.toBeUndefined(); + expect(rewindInstance).toHaveBeenCalledWith("inst-1", "because"); + + // The optional reason defaults to an empty string when omitted. + await client.rewind("inst-2"); + expect(rewindInstance).toHaveBeenCalledWith("inst-2", ""); + } 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({ diff --git a/packages/azure-functions-durable/test/unit/query-types.spec.ts b/packages/azure-functions-durable/test/unit/query-types.spec.ts index 40718e2..9965d7f 100644 --- a/packages/azure-functions-durable/test/unit/query-types.spec.ts +++ b/packages/azure-functions-durable/test/unit/query-types.spec.ts @@ -81,6 +81,30 @@ describe("toDurableOrchestrationStatus", () => { expect(status.customStatus).toBeUndefined(); expect(status.runtimeStatus).toBe(OrchestrationRuntimeStatus.Running); }); + + it("treats empty-string payloads as undefined and still parses valid siblings", () => { + // Regression: empty serialized fields previously threw + // "SyntaxError: Unexpected end of JSON input" from JSON.parse(""). + const state = new OrchestrationState( + "instance-3", + "orch", + OrchestrationStatus.RUNNING, + new Date(), + new Date(), + JSON.stringify({ a: 1 }), // valid input still round-trips + "", // empty output -> undefined (previously threw) + "", // empty custom status -> undefined (previously threw) + ); + + let status!: DurableOrchestrationStatus; + expect(() => { + status = toDurableOrchestrationStatus(state); + }).not.toThrow(); + + expect(status.input).toEqual({ a: 1 }); + expect(status.output).toBeUndefined(); + expect(status.customStatus).toBeUndefined(); + }); }); describe("EntityStateResponse / PurgeHistoryResult", () => { From f935bcefd1e112adffd155fb5cf2ab820eb8f727 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 14 Jul 2026 14:06:09 -0700 Subject: [PATCH 22/26] Address PR #282 review: arity fix, parseJson, exports, docs, node bump Resolves andystaples' review comments plus a ListAll E2E bug on packages/azure-functions-durable: - #1 orchestrator arity: detect generator kind (sync=classic v3, async=core native) instead of an arity heuristic; driver now handles sync+async generators so single-param native orchestrators actually execute. - #2 parseJson tolerates non-JSON serializedOutput (fixes getStatusAll 400). - #3 createCheckStatusResponse accepts undefined request (baseUrl fallback). - #4 createTimer returns TimerTask (cancelable) in its type signature. - #5 re-export TaskFailedError from core + compat; document removed v3-only exports in CHANGELOG/README. - #6/#7 document getStatus + classic-context breaking changes. - #8 bump @types/node to ^22 to match engines.node>=22. - #9 set version 0.4.0 + document release order. - #10 distinct abandon error for the single work-item execution path. - #11 revert proto codegen churn to origin/main. Adds regression tests for items #1/#2/#3/#5/#10. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 4 +- packages/azure-functions-durable/CHANGELOG.md | 30 +++++- packages/azure-functions-durable/README.md | 22 +++- packages/azure-functions-durable/package.json | 4 +- .../azure-functions-durable/src/client.ts | 70 ++++++------ .../src/entity-context.ts | 21 ++-- packages/azure-functions-durable/src/index.ts | 12 +-- .../src/orchestration-context.ts | 101 +++++++++++------- .../src/orchestration-status.ts | 20 +++- .../test/unit/client.spec.ts | 36 +++++-- .../test/unit/compat-exports.spec.ts | 8 ++ .../test/unit/orchestration-context.spec.ts | 50 ++++++--- .../test/unit/orchestration-status.spec.ts | 43 ++++++++ packages/durabletask-js/src/index.ts | 44 ++++---- .../src/proto/orchestrator_service_pb.d.ts | 2 +- .../src/worker/task-hub-grpc-worker.ts | 23 +++- .../test/functions-grpc-support.spec.ts | 37 ++++++- 17 files changed, 371 insertions(+), 156 deletions(-) create mode 100644 packages/azure-functions-durable/test/unit/orchestration-status.spec.ts diff --git a/package-lock.json b/package-lock.json index f9c04e0..bf0b630 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7571,7 +7571,7 @@ }, "packages/azure-functions-durable": { "name": "durable-functions", - "version": "4.0.0-alpha.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@azure/functions": "^4.16.1", @@ -7580,7 +7580,7 @@ }, "devDependencies": { "@types/jest": "^29.5.1", - "@types/node": "^18.16.1", + "@types/node": "^22.0.0", "jest": "^29.5.0", "ts-jest": "^29.1.0", "typescript": "^5.0.4" diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index b2c1a44..b1c8d48 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog -## 4.0.0-alpha.0 +## 0.4.0 -- Added the initial gRPC-consolidated Azure Functions Durable provider package. +Initial gRPC-consolidated Azure Functions Durable provider, built on `@microsoft/durabletask-js`. + +### Requirements + +- Node.js >= 22 (drops the Node 18/20 that `durable-functions` v3 supported). + +### Breaking changes vs `durable-functions` v3 + +- Classic orchestration and entity contexts no longer extend `InvocationContext`; they expose only + `df` plus replay-safe log helpers (no `invocationId` / `functionName` / `extraInputs`). +- Task result shape follows the core SDK: use `isComplete` / `isFailed` / `getResult()` (v3 used + `isCompleted` / `isFaulted` / `result`). `context.df.createTimer(...)` returns a cancelable + `TimerTask`, so the timeout-race pattern (`Task.any` then `timer.cancel()`) keeps working. +- `client.getStatus()` returns `DurableOrchestrationStatus | undefined` (was non-optional) and honors + only `showInput`; `showHistory` / `showHistoryOutput` are ignored and `history` is not populated. +- `client.startNew()` drops the v3 `version` option. +- Removed top-level exports: `DummyOrchestrationContext`, `DummyEntityContext`, `DurableError`, + `AggregatedError`, `ManagedIdentityTokenSource`, `TokenSource`. `TaskFailedError` is re-exported + from the core SDK and aggregate failures surface as JS-native `AggregateError`. For orchestration + unit tests, use the core `TestOrchestrationWorker` / `TestOrchestrationClient` in place of the v3 + dummy contexts. + +### Release order + +- Publish `@microsoft/durabletask-js` (0.3.0) before this package. +- Confirm the target `@azure/functions` (^4.16.1) build includes the Durable extension changes + (`DurableRequiresGrpc`) required by the consolidated gRPC path. diff --git a/packages/azure-functions-durable/README.md b/packages/azure-functions-durable/README.md index 7dfd1ce..d97ef63 100644 --- a/packages/azure-functions-durable/README.md +++ b/packages/azure-functions-durable/README.md @@ -6,7 +6,7 @@ Write [Azure Durable Functions](https://learn.microsoft.com/azure/azure-function `durable-functions` is the Azure Functions Durable provider for JavaScript, built on `@microsoft/durabletask-js`. You author Durable Functions apps with the familiar `app.orchestration` / `app.activity` / `app.entity` model, and the provider talks to the Durable Task backend over the Functions host's gRPC channel. -This is a new major version that supersedes the legacy [`durable-functions`](https://github.com/Azure/azure-functions-durable-js) package. New and existing (classic v3) orchestration, activity, and entity code all run on the same gRPC engine. +This package supersedes the legacy [`durable-functions`](https://github.com/Azure/azure-functions-durable-js) package. New and existing (classic v3) orchestration, activity, and entity code all run on the same gRPC engine. ## Why it is needed @@ -20,6 +20,24 @@ This is a new major version that supersedes the legacy [`durable-functions`](htt - **Client** — `getClient(context)` returns a `DurableFunctionsClient` for scheduling, querying, signaling, and managing instances, plus HTTP management-payload helpers (`createCheckStatusResponse`, `createHttpManagementPayload`) for durable HTTP starters. - **Classic (v3) compatibility** — orchestrators and entities written in the legacy `context.df.*` style, `RetryOptions`, `EntityId`, and the deprecated client aliases are adapted onto the core engine. +## Migrating from durable-functions v3 + +This provider keeps classic `context.df.*` orchestrators and entities working, but a few surfaces +changed. See [`CHANGELOG.md`](./CHANGELOG.md) for the full list; the highlights: + +- **Node.js >= 22** is required (v3 supported Node 18/20). +- **Classic contexts no longer extend `InvocationContext`** — only `df` plus replay-safe log helpers + are available (no `invocationId` / `functionName` / `extraInputs`). +- **Task result shape follows the core SDK** — use `isComplete` / `isFailed` / `getResult()` instead + of v3's `isCompleted` / `isFaulted` / `result`. `context.df.createTimer(...)` still returns a + cancelable `TimerTask` for the timeout-race pattern. +- **`client.getStatus()` may return `undefined`** and honors only `showInput` (`showHistory` / + `showHistoryOutput` are ignored); **`client.startNew()` drops the `version` option**. +- **Some v3 top-level exports were removed** — `DummyOrchestrationContext` / `DummyEntityContext`, + `DurableError` / `AggregatedError`, and `ManagedIdentityTokenSource` / `TokenSource`. + `TaskFailedError` is re-exported from the core SDK; use the core `TestOrchestrationWorker` / + `TestOrchestrationClient` for orchestration unit tests. + ## Getting started ```typescript @@ -47,4 +65,4 @@ app.http("startHello", { ## Status -This package is an early preview (`4.0.0-alpha.0`); APIs may change before the stable release. \ No newline at end of file +This package is an early preview (`0.4.0`); APIs may change before the stable release. diff --git a/packages/azure-functions-durable/package.json b/packages/azure-functions-durable/package.json index 128ca9f..330f179 100644 --- a/packages/azure-functions-durable/package.json +++ b/packages/azure-functions-durable/package.json @@ -1,6 +1,6 @@ { "name": "durable-functions", - "version": "4.0.0-alpha.0", + "version": "0.4.0", "description": "Azure Functions Durable provider for the Durable Task JavaScript SDK", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -55,7 +55,7 @@ }, "devDependencies": { "@types/jest": "^29.5.1", - "@types/node": "^18.16.1", + "@types/node": "^22.0.0", "jest": "^29.5.0", "ts-jest": "^29.1.0", "typescript": "^5.0.4" diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index 3afdd4c..8670d08 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -9,10 +9,7 @@ import { PurgeInstanceCriteria, TaskHubGrpcClient, } from "@microsoft/durabletask-js"; -import { - HttpManagementPayload, - createHttpManagementPayload as createPayload, -} from "./http-management-payload"; +import { HttpManagementPayload, createHttpManagementPayload as createPayload } from "./http-management-payload"; import { EntityStateResponse } from "./entity-state-response"; import { createAzureFunctionsMetadataGenerator } from "./metadata"; import { @@ -84,8 +81,19 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { this.grpcHttpClientTimeout = config.grpcHttpClientTimeout; } - createCheckStatusResponse(request: HttpRequest, instanceId: string): HttpResponse { - const payload = this.createHttpManagementPayload(request, instanceId); + /** + * Builds a 202 HTTP response whose body and `Location` header expose the orchestration's + * management URLs (classic Durable Functions v3 `createCheckStatusResponse`). + * + * @param request - The incoming HTTP request, or `undefined` to build the URLs from the client + * binding's `baseUrl` (v3 accepted an undefined request and fell back to the binding). + * @param instanceId - The orchestration instance to build management URLs for. + */ + createCheckStatusResponse(request: HttpRequest | undefined, instanceId: string): HttpResponse { + const payload = + request === undefined + ? this.createHttpManagementPayload(instanceId) + : this.createHttpManagementPayload(request, instanceId); return new HttpResponse({ status: 202, @@ -109,10 +117,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { */ createHttpManagementPayload(instanceId: string): HttpManagementPayload; createHttpManagementPayload(request: HttpRequest, instanceId: string): HttpManagementPayload; - createHttpManagementPayload( - requestOrInstanceId: HttpRequest | string, - instanceId?: string, - ): HttpManagementPayload { + createHttpManagementPayload(requestOrInstanceId: HttpRequest | string, instanceId?: string): HttpManagementPayload { // Classic Durable Functions (v3) accepted a single positional `instanceId`. Detect that call // style (a lone string argument) and fall back to the client binding's `baseUrl` when building // the payload URLs. @@ -137,10 +142,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * binding's `baseUrl`. * @param instanceId - The orchestration instance to build management URLs for. */ - getClientResponseLinks( - request: HttpRequest | undefined, - instanceId: string, - ): HttpManagementPayload { + getClientResponseLinks(request: HttpRequest | undefined, instanceId: string): HttpManagementPayload { return request === undefined ? this.createHttpManagementPayload(instanceId) : this.createHttpManagementPayload(request, instanceId); @@ -153,11 +155,11 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * @param orchestratorName - The name of the orchestrator to start. * @param options - Optional input and instance ID. * @returns The instance ID of the started orchestration. + * + * @remarks + * Breaking change from v3: the v3 `version` option is not supported and is silently dropped. */ - async startNew( - orchestratorName: string, - options?: { input?: unknown; instanceId?: string }, - ): Promise { + async startNew(orchestratorName: string, options?: { input?: unknown; instanceId?: string }): Promise { return this.scheduleNewOrchestration( orchestratorName, options?.input, @@ -172,6 +174,13 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * @param instanceId - The ID of the orchestration instance to query. * @param options - When `showInput` is `false`, input/output payloads are not fetched. * @returns The instance status, or `undefined` if the instance does not exist. + * + * @remarks + * Breaking changes from v3: + * - The return type is `DurableOrchestrationStatus | undefined` (v3 returned a non-optional value), + * so `(await getStatus(id)).runtimeStatus` must guard against `undefined`. + * - Only `showInput` is honored. The v3 `showHistory` / `showHistoryOutput` options are not + * supported and `history` is never populated. */ async getStatus( instanceId: string, @@ -221,10 +230,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { instanceId: string, waitOptions?: { timeoutInMilliseconds?: number }, ): Promise { - const timeoutSeconds = Math.max( - 1, - Math.ceil((waitOptions?.timeoutInMilliseconds ?? 10000) / 1000), - ); + const timeoutSeconds = Math.max(1, Math.ceil((waitOptions?.timeoutInMilliseconds ?? 10000) / 1000)); try { const state = await this.waitForOrchestrationCompletion(instanceId, true, timeoutSeconds); if (state) { @@ -261,10 +267,7 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * @param entityId - The target entity instance ID. * @param includeState - Whether to include the entity state in the response (default `true`). */ - async readEntityState( - entityId: EntityInstanceId, - includeState = true, - ): Promise> { + async readEntityState(entityId: EntityInstanceId, includeState = true): Promise> { const metadata = await this.getEntity(entityId, includeState); if (!metadata) { return new EntityStateResponse(false); @@ -399,11 +402,7 @@ export function getGrpcHostAddress(rpcBaseUrl: string): string { } } -function getInstanceStatusUrl( - request: HttpRequest | undefined, - instanceId: string, - baseUrl: string, -): string { +function getInstanceStatusUrl(request: HttpRequest | undefined, instanceId: string, baseUrl: string): string { const encodedInstanceId = encodeURIComponent(instanceId); if (request !== undefined) { const requestUrl = new URL(request.url); @@ -473,10 +472,7 @@ function optionalNumber(record: Record, name: string): number | return value; } -function optionalStringRecord( - record: Record, - name: string, -): Record | undefined { +function optionalStringRecord(record: Record, name: string): Record | undefined { const value = record[name]; if (value === undefined || value === null) { return undefined; @@ -486,9 +482,7 @@ function optionalStringRecord( 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.`, - ); + throw new TypeError(`Durable Functions client configuration field ${name}.${key} must be a string.`); } result[key] = entry; } diff --git a/packages/azure-functions-durable/src/entity-context.ts b/packages/azure-functions-durable/src/entity-context.ts index a130488..03950b5 100644 --- a/packages/azure-functions-durable/src/entity-context.ts +++ b/packages/azure-functions-durable/src/entity-context.ts @@ -1,12 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { - EntityFactory, - EntityInstanceId, - ITaskEntity, - TaskEntityOperation, -} from "@microsoft/durabletask-js"; +import { EntityFactory, EntityInstanceId, ITaskEntity, TaskEntityOperation } from "@microsoft/durabletask-js"; /** * Classic Durable Functions (v3) entity context, exposed to migrating entity functions as @@ -94,11 +89,7 @@ export class DurableEntityContext { * @param operationName - The name of the operation to invoke. * @param operationInput - Optional input for the operation. */ - signalEntity( - entityId: EntityInstanceId, - operationName: string, - operationInput?: unknown, - ): void { + signalEntity(entityId: EntityInstanceId, operationName: string, operationInput?: unknown): void { this._operation.context.signalEntity(entityId, operationName, operationInput); } @@ -108,7 +99,13 @@ export class DurableEntityContext { } } -/** The object passed to a classic (v3) entity function; its `df` is the durable entity context. */ +/** + * The object passed to a classic (v3) entity function; its `df` is the durable entity context. + * + * @remarks + * Breaking change from `durable-functions` v3, where the entity context extended `InvocationContext`. + * This context exposes only `df`; entity code that read `InvocationContext` members must be updated. + */ export interface ClassicEntityContext { df: DurableEntityContext; } diff --git a/packages/azure-functions-durable/src/index.ts b/packages/azure-functions-durable/src/index.ts index c7f1ad1..75d7380 100644 --- a/packages/azure-functions-durable/src/index.ts +++ b/packages/azure-functions-durable/src/index.ts @@ -21,18 +21,18 @@ export { createAzureFunctionsMetadataGenerator } from "./metadata"; export { DurableFunctionsWorker } from "./worker"; export { DurableBindingMetadata, addDurableGrpcMetadata } from "./durable-grpc"; export { RetryOptions } from "./retry-options"; +// Re-exported core error so callers can `instanceof`-guard caught orchestration failures, matching +// the classic durable-functions v3 top-level `TaskFailedError` export. Note: v3's `DurableError` / +// `AggregatedError` are intentionally not provided — the core engine surfaces `TaskFailedError` and +// JS-native `AggregateError`. See the package README/CHANGELOG migration notes. +export { TaskFailedError } from "@microsoft/durabletask-js"; export { DurableOrchestrationContext, ClassicOrchestrationContext, ClassicOrchestrator, wrapOrchestrator, } from "./orchestration-context"; -export { - DurableEntityContext, - ClassicEntityContext, - ClassicEntity, - wrapEntity, -} from "./entity-context"; +export { DurableEntityContext, ClassicEntityContext, ClassicEntity, wrapEntity } from "./entity-context"; export { DurableOrchestrationStatus, DurableOrchestrationStatusInit, diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index 631f0c0..9575962 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -6,6 +6,7 @@ import { EntityInstanceId, OrchestrationContext, Task, + TimerTask, TOrchestrator, whenAll, whenAny, @@ -79,23 +80,14 @@ export class DurableOrchestrationContext { } /** Schedules an activity for execution with a retry policy. */ - callActivityWithRetry( - name: string, - retryOptions: RetryOptions, - input?: unknown, - ): Task { + callActivityWithRetry(name: string, retryOptions: RetryOptions, input?: unknown): Task { return this._ctx.callActivity(name, input, { retry: retryOptions.toRetryPolicy(), }); } /** Schedules a sub-orchestrator for execution. */ - callSubOrchestrator( - name: string, - input?: unknown, - instanceId?: string, - version?: string, - ): Task { + callSubOrchestrator(name: string, input?: unknown, instanceId?: string, version?: string): Task { return this._ctx.callSubOrchestrator( name, input, @@ -118,8 +110,13 @@ export class DurableOrchestrationContext { }); } - /** Creates a durable timer that fires at the specified time. */ - createTimer(fireAt: Date | number): Task { + /** + * Creates a durable timer that fires at the specified time. + * + * @returns A cancelable {@link TimerTask}. Call `.cancel()` (and check `.isCanceled`) to support + * the classic durable-timeout pattern where a timer races activity work via `Task.any`. + */ + createTimer(fireAt: Date | number): TimerTask { return this._ctx.createTimer(fireAt); } @@ -157,31 +154,29 @@ export class DurableOrchestrationContext { * throws. This mirrors the Python provider, which raises for the same reason. */ callHttp(_options: unknown): never { - throw new Error( - "callHttp is not supported: the durabletask engine has no durable-HTTP (callHttp) equivalent.", - ); + throw new Error("callHttp is not supported: the durabletask engine has no durable-HTTP (callHttp) equivalent."); } /** Calls an entity operation and waits for its result. */ - callEntity( - entityId: EntityInstanceId, - operationName: string, - operationInput?: unknown, - ): Task { + callEntity(entityId: EntityInstanceId, operationName: string, operationInput?: unknown): Task { return this._ctx.entities.callEntity(entityId, operationName, operationInput); } /** Signals an entity operation (fire-and-forget). */ - signalEntity( - entityId: EntityInstanceId, - operationName: string, - operationInput?: unknown, - ): void { + signalEntity(entityId: EntityInstanceId, operationName: string, operationInput?: unknown): void { this._ctx.entities.signalEntity(entityId, operationName, operationInput); } } -/** The object passed to a classic (v3) orchestrator function; its `df` is the durable context. */ +/** + * The object passed to a classic (v3) orchestrator function; its `df` is the durable context. + * + * @remarks + * Breaking change from `durable-functions` v3: v3's `OrchestrationContext extends InvocationContext`, + * so orchestrators could read `context.invocationId`, `context.functionName`, `context.extraInputs`, + * etc. This context intentionally exposes only `df` plus replay-safe log helpers; orchestrator code + * that touched `InvocationContext` members must be updated. See the package README/CHANGELOG. + */ export interface ClassicOrchestrationContext { df: DurableOrchestrationContext; /** Replay-safe log at info level (suppressed during replay). Alias of {@link info}. */ @@ -210,15 +205,21 @@ export type ClassicOrchestrator = ( * Adapts an orchestrator handler for registration on the core worker. * * @remarks - * Core-native orchestrators declare `(ctx, input)` and are returned unchanged. Classic v3 - * orchestrators declare a single `context` parameter and use `context.df.*`; those are wrapped so - * the core engine drives them while `context.df` forwards to the core {@link OrchestrationContext}. + * Core-native orchestrators are driven directly by the engine and are returned unchanged. Classic v3 + * orchestrators use `context.df.*` and are wrapped so the engine drives them while `context.df` + * forwards to the core {@link OrchestrationContext}. * - * Detection is by arity: only single-parameter functions are treated as classic. Declare your - * core-native orchestrator as `(ctx, input)` so it passes through. + * Detection is by generator kind, mirroring the engine's own gate (it only drives values exposing + * `Symbol.asyncIterator`), not by arity: + * - `async function*` (core-native, e.g. `async function*(ctx) { yield ctx.callActivity(...) }`) is + * passed through — wrapping it would swap in a classic `{ df, log }` context and silently break it. + * - `function*` (classic v3 sync generator) is wrapped; the engine cannot drive a sync generator, so + * the wrapper delegates to it via `yield*`. + * - A plain (non-generator) function is ambiguous, so fall back to arity: a lone `context` parameter + * is treated as classic, `(ctx, input)` as core-native. */ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): TOrchestrator { - if (typeof handler === "function" && handler.length === 1) { + if (typeof handler === "function" && isClassicOrchestrator(handler)) { const classic = handler as ClassicOrchestrator; // The core engine only drives orchestrators whose invocation returns an ASYNC generator // (it gates on Symbol.asyncIterator). Classic v3 orchestrators are SYNC generators, so the @@ -243,7 +244,7 @@ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): trace: (...a) => logger.debug(...toArgs(a)), }; const result = classic(classicCtx); - if (isGenerator(result)) { + if (isDrivableGenerator(result)) { return yield* result; } return result; @@ -253,11 +254,37 @@ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): return handler as TOrchestrator; } -/** @hidden */ -function isGenerator(value: unknown): value is Generator, unknown, unknown> { +/** + * @hidden + * Classifies a handler as a classic v3 orchestrator (must be wrapped) versus a core-native one + * (must pass through). Prefers generator kind over arity so that single-parameter core-native + * `async function*(ctx)` orchestrators are not mis-detected as classic. + */ +function isClassicOrchestrator(handler: TOrchestrator | ClassicOrchestrator): boolean { + const kind = (handler as { constructor?: { name?: string } }).constructor?.name; + if (kind === "AsyncGeneratorFunction") { + return false; // core-native: the engine drives it directly. + } + if (kind === "GeneratorFunction") { + return true; // classic v3: a sync generator the engine can't drive on its own. + } + // Plain/async (non-generator) function: fall back to arity. A lone `context` parameter is the + // classic v3 shape; `(ctx, input)` is core-native. + return handler.length <= 1; +} + +/** + * @hidden + * Accepts both sync and async generators so the wrapper can drive whichever the classic handler + * returns (`yield*` in an async generator delegates to either). + */ +function isDrivableGenerator( + value: unknown, +): value is Generator, unknown, unknown> | AsyncGenerator, unknown, unknown> { return ( value != null && typeof (value as Iterator).next === "function" && - typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === "function" + (typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === "function" || + typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") ); } diff --git a/packages/azure-functions-durable/src/orchestration-status.ts b/packages/azure-functions-durable/src/orchestration-status.ts index 1ce3b76..581a0a1 100644 --- a/packages/azure-functions-durable/src/orchestration-status.ts +++ b/packages/azure-functions-durable/src/orchestration-status.ts @@ -121,7 +121,23 @@ export function toDurableOrchestrationStatus(state: OrchestrationState): Durable }); } -/** @hidden */ +/** + * @hidden + * Deserializes a serialized payload as JSON, tolerating non-JSON strings. + * + * @remarks + * `undefined`/`""` yield `undefined`. Successful orchestrations carry JSON payloads, but a FAILED + * instance's `serializedOutput` is a plain error string (e.g. `"TaskFailedError: Activity task #1 + * failed"`). Parsing that with `JSON.parse` throws a `SyntaxError`, which previously broke enumerating + * all instances (`getStatusAll` → HTTP 400). When parsing fails, return the raw string unchanged. + */ function parseJson(value: string | undefined): unknown { - return value === undefined || value === "" ? undefined : JSON.parse(value); + if (value === undefined || value === "") { + return undefined; + } + try { + return JSON.parse(value); + } catch { + return value; + } } diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index 9a2ebcb..22d4974 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -116,9 +116,7 @@ describe("DurableFunctionsClient", () => { const client = new DurableFunctionsClient(CLIENT_CONFIG); try { - const rewindInstance = jest - .spyOn(client, "rewindInstance") - .mockResolvedValue(undefined); + const rewindInstance = jest.spyOn(client, "rewindInstance").mockResolvedValue(undefined); // Regression: previously threw "rewind is not yet supported by durabletask." await expect(client.rewind("inst-1", "because")).resolves.toBeUndefined(); @@ -196,9 +194,7 @@ describe("DurableFunctionsClient", () => { try { expect(() => - (client.createHttpManagementPayload as unknown as (request: HttpRequest) => unknown)( - request, - ), + (client.createHttpManagementPayload as unknown as (request: HttpRequest) => unknown)(request), ).toThrow(TypeError); } finally { await client.stop(); @@ -213,9 +209,7 @@ describe("DurableFunctionsClient", () => { }); try { - expect(client.getClientResponseLinks(request, "abc")).toEqual( - client.createHttpManagementPayload(request, "abc"), - ); + expect(client.getClientResponseLinks(request, "abc")).toEqual(client.createHttpManagementPayload(request, "abc")); } finally { await client.stop(); } @@ -245,6 +239,30 @@ describe("DurableFunctionsClient", () => { } }); + it("creates a 202 check status response from baseUrl when request is undefined (classic v3)", async () => { + // Regression: v3's createCheckStatusResponse(request, instanceId) accepted an undefined request + // and fell back to the client binding's baseUrl. Passing undefined must not throw on request.url. + const client = new DurableFunctionsClient({ + ...CLIENT_CONFIG, + baseUrl: "https://public.example/runtime/webhooks/durabletask/", + }); + + try { + const response = client.createCheckStatusResponse(undefined, "abc"); + + expect(response.status).toBe(202); + expect(response.headers.get("Location")).toBe( + "https://public.example/runtime/webhooks/durabletask/instances/abc?code=secret&taskHub=functions-taskhub", + ); + const body = JSON.parse(await response.text()); + expect(body.statusQueryGetUri).toBe( + "https://public.example/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")(); diff --git a/packages/azure-functions-durable/test/unit/compat-exports.spec.ts b/packages/azure-functions-durable/test/unit/compat-exports.spec.ts index 918e962..039667e 100644 --- a/packages/azure-functions-durable/test/unit/compat-exports.spec.ts +++ b/packages/azure-functions-durable/test/unit/compat-exports.spec.ts @@ -9,6 +9,7 @@ import type { OrchestrationContext, OrchestrationHandler, } from "../../src"; +import { TaskFailedError } from "../../src"; describe("v3 compatibility type aliases", () => { it("exposes ActivityHandler / OrchestrationHandler / OrchestrationContext", () => { @@ -34,4 +35,11 @@ describe("v3 compatibility type aliases", () => { expect(typeof handler).toBe("function"); expect(client).toBeUndefined(); }); + + it("re-exports the core TaskFailedError as a top-level export for instanceof guards", () => { + // v3 exposed TaskFailedError so callers could `catch (e) { if (e instanceof TaskFailedError) }`. + // The core engine throws its own TaskFailedError; this package must re-export the same class. + expect(typeof TaskFailedError).toBe("function"); + expect(TaskFailedError.prototype).toBeInstanceOf(Error); + }); }); diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts index 3d1df67..2dfee10 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -2,13 +2,7 @@ // Licensed under the MIT License. import * as durabletask from "@microsoft/durabletask-js"; -import { - EntityInstanceId, - OrchestrationContext, - RetryPolicy, - Task, - TOrchestrator, -} from "@microsoft/durabletask-js"; +import { EntityInstanceId, OrchestrationContext, RetryPolicy, Task, TOrchestrator } from "@microsoft/durabletask-js"; import { ClassicOrchestrationContext, DurableOrchestrationContext, @@ -178,10 +172,38 @@ describe("wrapOrchestrator", () => { expect(wrapOrchestrator(native)).toBe(native); }); + it("returns a single-parameter core-native async-generator orchestrator unchanged and drives its body", async () => { + // Regression (arity-vs-kind): a native orchestrator written as `async function*(ctx)` ignores + // `input`, so it has arity 1 — but it is NOT classic. Detection must be by generator kind, not + // arity: it has to pass through unchanged so the engine drives it directly. Wrapping it would + // inject a classic `{ df, log }` context (no `ctx.callActivity`) and, because async generators + // expose `Symbol.asyncIterator` (not `Symbol.iterator`), the old wrapper would have returned the + // un-iterated generator as the result — the body would never run and nothing would throw. + const native = async function* (ctx: OrchestrationContext): AsyncGenerator, string, unknown> { + const result = (yield ctx.callActivity("a", "INPUT")) as string; + return `native-done:${result}`; + }; + + const wrapped = wrapOrchestrator(native as unknown as TOrchestrator); + // Passthrough: the native async generator is returned unchanged (identity), not wrapped. + expect(wrapped).toBe(native as unknown as TOrchestrator); + + const { ctx, raw } = createFakeCoreContext(); + const gen = wrapped(ctx, "INPUT") as AsyncGenerator; + + const firstYield = await gen.next(); + // Proves the body actually executed: it invoked the CORE ctx.callActivity and yielded its task. + expect(raw.callActivity).toHaveBeenCalledWith("a", "INPUT"); + expect(firstYield.value).toBe("callActivity-task"); + expect(firstYield.done).toBe(false); + + const final = await gen.next("ACTIVITY_RESULT"); + expect(final.done).toBe(true); + expect(final.value).toBe("native-done:ACTIVITY_RESULT"); + }); + it("wraps a single-parameter classic orchestrator and drives it through context.df", async () => { - const classic = function* ( - context: ClassicOrchestrationContext, - ): Generator, string, unknown> { + const classic = function* (context: ClassicOrchestrationContext): Generator, string, unknown> { const input = context.df.getInput(); const first = (yield context.df.callActivity("a", input)) as string; return `done:${first}`; @@ -195,9 +217,7 @@ describe("wrapOrchestrator", () => { // The engine gates on Symbol.asyncIterator: the wrapper MUST return an async generator or the // core executor never drives it (it would complete immediately with the raw return value). const gen = wrapped(ctx, "INPUT") as AsyncGenerator; - expect(typeof (gen as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator]).toBe( - "function", - ); + expect(typeof (gen as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator]).toBe("function"); const firstYield = await gen.next(); expect(raw.callActivity).toHaveBeenCalledWith("a", "INPUT"); @@ -226,9 +246,7 @@ describe("wrapOrchestrator", () => { expect(done.done).toBe(true); expect(done.value).toBe("logged"); // The wrapper builds the classic context's log methods from the CORE replay-safe logger. - expect( - (ctx as unknown as { createReplaySafeLogger: jest.Mock }).createReplaySafeLogger, - ).toHaveBeenCalledTimes(1); + expect((ctx as unknown as { createReplaySafeLogger: jest.Mock }).createReplaySafeLogger).toHaveBeenCalledTimes(1); expect(replaySafeLogger.info).toHaveBeenCalledWith("hi"); expect(replaySafeLogger.error).toHaveBeenCalledWith("boom"); }); diff --git a/packages/azure-functions-durable/test/unit/orchestration-status.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-status.spec.ts new file mode 100644 index 0000000..66bc70d --- /dev/null +++ b/packages/azure-functions-durable/test/unit/orchestration-status.spec.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { OrchestrationState, OrchestrationStatus } from "@microsoft/durabletask-js"; +import { OrchestrationRuntimeStatus, toDurableOrchestrationStatus } from "../../src/orchestration-status"; + +function stateWithOutput( + serializedOutput?: string, + runtimeStatus: OrchestrationStatus = OrchestrationStatus.FAILED, +): OrchestrationState { + return new OrchestrationState( + "inst-1", + "MyOrchestrator", + runtimeStatus, + new Date("2026-01-01T00:00:00.000Z"), + new Date("2026-01-01T00:00:05.000Z"), + undefined, + serializedOutput, + undefined, + ); +} + +describe("toDurableOrchestrationStatus (parseJson tolerance)", () => { + it("returns a non-JSON FAILED output as the raw string instead of throwing (ListAll regression)", () => { + // A FAILED instance's serializedOutput is a plain error string, not JSON. Parsing it with + // JSON.parse throws a SyntaxError, which previously broke enumerating all instances + // (getStatusAll -> HTTP 400) as soon as any FAILED instance was present. + const status = toDurableOrchestrationStatus(stateWithOutput("TaskFailedError: Activity task #1 failed")); + + expect(status.runtimeStatus).toBe(OrchestrationRuntimeStatus.Failed); + expect(status.output).toBe("TaskFailedError: Activity task #1 failed"); + }); + + it("still parses valid JSON output", () => { + const status = toDurableOrchestrationStatus(stateWithOutput('{"ok":true}', OrchestrationStatus.COMPLETED)); + expect(status.output).toEqual({ ok: true }); + }); + + it("treats undefined and empty-string outputs as undefined", () => { + expect(toDurableOrchestrationStatus(stateWithOutput(undefined)).output).toBeUndefined(); + expect(toDurableOrchestrationStatus(stateWithOutput("")).output).toBeUndefined(); + }); +}); diff --git a/packages/durabletask-js/src/index.ts b/packages/durabletask-js/src/index.ts index 9a1f7ed..1bb5cd3 100644 --- a/packages/durabletask-js/src/index.ts +++ b/packages/durabletask-js/src/index.ts @@ -21,7 +21,12 @@ export { ActivityContext } from "./task/context/activity-context"; // Orchestration types and utilities export { PurgeInstanceCriteria } from "./orchestration/orchestration-purge-criteria"; export { PurgeInstanceOptions } from "./orchestration/orchestration-purge-options"; -export { TerminateInstanceOptions, terminateOptions, isTerminateInstanceOptions, TERMINATE_OPTIONS_SYMBOL } from "./orchestration/orchestration-terminate-options"; +export { + TerminateInstanceOptions, + terminateOptions, + isTerminateInstanceOptions, + TERMINATE_OPTIONS_SYMBOL, +} from "./orchestration/orchestration-terminate-options"; export { OrchestrationStatus } from "./orchestration/enum/orchestration-status.enum"; export { OrchestrationState } from "./orchestration/orchestration-state"; @@ -72,6 +77,7 @@ export { OrchestrationStatus as ProtoOrchestrationStatus } from "./proto/orchest // Failure details export { FailureDetails, TaskFailureDetails } from "./task/failure-details"; +export { TaskFailedError } from "./task/exception/task-failed-error"; // Task utilities export { getName, whenAll, whenAny } from "./task"; @@ -109,16 +115,8 @@ export { TOutput } from "./types/output.type"; export { EntityInstanceId } from "./entities/entity-instance-id"; // Entity types - Client-side types (Step 2) -export { - EntityMetadata, - createEntityMetadata, - createEntityMetadataWithoutState, -} from "./entities/entity-metadata"; -export { - EntityQuery, - normalizeInstanceIdPrefix, - createEntityQuery, -} from "./entities/entity-query"; +export { EntityMetadata, createEntityMetadata, createEntityMetadataWithoutState } from "./entities/entity-metadata"; +export { EntityQuery, normalizeInstanceIdPrefix, createEntityQuery } from "./entities/entity-query"; export { CleanEntityStorageRequest, CleanEntityStorageResult, @@ -138,27 +136,25 @@ export { ITaskEntity, EntityFactory, TaskEntity } from "./entities/task-entity"; export { TaskEntityShim, EntityAction } from "./worker/entity-executor"; // Orchestration entity feature (Step 7) -export { - OrchestrationEntityFeature, - LockHandle, - CriticalSectionInfo, -} from "./entities/orchestration-entity-feature"; +export { OrchestrationEntityFeature, LockHandle, CriticalSectionInfo } from "./entities/orchestration-entity-feature"; // Testing utilities export { InMemoryOrchestrationBackend, TestOrchestrationClient, TestOrchestrationWorker } from "./testing"; export { ParentOrchestrationInstance } from "./types/parent-orchestration-instance.type"; // Logger -export { Logger, ConsoleLogger, NoOpLogger, StructuredLogger, LogEvent, isStructuredLogger, createLogEventHandler } from "./types/logger.type"; +export { + Logger, + ConsoleLogger, + NoOpLogger, + StructuredLogger, + LogEvent, + isStructuredLogger, + createLogEventHandler, +} from "./types/logger.type"; export { ReplaySafeLogger, ReplayContext } from "./types/replay-safe-logger"; // Versioning utilities export { compareVersions } from "./utils/versioning.util"; // Distributed Tracing (OpenTelemetry) -export { - TRACER_NAME, - DurableTaskAttributes, - TaskType, - createSpanName, - createTimerSpanName, -} from "./tracing"; +export { TRACER_NAME, DurableTaskAttributes, TaskType, createSpanName, createTimerSpanName } from "./tracing"; diff --git a/packages/durabletask-js/src/proto/orchestrator_service_pb.d.ts b/packages/durabletask-js/src/proto/orchestrator_service_pb.d.ts index 5723808..59ac1b3 100644 --- a/packages/durabletask-js/src/proto/orchestrator_service_pb.d.ts +++ b/packages/durabletask-js/src/proto/orchestrator_service_pb.d.ts @@ -1790,8 +1790,8 @@ export class OrchestratorRequest extends jspb.Message { static extensions: {[key: number]: jspb.ExtensionFieldInfo}; static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; static serializeBinaryToWriter(message: OrchestratorRequest, writer: jspb.BinaryWriter): void; - static deserializeBinaryFromReader(message: OrchestratorRequest, reader: jspb.BinaryReader): OrchestratorRequest; static deserializeBinary(bytes: Uint8Array): OrchestratorRequest; + static deserializeBinaryFromReader(message: OrchestratorRequest, reader: jspb.BinaryReader): OrchestratorRequest; } export namespace OrchestratorRequest { 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 1a40f82..8499a21 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -333,6 +333,17 @@ export class TaskHubGrpcWorker { await this._executeOrchestratorInternal(req, "", stub as unknown as stubs.TaskHubSidecarServiceClient); if (!stub.orchestratorResponse) { + if (stub.abandoned) { + // Versioning resolved this work item to the abandon path. Abandon has no meaning on the + // single-work-item host path (there is no work-item queue to hand the item back to), so + // surface a distinct, actionable error instead of the generic "no response" one. Construct + // the worker without versioning when using processOrchestratorRequest. + throw new Error( + "Orchestrator work item was abandoned (a version mismatch resolved to the abandon path). " + + "Abandon is not supported on the single-work-item processOrchestratorRequest path; " + + "construct the worker without versioning for this host integration.", + ); + } throw new Error("Orchestrator execution did not produce a response."); } @@ -749,7 +760,11 @@ export class TaskHubGrpcWorker { try { const abandonRequest = new pb.AbandonOrchestrationTaskRequest(); abandonRequest.setCompletiontoken(completionToken); - await callWithMetadata(stub.abandonTaskOrchestratorWorkItem.bind(stub), abandonRequest, this._metadataGenerator); + 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); @@ -1226,6 +1241,8 @@ export class TaskHubGrpcWorker { class CapturingSidecarStub { orchestratorResponse?: pb.OrchestratorResponse; entityResult?: pb.EntityBatchResult; + /** Set when the execution path abandons the work item (e.g. a version mismatch) rather than completing it. */ + abandoned = false; completeOrchestratorTask( request: pb.OrchestratorResponse, @@ -1252,7 +1269,9 @@ class CapturingSidecarStub { ): void { // Abandon is a no-op for the single-work-item host path: the version-mismatch abandon branch // in _executeOrchestratorInternal calls this, but processOrchestratorRequest only surfaces a - // completion response. Matches the Python provider, whose null stub no-ops abandon. + // completion response. Record it so the caller can distinguish an abandoned work item from a + // genuine "no response produced" failure. Matches the Python provider, whose null stub no-ops. + this.abandoned = true; callback(null, new Empty()); } } diff --git a/packages/durabletask-js/test/functions-grpc-support.spec.ts b/packages/durabletask-js/test/functions-grpc-support.spec.ts index 21f6d75..c6a5571 100644 --- a/packages/durabletask-js/test/functions-grpc-support.spec.ts +++ b/packages/durabletask-js/test/functions-grpc-support.spec.ts @@ -1,7 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { OrchestrationContext, TaskEntity, TaskHubGrpcWorker, TOrchestrator } from "../src"; +import { + OrchestrationContext, + TaskEntity, + TaskHubGrpcWorker, + TOrchestrator, + VersionFailureStrategy, + VersionMatchStrategy, +} 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"; @@ -41,6 +48,34 @@ describe("Functions gRPC support surface", () => { expect(completed?.getResult()?.getValue()).toBe('"done"'); }); + it("throws a distinct abandon error when versioning rejects a mismatched orchestration work item", async () => { + // Configure the worker with Strict version matching and the Reject failure strategy. The work + // item below carries no orchestration version, which does not match the worker's "2.0.0", so + // _executeOrchestratorInternal takes the abandon branch. Abandon has no meaning on the + // single-work-item host path (there is no work-item queue to hand it back to), so + // processOrchestratorRequest must surface a distinct, actionable error rather than the generic + // "did not produce a response" one. + const worker = new TaskHubGrpcWorker({ + logger: new NoOpLogger(), + versioning: { + version: "2.0.0", + matchStrategy: VersionMatchStrategy.Strict, + failureStrategy: VersionFailureStrategy.Reject, + }, + }); + const orchestrator: TOrchestrator = async (_ctx: OrchestrationContext) => "done"; + const name = worker.addOrchestrator(orchestrator); + + 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), + ]); + + await expect(worker.processOrchestratorRequest(request.serializeBinary())).rejects.toThrow(/abandoned/i); + }); + 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()); From 8f62a356b03931840dc64382b5138a38dac0d027 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 14 Jul 2026 14:35:19 -0700 Subject: [PATCH 23/26] Refine PR #282 review items 1-5: arity detection, getStatus v3 parity, startNew version - (1) orchestration-context: detect classic vs core-native orchestrators by generator/async kind (async* and async => native; sync generator => classic; plain sync => arity fallback) and drive both sync/async generators; add end-to-end regression tests through the real core executor. - (2) createTimer already returns cancelable TimerTask (type now visible); add type/Task.any cancel test. - (3) doc-only: classic contexts no longer extend InvocationContext (entity ctx is {df}); add replay-nondeterminism rationale to CHANGELOG/README. - (4) createCheckStatusResponse already accepts undefined request with baseUrl fallback (v3-faithful). - (5) getStatus returns non-optional DurableOrchestrationStatus and throws on not-found (v3 parity); showHistory populates core history; showInput maps to fetchPayloads; startNew forwards v3 version option. Docs note gRPC-path boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/azure-functions-durable/CHANGELOG.md | 15 ++- packages/azure-functions-durable/README.md | 11 +- .../azure-functions-durable/src/client.ts | 76 ++++++++--- .../src/orchestration-context.ts | 26 ++-- .../src/orchestration-status.ts | 11 +- .../test/unit/client-query.spec.ts | 12 +- .../test/unit/client.spec.ts | 99 +++++++++++++- .../test/unit/orchestration-context.spec.ts | 127 +++++++++++++++++- 8 files changed, 327 insertions(+), 50 deletions(-) diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index b1c8d48..f0841ad 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -11,13 +11,20 @@ Initial gRPC-consolidated Azure Functions Durable provider, built on `@microsoft ### Breaking changes vs `durable-functions` v3 - Classic orchestration and entity contexts no longer extend `InvocationContext`; they expose only - `df` plus replay-safe log helpers (no `invocationId` / `functionName` / `extraInputs`). + `df` plus replay-safe log helpers (no `invocationId` / `functionName` / `extraInputs`), and the + classic entity context exposes only `{ df }`. Rationale: reading `InvocationContext` members such as + `invocationId` / `extraInputs` inside an orchestrator is replay-nondeterministic and was never + recommended, so they are intentionally not surfaced. - Task result shape follows the core SDK: use `isComplete` / `isFailed` / `getResult()` (v3 used `isCompleted` / `isFaulted` / `result`). `context.df.createTimer(...)` returns a cancelable `TimerTask`, so the timeout-race pattern (`Task.any` then `timer.cancel()`) keeps working. -- `client.getStatus()` returns `DurableOrchestrationStatus | undefined` (was non-optional) and honors - only `showInput`; `showHistory` / `showHistoryOutput` are ignored and `history` is not populated. -- `client.startNew()` drops the v3 `version` option. +- `client.getStatus()` matches the v3 signature: it returns a non-optional `DurableOrchestrationStatus` + and throws when the instance does not exist. `showInput`, `showHistory`, and `showHistoryOutput` are + accepted, with two consolidated-path caveats: `showInput` maps to the core `fetchPayloads` flag (so + `showInput: false` also omits output/custom status, where v3 omitted only the input), and + `showHistory` surfaces core `HistoryEvent`s rather than the classic .NET extension's history shape + (`showHistoryOutput` is accepted but does not strip payloads on the gRPC path). +- `client.startNew()` supports the v3 `version` option (forwarded to the core scheduler). - Removed top-level exports: `DummyOrchestrationContext`, `DummyEntityContext`, `DurableError`, `AggregatedError`, `ManagedIdentityTokenSource`, `TokenSource`. `TaskFailedError` is re-exported from the core SDK and aggregate failures surface as JS-native `AggregateError`. For orchestration diff --git a/packages/azure-functions-durable/README.md b/packages/azure-functions-durable/README.md index d97ef63..ce61024 100644 --- a/packages/azure-functions-durable/README.md +++ b/packages/azure-functions-durable/README.md @@ -27,12 +27,17 @@ changed. See [`CHANGELOG.md`](./CHANGELOG.md) for the full list; the highlights: - **Node.js >= 22** is required (v3 supported Node 18/20). - **Classic contexts no longer extend `InvocationContext`** — only `df` plus replay-safe log helpers - are available (no `invocationId` / `functionName` / `extraInputs`). + are available (no `invocationId` / `functionName` / `extraInputs`; the classic entity context is + just `{ df }`). Reading those `InvocationContext` members inside an orchestrator is + replay-nondeterministic and was never recommended. - **Task result shape follows the core SDK** — use `isComplete` / `isFailed` / `getResult()` instead of v3's `isCompleted` / `isFaulted` / `result`. `context.df.createTimer(...)` still returns a cancelable `TimerTask` for the timeout-race pattern. -- **`client.getStatus()` may return `undefined`** and honors only `showInput` (`showHistory` / - `showHistoryOutput` are ignored); **`client.startNew()` drops the `version` option**. +- **`client.getStatus()` keeps the v3 shape** — it returns a non-optional `DurableOrchestrationStatus` + and throws when the instance is missing. `showInput` / `showHistory` / `showHistoryOutput` are + accepted, but `showInput` also gates output/custom status and `showHistory` returns core + `HistoryEvent`s (not the v3 extension history shape). **`client.startNew()` supports the `version` + option.** - **Some v3 top-level exports were removed** — `DummyOrchestrationContext` / `DummyEntityContext`, `DurableError` / `AggregatedError`, and `ManagedIdentityTokenSource` / `TokenSource`. `TaskFailedError` is re-exported from the core SDK; use the core `TestOrchestrationWorker` / diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index 8670d08..1eaf07f 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -35,6 +35,32 @@ export interface DurableFunctionsClientConfig { export type DurableFunctionsClientInput = string | DurableFunctionsClientConfig; +/** + * Options for {@link DurableFunctionsClient.getStatus} (classic Durable Functions v3 shape). + */ +export interface GetStatusOptions { + /** Include the execution history in {@link DurableOrchestrationStatus.history}. */ + showHistory?: boolean; + /** + * Include input/output payloads in the history entries. Accepted for v3 compatibility; on the + * consolidated gRPC path the core history events always carry their payloads (see + * {@link DurableFunctionsClient.getStatus}). + */ + showHistoryOutput?: boolean; + /** Include the orchestration input/output/custom-status payloads (maps to core `fetchPayloads`). */ + showInput?: boolean; +} + +/** Options for {@link DurableFunctionsClient.startNew} (classic Durable Functions v3 shape). */ +export interface StartNewOptions { + /** JSON-serializable input for the orchestrator. */ + input?: unknown; + /** Instance ID to use; a random GUID is generated when omitted. */ + instanceId?: string; + /** Orchestration version to assign (forwarded to the core scheduler). */ + version?: string; +} + export class DurableFunctionsClient extends TaskHubGrpcClient { public readonly taskHubName: string; public readonly connectionName: string; @@ -153,18 +179,15 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * * @deprecated Use {@link scheduleNewOrchestration} instead. * @param orchestratorName - The name of the orchestrator to start. - * @param options - Optional input and instance ID. + * @param options - Optional input, instance ID, and version. * @returns The instance ID of the started orchestration. - * - * @remarks - * Breaking change from v3: the v3 `version` option is not supported and is silently dropped. */ - async startNew(orchestratorName: string, options?: { input?: unknown; instanceId?: string }): Promise { - return this.scheduleNewOrchestration( - orchestratorName, - options?.input, - options?.instanceId !== undefined ? { instanceId: options.instanceId } : undefined, - ); + async startNew(orchestratorName: string, options?: StartNewOptions): Promise { + const scheduleOptions = + options?.instanceId !== undefined || options?.version !== undefined + ? { instanceId: options?.instanceId, version: options?.version } + : undefined; + return this.scheduleNewOrchestration(orchestratorName, options?.input, scheduleOptions); } /** @@ -172,22 +195,31 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * * @deprecated Use {@link getOrchestrationState} instead. * @param instanceId - The ID of the orchestration instance to query. - * @param options - When `showInput` is `false`, input/output payloads are not fetched. - * @returns The instance status, or `undefined` if the instance does not exist. + * @param options - v3 {@link GetStatusOptions}: `showInput`, `showHistory`, `showHistoryOutput`. + * @returns The instance status. + * @throws If no orchestration instance with the given ID exists (matches v3, which threw on the + * extension's HTTP 404 response). * * @remarks - * Breaking changes from v3: - * - The return type is `DurableOrchestrationStatus | undefined` (v3 returned a non-optional value), - * so `(await getStatus(id)).runtimeStatus` must guard against `undefined`. - * - Only `showInput` is honored. The v3 `showHistory` / `showHistoryOutput` options are not - * supported and `history` is never populated. + * v3 parity and its boundaries on the consolidated gRPC path: + * - Returns a non-optional {@link DurableOrchestrationStatus} and throws when the instance is not + * found, exactly like v3's `DurableClient.getStatus`. + * - `showHistory` populates {@link DurableOrchestrationStatus.history} from the core execution + * history (`getOrchestrationHistory`). The entries are core `HistoryEvent`s, not the classic + * .NET extension's history shape (the gRPC path bypasses the extension's HTTP history formatter). + * - `showHistoryOutput` is accepted for API compatibility but does not strip payloads from the + * history events: core history events always carry their intrinsic input/output. + * - `showInput` maps to the core `fetchPayloads` flag. Because that call fetches input, output, and + * custom status together, `showInput: false` also omits output/custom status (v3 omitted only the + * input). */ - async getStatus( - instanceId: string, - options?: { showInput?: boolean }, - ): Promise { + async getStatus(instanceId: string, options?: GetStatusOptions): Promise { const state = await this.getOrchestrationState(instanceId, options?.showInput ?? true); - return state ? toDurableOrchestrationStatus(state) : undefined; + if (state === undefined) { + throw new Error(`No orchestration instance with ID '${instanceId}' was found.`); + } + const history = options?.showHistory ? await this.getOrchestrationHistory(instanceId) : undefined; + return toDurableOrchestrationStatus(state, history); } /** diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index 9575962..1433910 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -209,14 +209,17 @@ export type ClassicOrchestrator = ( * orchestrators use `context.df.*` and are wrapped so the engine drives them while `context.df` * forwards to the core {@link OrchestrationContext}. * - * Detection is by generator kind, mirroring the engine's own gate (it only drives values exposing - * `Symbol.asyncIterator`), not by arity: + * Detection is by generator/async kind, mirroring the engine's own gate (it only drives values + * exposing `Symbol.asyncIterator`), not by arity: * - `async function*` (core-native, e.g. `async function*(ctx) { yield ctx.callActivity(...) }`) is * passed through — wrapping it would swap in a classic `{ df, log }` context and silently break it. + * - `async function` (core-native non-generator, e.g. `async (ctx, input) => ...`) is passed through; + * the engine awaits it. A classic v3 orchestrator is never a plain async function (it must be a + * generator to `yield` durable tasks), so `async` unambiguously means core-native. * - `function*` (classic v3 sync generator) is wrapped; the engine cannot drive a sync generator, so * the wrapper delegates to it via `yield*`. - * - A plain (non-generator) function is ambiguous, so fall back to arity: a lone `context` parameter - * is treated as classic, `(ctx, input)` as core-native. + * - A plain SYNC (non-generator) function is ambiguous, so fall back to arity: a lone `context` + * parameter is treated as classic, `(ctx, input)` as core-native. */ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): TOrchestrator { if (typeof handler === "function" && isClassicOrchestrator(handler)) { @@ -257,19 +260,22 @@ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): /** * @hidden * Classifies a handler as a classic v3 orchestrator (must be wrapped) versus a core-native one - * (must pass through). Prefers generator kind over arity so that single-parameter core-native - * `async function*(ctx)` orchestrators are not mis-detected as classic. + * (must pass through). Prefers generator/async kind over arity so that single-parameter core-native + * `async function*(ctx)` / `async (ctx) => ...` orchestrators are not mis-detected as classic. */ function isClassicOrchestrator(handler: TOrchestrator | ClassicOrchestrator): boolean { const kind = (handler as { constructor?: { name?: string } }).constructor?.name; - if (kind === "AsyncGeneratorFunction") { - return false; // core-native: the engine drives it directly. + if (kind === "AsyncGeneratorFunction" || kind === "AsyncFunction") { + // Core-native: the engine drives async generators directly and awaits plain async + // orchestrators. A classic v3 orchestrator is never a plain async function — it must be a + // (sync) generator to `yield` durable tasks — so `async` unambiguously means core-native. + return false; } if (kind === "GeneratorFunction") { return true; // classic v3: a sync generator the engine can't drive on its own. } - // Plain/async (non-generator) function: fall back to arity. A lone `context` parameter is the - // classic v3 shape; `(ctx, input)` is core-native. + // Plain SYNC (non-generator) function: kind is ambiguous, so fall back to arity. A lone + // `context` parameter is the classic v3 shape; `(ctx, input)` is core-native. return handler.length <= 1; } diff --git a/packages/azure-functions-durable/src/orchestration-status.ts b/packages/azure-functions-durable/src/orchestration-status.ts index 581a0a1..3264776 100644 --- a/packages/azure-functions-durable/src/orchestration-status.ts +++ b/packages/azure-functions-durable/src/orchestration-status.ts @@ -107,8 +107,16 @@ export function fromOrchestrationRuntimeStatus(status: OrchestrationRuntimeStatu * * Payloads are deserialized with `JSON.parse`, matching the plain-JSON wire contract the core client * and worker use. + * + * @param state - The core orchestration state to map. + * @param history - Optional execution history to attach (see {@link DurableFunctionsClient.getStatus} + * `showHistory`). The entries are core {@link OrchestrationState} history events; the consolidated + * gRPC path surfaces core `HistoryEvent`s rather than the classic .NET extension's history shape. */ -export function toDurableOrchestrationStatus(state: OrchestrationState): DurableOrchestrationStatus { +export function toDurableOrchestrationStatus( + state: OrchestrationState, + history?: unknown[], +): DurableOrchestrationStatus { return new DurableOrchestrationStatus({ name: state.name, instanceId: state.instanceId, @@ -118,6 +126,7 @@ export function toDurableOrchestrationStatus(state: OrchestrationState): Durable output: parseJson(state.serializedOutput), runtimeStatus: toOrchestrationRuntimeStatus(state.runtimeStatus), customStatus: parseJson(state.serializedCustomStatus), + history, }); } diff --git a/packages/azure-functions-durable/test/unit/client-query.spec.ts b/packages/azure-functions-durable/test/unit/client-query.spec.ts index b51a977..d939407 100644 --- a/packages/azure-functions-durable/test/unit/client-query.spec.ts +++ b/packages/azure-functions-durable/test/unit/client-query.spec.ts @@ -56,11 +56,11 @@ describe("DurableFunctionsClient query methods", () => { } }); - it("getStatus returns undefined when the instance does not exist", async () => { + it("getStatus throws when the instance does not exist (v3 not-found behavior)", async () => { const client = new DurableFunctionsClient(CLIENT_CONFIG); try { jest.spyOn(client, "getOrchestrationState").mockResolvedValue(undefined); - await expect(client.getStatus("missing")).resolves.toBeUndefined(); + await expect(client.getStatus("missing")).rejects.toThrow(/No orchestration instance with ID 'missing'/); } finally { await client.stop(); } @@ -96,9 +96,7 @@ describe("DurableFunctionsClient query methods", () => { it("purgeInstanceHistory maps the core purge result", async () => { const client = new DurableFunctionsClient(CLIENT_CONFIG); try { - jest - .spyOn(client, "purgeOrchestration") - .mockResolvedValue({ deletedInstanceCount: 1 } as never); + jest.spyOn(client, "purgeOrchestration").mockResolvedValue({ deletedInstanceCount: 1 } as never); const result = await client.purgeInstanceHistory("abc"); expect(result).toBeInstanceOf(PurgeHistoryResult); @@ -193,9 +191,7 @@ describe("DurableFunctionsClient query methods", () => { const client = new DurableFunctionsClient(CLIENT_CONFIG); const request = new HttpRequest({ method: "GET", url: "http://localhost:7071/api/x" }); try { - jest - .spyOn(client, "waitForOrchestrationCompletion") - .mockRejectedValue(new Error("Timed out")); + jest.spyOn(client, "waitForOrchestrationCompletion").mockRejectedValue(new Error("Timed out")); const response = await client.waitForCompletionOrCreateCheckStatusResponse(request, "abc", { timeoutInMilliseconds: 1000, diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index 22d4974..50472c9 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -2,7 +2,12 @@ // Licensed under the MIT License. import { HttpRequest } from "@azure/functions"; -import { PurgeInstanceCriteria, TaskHubGrpcClient } from "@microsoft/durabletask-js"; +import { + OrchestrationState, + OrchestrationStatus, + PurgeInstanceCriteria, + TaskHubGrpcClient, +} from "@microsoft/durabletask-js"; import { DurableFunctionsClient, getGrpcHostAddress } from "../../src/client"; import { createAzureFunctionsMetadataGenerator } from "../../src/metadata"; import { OrchestrationRuntimeStatus } from "../../src/orchestration-status"; @@ -270,4 +275,96 @@ describe("DurableFunctionsClient", () => { expect(metadata.get("x-user-agent")[0]).toMatch(/^durable-functions\//); expect(metadata.get("requiredQueryStringParameters")).toEqual([]); }); + + describe("getStatus / startNew (v3 parity)", () => { + const makeState = () => + new OrchestrationState( + "inst-1", + "MyOrch", + OrchestrationStatus.COMPLETED, + new Date("2026-01-01T00:00:00.000Z"), + new Date("2026-01-01T00:00:05.000Z"), + JSON.stringify("the-input"), + JSON.stringify("the-output"), + undefined, + undefined, + ); + + it("getStatus returns a non-optional DurableOrchestrationStatus and honors showInput", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + try { + const getState = jest.spyOn(client, "getOrchestrationState").mockResolvedValue(makeState()); + + const status = await client.getStatus("inst-1"); + expect(getState).toHaveBeenCalledWith("inst-1", true); + expect(status.instanceId).toBe("inst-1"); + expect(status.name).toBe("MyOrch"); + expect(status.runtimeStatus).toBe(OrchestrationRuntimeStatus.Completed); + expect(status.input).toBe("the-input"); + expect(status.output).toBe("the-output"); + expect(status.history).toBeUndefined(); + + // showInput maps to the core fetchPayloads flag. + await client.getStatus("inst-1", { showInput: false }); + expect(getState).toHaveBeenLastCalledWith("inst-1", false); + } finally { + await client.stop(); + } + }); + + it("getStatus throws when the instance does not exist (v3 not-found behavior)", async () => { + // v3's DurableClient.getStatus returns a non-optional status and throws on the extension's HTTP + // 404; replicate that instead of returning undefined. + const client = new DurableFunctionsClient(CLIENT_CONFIG); + try { + jest.spyOn(client, "getOrchestrationState").mockResolvedValue(undefined); + await expect(client.getStatus("missing")).rejects.toThrow(/No orchestration instance with ID 'missing'/); + } finally { + await client.stop(); + } + }); + + it("getStatus populates history from the core execution history when showHistory is set", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + try { + jest.spyOn(client, "getOrchestrationState").mockResolvedValue(makeState()); + const events = [{ eventId: 1 }, { eventId: 2 }]; + const getHistory = jest.spyOn(client, "getOrchestrationHistory").mockResolvedValue(events as never); + + const status = await client.getStatus("inst-1", { showHistory: true }); + expect(getHistory).toHaveBeenCalledWith("inst-1"); + expect(status.history).toEqual(events); + + // Without showHistory the core history call is skipped and history stays undefined. + getHistory.mockClear(); + const status2 = await client.getStatus("inst-1"); + expect(getHistory).not.toHaveBeenCalled(); + expect(status2.history).toBeUndefined(); + } finally { + await client.stop(); + } + }); + + it("startNew forwards the version option to the core scheduler", async () => { + const client = new DurableFunctionsClient(CLIENT_CONFIG); + try { + const schedule = jest.spyOn(client, "scheduleNewOrchestration").mockResolvedValue("inst-9"); + + await client.startNew("MyOrch", { input: { a: 1 }, instanceId: "inst-9", version: "2.0.0" }); + expect(schedule).toHaveBeenCalledWith("MyOrch", { a: 1 }, { instanceId: "inst-9", version: "2.0.0" }); + + // version alone (no instanceId) is still forwarded. + schedule.mockClear(); + await client.startNew("MyOrch", { version: "3.0.0" }); + expect(schedule).toHaveBeenCalledWith("MyOrch", undefined, { instanceId: undefined, version: "3.0.0" }); + + // No instanceId/version → no options object (unchanged pre-existing behavior). + schedule.mockClear(); + await client.startNew("MyOrch", { input: "x" }); + expect(schedule).toHaveBeenCalledWith("MyOrch", "x", undefined); + } finally { + await client.stop(); + } + }); + }); }); diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts index 2dfee10..e1d3913 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -2,7 +2,19 @@ // Licensed under the MIT License. import * as durabletask from "@microsoft/durabletask-js"; -import { EntityInstanceId, OrchestrationContext, RetryPolicy, Task, TOrchestrator } from "@microsoft/durabletask-js"; +import { + ActivityContext, + EntityInstanceId, + InMemoryOrchestrationBackend, + OrchestrationContext, + OrchestrationStatus, + RetryPolicy, + Task, + TestOrchestrationClient, + TestOrchestrationWorker, + TimerTask, + TOrchestrator, +} from "@microsoft/durabletask-js"; import { ClassicOrchestrationContext, DurableOrchestrationContext, @@ -162,6 +174,36 @@ describe("DurableOrchestrationContext", () => { whenAnySpy.mockRestore(); } }); + + it("createTimer returns a cancelable TimerTask usable in Task.any (type + forwarding)", () => { + // Regression (#3/#293): createTimer's declared return type must be TimerTask so `.cancel()` and + // `.isCanceled` are visible to TS and a timer is assignable where a Task is expected (Task.any). + // The adapter forwards to core (which owns the runtime cancel semantics); this asserts the type + // is exposed and the call is forwarded. + const { ctx, raw } = createFakeCoreContext(); + const timerStub = { cancel: jest.fn(), isCanceled: false } as unknown as TimerTask; + raw.createTimer.mockReturnValue(timerStub); + const df = new DurableOrchestrationContext(ctx, undefined); + + const timer: TimerTask = df.createTimer(new Date("2030-01-01T00:00:00.000Z")); + const work = df.callActivity("fast", "x"); + + const whenAnySpy = jest.spyOn(durabletask, "whenAny").mockReturnValue("race" as never); + try { + // Compiles only if TimerTask extends Task (so it is accepted in the Task[] passed to Task.any). + const race = df.Task.any([timer, work]); + expect(race).toBe("race"); + expect(whenAnySpy).toHaveBeenCalledWith([timer, work]); + } finally { + whenAnySpy.mockRestore(); + } + + // Classic durable-timeout pattern: cancel the timer once the work wins. + timer.cancel(); + expect(timer.isCanceled).toBe(false); + expect(raw.createTimer).toHaveBeenCalledWith(new Date("2030-01-01T00:00:00.000Z")); + expect(timerStub.cancel).toHaveBeenCalledTimes(1); + }); }); describe("wrapOrchestrator", () => { @@ -251,3 +293,86 @@ describe("wrapOrchestrator", () => { expect(replaySafeLogger.error).toHaveBeenCalledWith("boom"); }); }); + +describe("wrapOrchestrator end-to-end (real core executor)", () => { + // These tests drive wrapped orchestrators through the REAL core executor via the in-memory + // backend (not a hand-driven fake context), which is what andystaples' arity review asked for: + // proof that a native single-arg orchestrator's body actually executes and round-trips an + // activity, rather than being mis-detected as classic and silently returning a garbage value. + const echo = (_ctx: ActivityContext, input: unknown): string => `echo:${String(input)}`; + + it("drives a native single-argument async-generator orchestrator through the executor", async () => { + const backend = new InMemoryOrchestrationBackend(); + const worker = new TestOrchestrationWorker(backend); + worker.addNamedActivity("echo", echo); + + // Native single-arg async generator: arity 1 but NOT classic. It must pass through wrapOrchestrator + // unchanged so the engine drives it; wrapping it would inject a classic { df, log } context. + const native = async function* (ctx: OrchestrationContext): AsyncGenerator, string, unknown> { + const result = (yield ctx.callActivity("echo", "IN")) as string; + return `native-done:${result}`; + }; + worker.addNamedOrchestrator("nativeOrch", wrapOrchestrator(native as unknown as TOrchestrator)); + + await worker.start(); + try { + const client = new TestOrchestrationClient(backend); + const id = await client.scheduleNewOrchestration("nativeOrch"); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state?.runtimeStatus).toBe(OrchestrationStatus.COMPLETED); + // The body ran and round-tripped the activity: output reflects the activity result. + expect(state?.serializedOutput).toBe(JSON.stringify("native-done:echo:IN")); + } finally { + await worker.stop(); + } + }); + + it("drives a wrapped classic single-argument generator orchestrator through the executor", async () => { + const backend = new InMemoryOrchestrationBackend(); + const worker = new TestOrchestrationWorker(backend); + worker.addNamedActivity("echo", echo); + + // Classic v3 sync generator using context.df.*; wrapOrchestrator wraps it so the engine drives it. + const classic = function* (context: ClassicOrchestrationContext): Generator, string, unknown> { + const input = context.df.getInput(); + const result = (yield context.df.callActivity("echo", input)) as string; + return `classic-done:${result}`; + }; + worker.addNamedOrchestrator("classicOrch", wrapOrchestrator(classic)); + + await worker.start(); + try { + const client = new TestOrchestrationClient(backend); + const id = await client.scheduleNewOrchestration("classicOrch", "IN"); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state?.runtimeStatus).toBe(OrchestrationStatus.COMPLETED); + expect(state?.serializedOutput).toBe(JSON.stringify("classic-done:echo:IN")); + } finally { + await worker.stop(); + } + }); + + it("drives a native plain async (non-generator) orchestrator through the executor", async () => { + // `async (ctx) => value` is core-native (the engine awaits it); a classic orchestrator is never a + // plain async function. Detection must send it through unchanged, not to the arity → classic path. + const backend = new InMemoryOrchestrationBackend(); + const worker = new TestOrchestrationWorker(backend); + + const nativeAsync = async (ctx: OrchestrationContext): Promise => `async-done:${ctx.instanceId}`; + worker.addNamedOrchestrator("asyncOrch", wrapOrchestrator(nativeAsync as unknown as TOrchestrator)); + + await worker.start(); + try { + const client = new TestOrchestrationClient(backend); + const id = await client.scheduleNewOrchestration("asyncOrch", undefined, "async-instance"); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state?.runtimeStatus).toBe(OrchestrationStatus.COMPLETED); + expect(state?.serializedOutput).toBe(JSON.stringify("async-done:async-instance")); + } finally { + await worker.stop(); + } + }); +}); From 900064f3aecc7f4f8cf1ad2d897b31273609bc03 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 14 Jul 2026 14:46:18 -0700 Subject: [PATCH 24/26] Refine getStatus/startNew v3 parity (review item 5) - getStatus throws on not-found with a v3-style 'DurableClient error:' message (verified v3 DurableClient.getStatus throws on the extension's HTTP 404; returns non-optional DurableOrchestrationStatus). - showInput now gates only the top-level input (payloads always fetched); output/customStatus are always returned, matching v3. - showHistoryOutput now strips input/result payloads from history entries when falsy and keeps them when true. history stays core HistoryEvent[] (v3 types history as Array). - startNew forwards the v3 version option (already wired to core scheduleNewOrchestration). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/azure-functions-durable/CHANGELOG.md | 10 ++-- packages/azure-functions-durable/README.md | 8 +-- .../azure-functions-durable/src/client.ts | 57 +++++++++++++------ .../src/orchestration-status.ts | 9 ++- .../test/unit/client.spec.ts | 30 +++++++--- 5 files changed, 76 insertions(+), 38 deletions(-) diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index f0841ad..89fb904 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -19,11 +19,11 @@ Initial gRPC-consolidated Azure Functions Durable provider, built on `@microsoft `isCompleted` / `isFaulted` / `result`). `context.df.createTimer(...)` returns a cancelable `TimerTask`, so the timeout-race pattern (`Task.any` then `timer.cancel()`) keeps working. - `client.getStatus()` matches the v3 signature: it returns a non-optional `DurableOrchestrationStatus` - and throws when the instance does not exist. `showInput`, `showHistory`, and `showHistoryOutput` are - accepted, with two consolidated-path caveats: `showInput` maps to the core `fetchPayloads` flag (so - `showInput: false` also omits output/custom status, where v3 omitted only the input), and - `showHistory` surfaces core `HistoryEvent`s rather than the classic .NET extension's history shape - (`showHistoryOutput` is accepted but does not strip payloads on the gRPC path). + and throws when the instance does not exist. `showInput` suppresses only the top-level input (output + and custom status are always returned, as in v3); `showHistory` populates `history`, and + `showHistoryOutput` toggles whether those entries keep their input/result payloads. One + consolidated-path note: `history` entries are core `HistoryEvent`s (v3 types `history` as + `Array`), not the classic .NET extension's history serialization. - `client.startNew()` supports the v3 `version` option (forwarded to the core scheduler). - Removed top-level exports: `DummyOrchestrationContext`, `DummyEntityContext`, `DurableError`, `AggregatedError`, `ManagedIdentityTokenSource`, `TokenSource`. `TaskFailedError` is re-exported diff --git a/packages/azure-functions-durable/README.md b/packages/azure-functions-durable/README.md index ce61024..0084b5b 100644 --- a/packages/azure-functions-durable/README.md +++ b/packages/azure-functions-durable/README.md @@ -34,10 +34,10 @@ changed. See [`CHANGELOG.md`](./CHANGELOG.md) for the full list; the highlights: of v3's `isCompleted` / `isFaulted` / `result`. `context.df.createTimer(...)` still returns a cancelable `TimerTask` for the timeout-race pattern. - **`client.getStatus()` keeps the v3 shape** — it returns a non-optional `DurableOrchestrationStatus` - and throws when the instance is missing. `showInput` / `showHistory` / `showHistoryOutput` are - accepted, but `showInput` also gates output/custom status and `showHistory` returns core - `HistoryEvent`s (not the v3 extension history shape). **`client.startNew()` supports the `version` - option.** + and throws when the instance is missing. `showInput` suppresses only the top-level input, + `showHistory` populates `history`, and `showHistoryOutput` toggles the per-entry input/result + payloads; `history` entries are core `HistoryEvent`s (v3 types `history` as `Array`). + **`client.startNew()` supports the `version` option.** - **Some v3 top-level exports were removed** — `DummyOrchestrationContext` / `DummyEntityContext`, `DurableError` / `AggregatedError`, and `ManagedIdentityTokenSource` / `TokenSource`. `TaskFailedError` is re-exported from the core SDK; use the core `TestOrchestrationWorker` / diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index 1eaf07f..7828c3b 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -42,9 +42,8 @@ export interface GetStatusOptions { /** Include the execution history in {@link DurableOrchestrationStatus.history}. */ showHistory?: boolean; /** - * Include input/output payloads in the history entries. Accepted for v3 compatibility; on the - * consolidated gRPC path the core history events always carry their payloads (see - * {@link DurableFunctionsClient.getStatus}). + * Include the input/result payloads on the history entries. When omitted or `false`, those + * payloads are stripped from each entry (see {@link DurableFunctionsClient.getStatus}). */ showHistoryOutput?: boolean; /** Include the orchestration input/output/custom-status payloads (maps to core `fetchPayloads`). */ @@ -61,6 +60,21 @@ export interface StartNewOptions { version?: string; } +/** + * @hidden + * Removes the input/result payload fields from a core history event, honoring v3's + * `showHistoryOutput: false`. Returns a shallow copy so the source event is left untouched. + */ +function stripHistoryEventOutput(event: unknown): unknown { + if (event !== null && typeof event === "object") { + const clone: Record = { ...(event as Record) }; + delete clone.input; + delete clone.result; + return clone; + } + return event; +} + export class DurableFunctionsClient extends TaskHubGrpcClient { public readonly taskHubName: string; public readonly connectionName: string; @@ -197,29 +211,36 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { * @param instanceId - The ID of the orchestration instance to query. * @param options - v3 {@link GetStatusOptions}: `showInput`, `showHistory`, `showHistoryOutput`. * @returns The instance status. - * @throws If no orchestration instance with the given ID exists (matches v3, which threw on the - * extension's HTTP 404 response). + * @throws If no orchestration instance with the given ID exists. This matches v3's + * `DurableClient.getStatus`, which throws on the extension's HTTP 404 not-found response. * * @remarks - * v3 parity and its boundaries on the consolidated gRPC path: + * v3 parity, verified against `azure-functions-durable-js`'s `DurableClient.getStatus`: * - Returns a non-optional {@link DurableOrchestrationStatus} and throws when the instance is not - * found, exactly like v3's `DurableClient.getStatus`. + * found, exactly like v3 (its `case 404` branch throws). + * - `showInput` gates only the top-level `input`: v3 keeps output/custom status regardless, so the + * payloads are always fetched and only `input` is suppressed when `showInput === false`. * - `showHistory` populates {@link DurableOrchestrationStatus.history} from the core execution - * history (`getOrchestrationHistory`). The entries are core `HistoryEvent`s, not the classic - * .NET extension's history shape (the gRPC path bypasses the extension's HTTP history formatter). - * - `showHistoryOutput` is accepted for API compatibility but does not strip payloads from the - * history events: core history events always carry their intrinsic input/output. - * - `showInput` maps to the core `fetchPayloads` flag. Because that call fetches input, output, and - * custom status together, `showInput: false` also omits output/custom status (v3 omitted only the - * input). + * history (`getOrchestrationHistory`). v3 types `history` as `Array`, so the entries are + * core `HistoryEvent`s. On the consolidated gRPC path these are the core event shape, not the + * classic .NET extension's history serialization (the gRPC path bypasses that HTTP formatter). + * - `showHistoryOutput` controls whether the history entries carry their input/result payloads: + * when falsy, those fields are stripped from each event; when `true`, they are kept. */ async getStatus(instanceId: string, options?: GetStatusOptions): Promise { - const state = await this.getOrchestrationState(instanceId, options?.showInput ?? true); + const state = await this.getOrchestrationState(instanceId, true); if (state === undefined) { - throw new Error(`No orchestration instance with ID '${instanceId}' was found.`); + throw new Error( + `DurableClient error: No orchestration instance with ID '${instanceId}' was found. ` + + `This usually means no data is associated with the provided instanceId.`, + ); + } + let history: unknown[] | undefined; + if (options?.showHistory) { + const events = await this.getOrchestrationHistory(instanceId); + history = options.showHistoryOutput ? events : events.map(stripHistoryEventOutput); } - const history = options?.showHistory ? await this.getOrchestrationHistory(instanceId) : undefined; - return toDurableOrchestrationStatus(state, history); + return toDurableOrchestrationStatus(state, history, options?.showInput ?? true); } /** diff --git a/packages/azure-functions-durable/src/orchestration-status.ts b/packages/azure-functions-durable/src/orchestration-status.ts index 3264776..aa800ff 100644 --- a/packages/azure-functions-durable/src/orchestration-status.ts +++ b/packages/azure-functions-durable/src/orchestration-status.ts @@ -110,19 +110,22 @@ export function fromOrchestrationRuntimeStatus(status: OrchestrationRuntimeStatu * * @param state - The core orchestration state to map. * @param history - Optional execution history to attach (see {@link DurableFunctionsClient.getStatus} - * `showHistory`). The entries are core {@link OrchestrationState} history events; the consolidated - * gRPC path surfaces core `HistoryEvent`s rather than the classic .NET extension's history shape. + * `showHistory`). The entries are core `HistoryEvent`s; the consolidated gRPC path surfaces those + * rather than the classic .NET extension's history shape (v3 types `history` as `Array`). + * @param includeInput - When `false`, the top-level `input` is omitted (maps to v3's `showInput`, + * which suppresses only the input while still returning output/custom status). Defaults to `true`. */ export function toDurableOrchestrationStatus( state: OrchestrationState, history?: unknown[], + includeInput = true, ): DurableOrchestrationStatus { return new DurableOrchestrationStatus({ name: state.name, instanceId: state.instanceId, createdTime: state.createdAt, lastUpdatedTime: state.lastUpdatedAt, - input: parseJson(state.serializedInput), + input: includeInput ? parseJson(state.serializedInput) : undefined, output: parseJson(state.serializedOutput), runtimeStatus: toOrchestrationRuntimeStatus(state.runtimeStatus), customStatus: parseJson(state.serializedCustomStatus), diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index 50472c9..95e4b74 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -290,12 +290,13 @@ describe("DurableFunctionsClient", () => { undefined, ); - it("getStatus returns a non-optional DurableOrchestrationStatus and honors showInput", async () => { + it("getStatus returns a non-optional DurableOrchestrationStatus and gates only input via showInput", async () => { const client = new DurableFunctionsClient(CLIENT_CONFIG); try { const getState = jest.spyOn(client, "getOrchestrationState").mockResolvedValue(makeState()); const status = await client.getStatus("inst-1"); + // Payloads are always fetched; showInput only gates the top-level input (v3 keeps output). expect(getState).toHaveBeenCalledWith("inst-1", true); expect(status.instanceId).toBe("inst-1"); expect(status.name).toBe("MyOrch"); @@ -304,9 +305,11 @@ describe("DurableFunctionsClient", () => { expect(status.output).toBe("the-output"); expect(status.history).toBeUndefined(); - // showInput maps to the core fetchPayloads flag. - await client.getStatus("inst-1", { showInput: false }); - expect(getState).toHaveBeenLastCalledWith("inst-1", false); + // showInput: false suppresses only input; output/custom status still come back. + const suppressed = await client.getStatus("inst-1", { showInput: false }); + expect(getState).toHaveBeenLastCalledWith("inst-1", true); + expect(suppressed.input).toBeUndefined(); + expect(suppressed.output).toBe("the-output"); } finally { await client.stop(); } @@ -324,16 +327,27 @@ describe("DurableFunctionsClient", () => { } }); - it("getStatus populates history from the core execution history when showHistory is set", async () => { + it("getStatus populates history and honors showHistoryOutput", async () => { const client = new DurableFunctionsClient(CLIENT_CONFIG); try { jest.spyOn(client, "getOrchestrationState").mockResolvedValue(makeState()); - const events = [{ eventId: 1 }, { eventId: 2 }]; + const events = [ + { eventId: 1, type: "TaskCompleted", input: "in", result: "out" }, + { eventId: 2, type: "TimerFired" }, + ]; const getHistory = jest.spyOn(client, "getOrchestrationHistory").mockResolvedValue(events as never); - const status = await client.getStatus("inst-1", { showHistory: true }); + // showHistoryOutput: true keeps the input/result payloads on each entry. + const withOutput = await client.getStatus("inst-1", { showHistory: true, showHistoryOutput: true }); expect(getHistory).toHaveBeenCalledWith("inst-1"); - expect(status.history).toEqual(events); + expect(withOutput.history).toEqual(events); + + // showHistory without showHistoryOutput strips input/result from each entry. + const stripped = await client.getStatus("inst-1", { showHistory: true }); + expect(stripped.history).toEqual([ + { eventId: 1, type: "TaskCompleted" }, + { eventId: 2, type: "TimerFired" }, + ]); // Without showHistory the core history call is skipped and history stays undefined. getHistory.mockClear(); From 0dcc47cab83bc826f0592e86b21a16ec413db7b3 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 14 Jul 2026 14:50:34 -0700 Subject: [PATCH 25/26] Align getStatus not-found message with v3 wording (review item 5) v3 DurableClient.getStatus throws on the extension's HTTP 404 with 'This usually means we could not find any data associated with the instanceId provided: .'. Mirror that sentence verbatim (only the HTTP-404-specific first sentence is replaced, since the gRPC path has no HTTP 404). Update the not-found tests to assert the v3 message. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/azure-functions-durable/src/client.ts | 7 +++++-- .../azure-functions-durable/test/unit/client-query.spec.ts | 4 +++- packages/azure-functions-durable/test/unit/client.spec.ts | 4 +++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/azure-functions-durable/src/client.ts b/packages/azure-functions-durable/src/client.ts index 7828c3b..b8ff37f 100644 --- a/packages/azure-functions-durable/src/client.ts +++ b/packages/azure-functions-durable/src/client.ts @@ -230,9 +230,12 @@ export class DurableFunctionsClient extends TaskHubGrpcClient { async getStatus(instanceId: string, options?: GetStatusOptions): Promise { const state = await this.getOrchestrationState(instanceId, true); if (state === undefined) { + // Mirror v3's DurableClient.getStatus not-found behavior: it throws (its case-404 branch) with + // this message shape. The gRPC path has no HTTP 404, so only the extension-specific first + // sentence is replaced; the instanceId-bearing sentence is kept verbatim. throw new Error( - `DurableClient error: No orchestration instance with ID '${instanceId}' was found. ` + - `This usually means no data is associated with the provided instanceId.`, + `DurableClient error: No orchestration instance found. ` + + `This usually means we could not find any data associated with the instanceId provided: ${instanceId}.`, ); } let history: unknown[] | undefined; diff --git a/packages/azure-functions-durable/test/unit/client-query.spec.ts b/packages/azure-functions-durable/test/unit/client-query.spec.ts index d939407..445d545 100644 --- a/packages/azure-functions-durable/test/unit/client-query.spec.ts +++ b/packages/azure-functions-durable/test/unit/client-query.spec.ts @@ -60,7 +60,9 @@ describe("DurableFunctionsClient query methods", () => { const client = new DurableFunctionsClient(CLIENT_CONFIG); try { jest.spyOn(client, "getOrchestrationState").mockResolvedValue(undefined); - await expect(client.getStatus("missing")).rejects.toThrow(/No orchestration instance with ID 'missing'/); + await expect(client.getStatus("missing")).rejects.toThrow( + /could not find any data associated with the instanceId provided: missing/, + ); } finally { await client.stop(); } diff --git a/packages/azure-functions-durable/test/unit/client.spec.ts b/packages/azure-functions-durable/test/unit/client.spec.ts index 95e4b74..656f341 100644 --- a/packages/azure-functions-durable/test/unit/client.spec.ts +++ b/packages/azure-functions-durable/test/unit/client.spec.ts @@ -321,7 +321,9 @@ describe("DurableFunctionsClient", () => { const client = new DurableFunctionsClient(CLIENT_CONFIG); try { jest.spyOn(client, "getOrchestrationState").mockResolvedValue(undefined); - await expect(client.getStatus("missing")).rejects.toThrow(/No orchestration instance with ID 'missing'/); + await expect(client.getStatus("missing")).rejects.toThrow( + /could not find any data associated with the instanceId provided: missing/, + ); } finally { await client.stop(); } From fa2a17cc6d8d206f10a3caf54422fe41850dcc82 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 14 Jul 2026 17:00:41 -0700 Subject: [PATCH 26/26] Correct inaccurate abandon comment re: Python provider The single-work-item abandon path is JS-specific for the Azure Functions host integration. durabletask-python has no equivalent single-work-item helper, and its worker-loop abandon hands the item back over a real sidecar stub rather than no-opping, so the prior 'matches the Python provider, whose null stub no-ops' note was inaccurate. Comment-only change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/durabletask-js/src/worker/task-hub-grpc-worker.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts index 8499a21..e90eb0e 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -1270,7 +1270,9 @@ class CapturingSidecarStub { // Abandon is a no-op for the single-work-item host path: the version-mismatch abandon branch // in _executeOrchestratorInternal calls this, but processOrchestratorRequest only surfaces a // completion response. Record it so the caller can distinguish an abandoned work item from a - // genuine "no response produced" failure. Matches the Python provider, whose null stub no-ops. + // genuine "no response produced" failure. This single-work-item helper is JS-specific for the + // Azure Functions host integration; durabletask-python has no equivalent helper, and its + // worker-loop abandon hands the item back over a real sidecar stub rather than no-opping here. this.abandoned = true; callback(null, new Empty()); }