diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index 24a0e96a..c1c06dfd 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -2,6 +2,9 @@ ### New +- Add options-object overloads to `callSubOrchestrator()` and `callSubOrchestratorWithRetry()` so + classic orchestrators can attach tags to child orchestration instances. Existing positional + `instanceId` and `version` calls are unchanged. - Add opt-in `callHttp({ retryOptions: new HttpRetryOptions(...) })` failure retries using the core durable activity retry engine, configurable status codes, backoff, attempt limits, and timeout. Defaults match .NET in-process HTTP retries (non-2xx statuses, six-day maximum interval); diff --git a/packages/azure-functions-durable/src/index.ts b/packages/azure-functions-durable/src/index.ts index 319c55d2..2ef05bfa 100644 --- a/packages/azure-functions-durable/src/index.ts +++ b/packages/azure-functions-durable/src/index.ts @@ -39,6 +39,7 @@ export { ClassicOrchestrator, wrapOrchestrator, } from "./orchestration-context"; +export type { SubOrchestrationOptions } from "./orchestration-context"; export { DurableEntityContext, ClassicEntityContext, ClassicEntity, wrapEntity } from "./entity-context"; export { DurableOrchestrationStatus, diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index 4158f1be..fd278be4 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -15,6 +15,35 @@ import { RetryOptions } from "./retry-options"; import { BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME } from "./http/builtin"; import { CallHttpOptions, DurableHttpRequestPayload, DurableHttpResponse } from "./http/models"; +/** + * Options for scheduling a sub-orchestration through the classic Durable Functions context. + */ +export interface SubOrchestrationOptions { + /** The unique ID to use for the sub-orchestration instance. */ + instanceId?: string; + /** The version of the sub-orchestration to execute. */ + version?: string; + /** Tags to associate with the sub-orchestration instance. */ + tags?: Record; +} + +function normalizeSubOrchestrationOptions( + optionsOrInstanceId?: SubOrchestrationOptions | string, + version?: string, +): SubOrchestrationOptions | undefined { + if (optionsOrInstanceId !== null && typeof optionsOrInstanceId === "object") { + return { + instanceId: optionsOrInstanceId.instanceId, + version: optionsOrInstanceId.version, + tags: optionsOrInstanceId.tags === undefined ? undefined : { ...optionsOrInstanceId.tags }, + }; + } + + return optionsOrInstanceId !== undefined || version !== undefined + ? { instanceId: optionsOrInstanceId, version } + : undefined; +} + /** * Classic Durable Functions (v3) orchestration context, exposed to migrating orchestrators as * `context.df`. @@ -93,27 +122,56 @@ export class DurableOrchestrationContext { }); } - /** Schedules a sub-orchestrator for execution. */ - callSubOrchestrator(name: string, input?: unknown, instanceId?: string, version?: string): Task { + /** + * Schedules a sub-orchestrator for execution. + * + * Tags supplied through the options overload are snapshotted when the task is scheduled. + */ + callSubOrchestrator(name: string, input?: unknown, options?: SubOrchestrationOptions): Task; + callSubOrchestrator(name: string, input?: unknown, instanceId?: string, version?: string): Task; + callSubOrchestrator( + name: string, + input?: unknown, + optionsOrInstanceId?: SubOrchestrationOptions | string, + version?: string, + ): Task { return this._ctx.callSubOrchestrator( name, input, - instanceId !== undefined || version !== undefined ? { instanceId, version } : undefined, + normalizeSubOrchestrationOptions(optionsOrInstanceId, version), ); } - /** Schedules a sub-orchestrator for execution with a retry policy. */ + /** + * Schedules a sub-orchestrator for execution with a retry policy. + * + * Tags supplied through the options overload are snapshotted when the task is scheduled and + * preserved by subsequent retry attempts. + */ + callSubOrchestratorWithRetry( + name: string, + retryOptions: RetryOptions, + input?: unknown, + options?: SubOrchestrationOptions, + ): Task; callSubOrchestratorWithRetry( name: string, retryOptions: RetryOptions, input?: unknown, instanceId?: string, version?: string, + ): Task; + callSubOrchestratorWithRetry( + name: string, + retryOptions: RetryOptions, + input?: unknown, + optionsOrInstanceId?: SubOrchestrationOptions | string, + version?: string, ): Task { + const options = normalizeSubOrchestrationOptions(optionsOrInstanceId, version); return this._ctx.callSubOrchestrator(name, input, { retry: retryOptions.toRetryPolicy(), - instanceId, - version, + ...options, }); } diff --git a/packages/azure-functions-durable/test/unit/worker.spec.ts b/packages/azure-functions-durable/test/unit/worker.spec.ts index e0c03b5d..4bcd89c1 100644 --- a/packages/azure-functions-durable/test/unit/worker.spec.ts +++ b/packages/azure-functions-durable/test/unit/worker.spec.ts @@ -6,7 +6,7 @@ import { NoOpLogger } from "@microsoft/durabletask-js"; import { ClassicOrchestrationContext, wrapOrchestrator } from "../../src/orchestration-context"; import * as pb from "../../../durabletask-js/src/proto/orchestrator_service_pb"; import * as ph from "../../../durabletask-js/src/utils/pb-helper.util"; -import { TaskCancelledError } from "../../src"; +import { RetryOptions, TaskCancelledError, type SubOrchestrationOptions } from "../../src"; const DAY = 24 * 60 * 60 * 1000; const START = new Date("2026-01-01T00:00:00Z"); @@ -66,6 +66,118 @@ describe("DurableFunctionsWorker", () => { } }); + it("forwards tagged sub-orchestration options without changing positional calls", async () => { + const worker = new DurableFunctionsWorker({ + logger: new NoOpLogger(), + versioning: { defaultVersion: "child-default" }, + }); + worker.addNamedOrchestrator( + "parent", + wrapOrchestrator(function* (ctx: ClassicOrchestrationContext) { + const tags = { empty: "", owner: "durable" }; + ctx.df.callSubOrchestrator("DefaultChild"); + ctx.df.callSubOrchestrator("LegacyChild", undefined, "legacy-id", "legacy-version"); + const tagged = ctx.df.callSubOrchestrator( + "TaggedChild", + { value: 42 }, + { + instanceId: "tagged-id", + version: "tagged-version", + tags, + }, + ); + tags.owner = "mutated"; + ctx.df.callSubOrchestrator("EmptyTagsChild", undefined, { tags: {} }); + yield tagged; + }), + ); + const request = new pb.OrchestratorRequest() + .setInstanceid("parent-id") + .setNeweventsList([ph.newOrchestratorStartedEvent(START), ph.newExecutionStartedEvent("parent", "parent-id")]); + + const response = await worker.handleOrchestratorRequest(Buffer.from(request.serializeBinary()).toString("base64")); + const actions = pb.OrchestratorResponse.deserializeBinary(Buffer.from(response, "base64")).getActionsList(); + const children = actions.map((action) => action.getCreatesuborchestration()!); + + expect(children.map((child) => child.getName())).toEqual([ + "DefaultChild", + "LegacyChild", + "TaggedChild", + "EmptyTagsChild", + ]); + expect(children[0].getVersion()?.getValue()).toBe("child-default"); + expect(children[0].getTagsMap().getLength()).toBe(0); + expect(children[1].getInstanceid()).toBe("legacy-id"); + expect(children[1].getVersion()?.getValue()).toBe("legacy-version"); + expect(children[1].getTagsMap().getLength()).toBe(0); + expect(children[2].getInstanceid()).toBe("tagged-id"); + expect(children[2].getVersion()?.getValue()).toBe("tagged-version"); + expect(children[2].getTagsMap().toObject()).toEqual([ + ["empty", ""], + ["owner", "durable"], + ]); + expect(children[3].getTagsMap().getLength()).toBe(0); + }); + + it("preserves facade tags when retrying a sub-orchestration", async () => { + const worker = new DurableFunctionsWorker({ logger: new NoOpLogger() }); + worker.addNamedOrchestrator( + "retry-parent", + wrapOrchestrator(function* (ctx: ClassicOrchestrationContext): Generator { + const options: SubOrchestrationOptions = { + instanceId: "retry-child-id", + version: "retry-child-version", + tags: { empty: "", owner: "durable" }, + }; + return yield ctx.df.callSubOrchestratorWithRetry("RetryChild", new RetryOptions(1000, 2), undefined, options); + }), + ); + const startEvents = [ + ph.newOrchestratorStartedEvent(START), + ph.newExecutionStartedEvent("retry-parent", "parent-id"), + ]; + const execute = async (pastEvents: pb.HistoryEvent[], newEvents: pb.HistoryEvent[]) => { + const request = new pb.OrchestratorRequest() + .setInstanceid("parent-id") + .setPasteventsList(pastEvents) + .setNeweventsList(newEvents); + const response = await worker.handleOrchestratorRequest( + Buffer.from(request.serializeBinary()).toString("base64"), + ); + return pb.OrchestratorResponse.deserializeBinary(Buffer.from(response, "base64")).getActionsList(); + }; + + const initialActions = await execute([], startEvents); + const initialAction = initialActions[0]; + const initialChild = initialAction.getCreatesuborchestration()!; + expect(initialChild.getTagsMap().toObject()).toEqual([ + ["empty", ""], + ["owner", "durable"], + ]); + + const created = ph.newSubOrchestrationCreatedEvent( + initialAction.getId(), + initialChild.getName(), + initialChild.getInstanceid(), + initialChild.getInput()?.getValue(), + ); + const failed = ph.newSubOrchestrationFailedEvent(initialAction.getId(), new Error("transient")); + const timerActions = await execute([...startEvents, created], [failed]); + const timerAction = timerActions[0]; + const fireAt = timerAction.getCreatetimer()!.getFireat()!.toDate(); + const timerCreated = ph.newTimerCreatedEvent(timerAction.getId(), fireAt); + const timerFired = ph.newTimerFiredEvent(timerAction.getId(), fireAt); + + const retryActions = await execute([...startEvents, created, failed, timerCreated], [timerFired]); + const retriedChild = retryActions[0].getCreatesuborchestration()!; + expect(retriedChild.getInstanceid()).toBe("retry-child-id"); + expect(retriedChild.getVersion()?.getValue()).toBe("retry-child-version"); + expect(retriedChild.getTagsMap().toObject()).toEqual([ + ["empty", ""], + ["owner", "durable"], + ]); + }); + it("inherits the core default without a Functions timer configuration surface", async () => { const options = { logger: new NoOpLogger(), maximumTimerIntervalMs: null }; const worker = new DurableFunctionsWorker(options); diff --git a/test/e2e-functions/README.md b/test/e2e-functions/README.md index f2a393ae..67b815a0 100644 --- a/test/e2e-functions/README.md +++ b/test/e2e-functions/README.md @@ -67,6 +67,9 @@ tests are `it.skip` with a comment citing the same reason. | `purge.spec.ts` | `PurgeInstancesTests` | purge by time / by entity id (purge-without-start-time #644 skipped) | | `class-based-entity.spec.ts` | `ClassBasedEntityTests` | class-based entity state | +`sub-orchestration-tags.spec.ts` is an SDK-specific regression that verifies tagged +child metadata through the Functions host, Preview extension, and AzureStorage provider. + ### Node bug annotations honored - [#642](https://github.com/Azure/azure-functions-durable-js/issues/642) — entity @@ -83,11 +86,11 @@ tests are `it.skip` with a comment citing the same reason. - Node swallows suspend/resume/terminate of a **terminal** instance and returns success (`200`); the specs assert that behavior. -The only test-app deviation from `BasicNode` is the `file:` dependency wiring -(see `test-app/package.json`); the Durable function code is otherwise kept close -to the source app. Host readiness is detected by polling `/admin/host/status` -for `state == "Running"`, the same way the extension's C# `FunctionAppProcess` -fixture does. +The test app keeps the ported functions close to `BasicNode`, with two intentional +additions: the `file:` dependency wiring (see `test-app/package.json`) and the +feature-specific sub-orchestration-tags regression route. Host readiness is detected +by polling `/admin/host/status` for `state == "Running"`, the same way the extension's +C# `FunctionAppProcess` fixture does. ## Running locally diff --git a/test/e2e-functions/sub-orchestration-tags.spec.ts b/test/e2e-functions/sub-orchestration-tags.spec.ts new file mode 100644 index 00000000..12621105 --- /dev/null +++ b/test/e2e-functions/sub-orchestration-tags.spec.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { randomUUID } from "crypto"; +import { invokeHttpTrigger, parseStatusQueryGetUri, readPreflight, waitForOrchestrationState } from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] sub-orchestration-tags.spec skipped: ${preflight.reason}`); +} + +interface TaggedSubOrchestrationMetadata { + instanceId: string; + name: string; + isCompleted: boolean; + tags: Record; +} + +async function purge(instanceId: string): Promise { + try { + const response = await invokeHttpTrigger( + baseUrl, + "PurgeOrchestrationHistory", + `?instanceId=${encodeURIComponent(instanceId)}`, + ); + if (response.status !== 200) { + console.warn(`[functions-e2e] Failed to purge ${instanceId}: ${response.status} ${response.body}`); + } + } catch (error) { + console.warn(`[functions-e2e] Failed to purge ${instanceId}: ${String(error)}`); + } +} + +describeMaybe("Functions host E2E — sub-orchestration tags (AzureStorage)", () => { + it("persists facade tags on the child orchestration instance", async () => { + const runId = randomUUID(); + const parentInstanceId = `tagged-parent-${runId}`; + const childInstanceId = `tagged-child-${runId}`; + + try { + const start = await invokeHttpTrigger( + baseUrl, + "StartTaggedSubOrchestration", + `?parentInstanceId=${encodeURIComponent(parentInstanceId)}&childInstanceId=${encodeURIComponent(childInstanceId)}`, + ); + expect(start.status).toBe(202); + + await waitForOrchestrationState(parseStatusQueryGetUri(start), "Completed", 60); + + const metadataResponse = await invokeHttpTrigger( + baseUrl, + "GetTaggedSubOrchestrationMetadata", + `?instanceId=${encodeURIComponent(childInstanceId)}`, + ); + expect(metadataResponse.status).toBe(200); + expect(metadataResponse.json()).toEqual({ + instanceId: childInstanceId, + name: "TaggedSubOrchestrationChild", + isCompleted: true, + tags: { + environment: "functions-e2e", + empty: "", + }, + }); + } finally { + await purge(childInstanceId); + await purge(parentInstanceId); + } + }, 120_000); +}); diff --git a/test/e2e-functions/test-app/src/functions/SubOrchestrationTags.ts b/test/e2e-functions/test-app/src/functions/SubOrchestrationTags.ts new file mode 100644 index 00000000..1349e48d --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/SubOrchestrationTags.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { app, HttpHandler, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import * as df from 'durable-functions'; +import { OrchestrationContext, OrchestrationHandler } from 'durable-functions'; + +const childName = 'TaggedSubOrchestrationChild'; +const parentName = 'TaggedSubOrchestrationParent'; + +const TaggedSubOrchestrationChild: OrchestrationHandler = function* (context: OrchestrationContext) { + yield context.df.Task.all([]); + return 'completed'; +}; +df.app.orchestration(childName, TaggedSubOrchestrationChild); + +const TaggedSubOrchestrationParent: OrchestrationHandler = function* (context: OrchestrationContext) { + const input = context.df.getInput<{ childInstanceId: string }>(); + return yield context.df.callSubOrchestrator(childName, undefined, { + instanceId: input.childInstanceId, + tags: { + environment: 'functions-e2e', + empty: '', + }, + }); +}; +df.app.orchestration(parentName, TaggedSubOrchestrationParent); + +const StartTaggedSubOrchestration: HttpHandler = async ( + request: HttpRequest, + context: InvocationContext, +): Promise => { + const parentInstanceId = request.query.get('parentInstanceId'); + const childInstanceId = request.query.get('childInstanceId'); + if (!parentInstanceId || !childInstanceId) { + return { + status: 400, + body: 'parentInstanceId and childInstanceId are required', + }; + } + + const client = df.getClient(context); + const instanceId = await client.startNew(parentName, { + instanceId: parentInstanceId, + input: { childInstanceId }, + }); + return client.createCheckStatusResponse(request, instanceId); +}; + +app.http('StartTaggedSubOrchestration', { + route: 'StartTaggedSubOrchestration', + extraInputs: [df.input.durableClient()], + handler: StartTaggedSubOrchestration, +}); + +const GetTaggedSubOrchestrationMetadata: HttpHandler = async ( + request: HttpRequest, + context: InvocationContext, +): Promise => { + const instanceId = request.query.get('instanceId'); + if (!instanceId) { + return { + status: 400, + body: 'instanceId is required', + }; + } + + const state = await df.getClient(context).waitForOrchestrationCompletion(instanceId, true, 60); + if (!state) { + return { + status: 404, + body: `Orchestration '${instanceId}' was not found`, + }; + } + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + instanceId: state.instanceId, + name: state.name, + isCompleted: state.isCompleted, + tags: state.tags, + }), + }; +}; + +app.http('GetTaggedSubOrchestrationMetadata', { + route: 'GetTaggedSubOrchestrationMetadata', + extraInputs: [df.input.durableClient()], + handler: GetTaggedSubOrchestrationMetadata, +});