Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/azure-functions-durable/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/azure-functions-durable/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 64 additions & 6 deletions packages/azure-functions-durable/src/orchestration-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

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`.
Expand Down Expand Up @@ -93,27 +122,56 @@ export class DurableOrchestrationContext {
});
}

/** Schedules a sub-orchestrator for execution. */
callSubOrchestrator<T = unknown>(name: string, input?: unknown, instanceId?: string, version?: string): Task<T> {
/**
* Schedules a sub-orchestrator for execution.
*
* Tags supplied through the options overload are snapshotted when the task is scheduled.
*/
callSubOrchestrator<T = unknown>(name: string, input?: unknown, options?: SubOrchestrationOptions): Task<T>;
callSubOrchestrator<T = unknown>(name: string, input?: unknown, instanceId?: string, version?: string): Task<T>;
callSubOrchestrator<T = unknown>(
name: string,
input?: unknown,
optionsOrInstanceId?: SubOrchestrationOptions | string,
version?: string,
): Task<T> {
return this._ctx.callSubOrchestrator<unknown, T>(
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<T = unknown>(
name: string,
retryOptions: RetryOptions,
input?: unknown,
options?: SubOrchestrationOptions,
): Task<T>;
callSubOrchestratorWithRetry<T = unknown>(
name: string,
retryOptions: RetryOptions,
input?: unknown,
instanceId?: string,
version?: string,
): Task<T>;
callSubOrchestratorWithRetry<T = unknown>(
name: string,
retryOptions: RetryOptions,
input?: unknown,
optionsOrInstanceId?: SubOrchestrationOptions | string,
version?: string,
): Task<T> {
const options = normalizeSubOrchestrationOptions(optionsOrInstanceId, version);
return this._ctx.callSubOrchestrator<unknown, T>(name, input, {
retry: retryOptions.toRetryPolicy(),
instanceId,
version,
...options,
});
}

Expand Down
114 changes: 113 additions & 1 deletion packages/azure-functions-durable/test/unit/worker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<unknown, unknown, unknown> {
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);
Expand Down
13 changes: 8 additions & 5 deletions test/e2e-functions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
73 changes: 73 additions & 0 deletions test/e2e-functions/sub-orchestration-tags.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

async function purge(instanceId: string): Promise<void> {
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<TaggedSubOrchestrationMetadata>()).toEqual({
instanceId: childInstanceId,
name: "TaggedSubOrchestrationChild",
isCompleted: true,
tags: {
environment: "functions-e2e",
empty: "",
},
});
} finally {
await purge(childInstanceId);
await purge(parentInstanceId);
}
}, 120_000);
});
Loading
Loading