From b680614cee4f9cd50b45a2ed446fd71cc0b27bcd Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Sat, 4 Oct 2025 19:54:12 -0700 Subject: [PATCH] Allow overriding orchestration version when starting orchestrations via APIs --- samples-ts/functions/httpStart.ts | 22 +++- samples-ts/functions/orchestrationVersion.ts | 13 ++- samples-ts/host.json | 2 +- src/actions/CallSubOrchestratorAction.ts | 3 +- .../CallSubOrchestratorWithRetryAction.ts | 3 +- src/durableClient/DurableClient.ts | 14 ++- .../DurableOrchestrationContext.ts | 15 ++- test/integration/orchestrator-spec.ts | 108 ++++++++++++++++-- test/testobjects/TestOrchestrations.ts | 30 +++++ types/durableClient.d.ts | 6 + types/orchestration.d.ts | 9 +- 11 files changed, 201 insertions(+), 24 deletions(-) diff --git a/samples-ts/functions/httpStart.ts b/samples-ts/functions/httpStart.ts index 6bc1fbeb..8ff0102b 100644 --- a/samples-ts/functions/httpStart.ts +++ b/samples-ts/functions/httpStart.ts @@ -7,11 +7,25 @@ const httpStart: HttpHandler = async ( ): Promise => { const client = df.getClient(context); const body: unknown = await request.json(); - const instanceId: string = await client.startNew(request.params.orchestratorName, { - input: body, - }); - context.log(`Started orchestration with ID = '${instanceId}'.`); + // Get optional version from query parameter + const version = request.query.get("version"); + + let instanceId: string; + if (version) { + // Override the orchestration version + instanceId = await client.startNew(request.params.orchestratorName, { + input: body, + version: version, + }); + context.log(`Started orchestration with ID = '${instanceId}' and version = '${version}'.`); + } else { + // Use defaultVersion from host.json + instanceId = await client.startNew(request.params.orchestratorName, { + input: body, + }); + context.log(`Started orchestration with ID = '${instanceId}'.`); + } return client.createCheckStatusResponse(request, instanceId); }; diff --git a/samples-ts/functions/orchestrationVersion.ts b/samples-ts/functions/orchestrationVersion.ts index 737bf6c0..50f91d46 100644 --- a/samples-ts/functions/orchestrationVersion.ts +++ b/samples-ts/functions/orchestrationVersion.ts @@ -18,12 +18,21 @@ const versionedOrchestrator: OrchestrationHandler = function* (context: Orchestr yield context.df.waitForExternalEvent("Continue"); context.df.setCustomStatus("Continue event received"); - // New sub-orchestrations will use the current defaultVersion specified in host.json + // You can explicitly pass a version to sub-orchestrators + const subOrchestratorWithVersionResult = yield context.df.callSubOrchestrator( + "versionedSuborchestrator", + undefined, // input + undefined, // instanceId + "0.9" // version override + ); + + // Without specifying version, the sub-orchestrator will use the current defaultVersion const subOrchestratorResult = yield context.df.callSubOrchestrator("versionedSuborchestrator"); return [ `Orchestration version: ${context.df.version}`, - `Suborchestration version: ${subOrchestratorResult}`, + `Suborchestration version (explicit): ${subOrchestratorWithVersionResult}`, + `Suborchestration version (default): ${subOrchestratorResult}`, `Activity result: ${activityResult}`, ]; }; diff --git a/samples-ts/host.json b/samples-ts/host.json index 5753410a..5cda6cf7 100644 --- a/samples-ts/host.json +++ b/samples-ts/host.json @@ -10,7 +10,7 @@ }, "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[3.15.0, 4.0.0)" + "version": "[4.26.0, 5.0.0)" }, "extensions": { "durableTask": { diff --git a/src/actions/CallSubOrchestratorAction.ts b/src/actions/CallSubOrchestratorAction.ts index ae8bf51d..d39c6c67 100644 --- a/src/actions/CallSubOrchestratorAction.ts +++ b/src/actions/CallSubOrchestratorAction.ts @@ -10,7 +10,8 @@ export class CallSubOrchestratorAction implements IAction { constructor( public readonly functionName: string, public readonly instanceId?: string, - input?: unknown + input?: unknown, + public readonly version?: string ) { this.input = input; Utils.throwIfEmpty(functionName, "functionName"); diff --git a/src/actions/CallSubOrchestratorWithRetryAction.ts b/src/actions/CallSubOrchestratorWithRetryAction.ts index 9dbdceaf..f98e3f8c 100644 --- a/src/actions/CallSubOrchestratorWithRetryAction.ts +++ b/src/actions/CallSubOrchestratorWithRetryAction.ts @@ -12,7 +12,8 @@ export class CallSubOrchestratorWithRetryAction implements IAction { public readonly functionName: string, public readonly retryOptions: RetryOptions, input?: unknown, - public readonly instanceId?: string + public readonly instanceId?: string, + public readonly version?: string ) { this.input = input; Utils.throwIfEmpty(functionName, "functionName"); diff --git a/src/durableClient/DurableClient.ts b/src/durableClient/DurableClient.ts index f5ffd9f5..65900e8b 100644 --- a/src/durableClient/DurableClient.ts +++ b/src/durableClient/DurableClient.ts @@ -484,16 +484,26 @@ export class DurableClient implements types.DurableClient { const instanceIdPath: string = options?.instanceId ? `/${options.instanceId}` : ""; if (this.clientData.rpcBaseUrl) { // Fast local RPC path - requestUrl = new URL( + const urlObj = new URL( `orchestrators/${orchestratorFunctionName}${instanceIdPath}`, this.clientData.rpcBaseUrl - ).href; + ); + if (options?.version) { + urlObj.searchParams.append("version", options.version); + } + requestUrl = urlObj.href; } else { // Legacy app frontend path requestUrl = this.clientData.creationUrls.createNewInstancePostUri; requestUrl = requestUrl .replace(this.functionNamePlaceholder, orchestratorFunctionName) .replace(this.instanceIdPlaceholder, instanceIdPath); + if (options?.version) { + const separator = requestUrl.includes("?") ? "&" : "?"; + requestUrl = `${requestUrl}${separator}version=${encodeURIComponent( + options.version + )}`; + } } const headers = this.getDistributedTracingHeaders(); diff --git a/src/orchestrations/DurableOrchestrationContext.ts b/src/orchestrations/DurableOrchestrationContext.ts index 65b09e0a..3bd1f712 100644 --- a/src/orchestrations/DurableOrchestrationContext.ts +++ b/src/orchestrations/DurableOrchestrationContext.ts @@ -201,14 +201,19 @@ export class DurableOrchestrationContext implements types.DurableOrchestrationCo this.taskOrchestratorExecutor.recordFireAndForgetAction(action); } - public callSubOrchestrator(name: string, input?: unknown, instanceId?: string): Task { + public callSubOrchestrator( + name: string, + input?: unknown, + instanceId?: string, + version?: string + ): Task { if (!name) { throw new Error( "A sub-orchestration function name must be provided when attempting to create a suborchestration" ); } - const newAction = new CallSubOrchestratorAction(name, instanceId, input); + const newAction = new CallSubOrchestratorAction(name, instanceId, input, version); const task = new AtomicTask(false, newAction); return task; } @@ -217,7 +222,8 @@ export class DurableOrchestrationContext implements types.DurableOrchestrationCo name: string, retryOptions: types.RetryOptions, input?: unknown, - instanceId?: string + instanceId?: string, + version?: string ): Task { if (!name) { throw new Error( @@ -229,7 +235,8 @@ export class DurableOrchestrationContext implements types.DurableOrchestrationCo name, retryOptions, input, - instanceId + instanceId, + version ); const backingTask = new AtomicTask(false, newAction); const task = new RetryableTask(backingTask, retryOptions); diff --git a/test/integration/orchestrator-spec.ts b/test/integration/orchestrator-spec.ts index c94d18a2..89201910 100644 --- a/test/integration/orchestrator-spec.ts +++ b/test/integration/orchestrator-spec.ts @@ -1488,6 +1488,45 @@ describe("Orchestrator", () => { ); }); + it("schedules a versioned suborchestrator function", async () => { + const orchestrator = TestOrchestrations.SayHelloWithVersionedSubOrchestrator; + const name = "World"; + const id = uuidv1(); + const childId = `${id}:0`; + const mockContext = new DummyOrchestrationContext(); + const orchestrationInput = new DurableOrchestrationInput( + id, + TestHistories.GetOrchestratorStart( + "SayHelloWithVersionedSubOrchestrator", + moment.utc().toDate() + ), + name + ); + + const result = await orchestrator(orchestrationInput, mockContext); + + expect(result).to.be.deep.equal( + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new CallSubOrchestratorAction( + "SayHelloWithActivity", + childId, + name, + "v2" + ), + ], + ], + schemaVersion: ReplaySchema.V1, + }, + true + ) + ); + }); + it("schedules a suborchestrator function with no instanceId", async () => { const orchestrator = TestOrchestrations.SayHelloWithSubOrchestratorNoSubId; const name = "World"; @@ -1661,8 +1700,18 @@ describe("Orchestrator", () => { .to.be.an("object") .that.deep.include({ isDone: false, + // Note: In error paths, actions may be serialized without undefined fields. + // Using a plain object literal avoids setting expectations on the presence + // of any property that would cause deep.include to fail. actions: [ - [new CallSubOrchestratorAction("SayHelloWithActivity", childId, name)], + [ + { + actionType: ActionType.CallSubOrchestrator, + functionName: "SayHelloWithActivity", + instanceId: childId, + input: name, + }, + ], ], }); expect(orchestrationState.error).to.include(expectedErr); @@ -1789,6 +1838,47 @@ describe("Orchestrator", () => { ); }); + it("schedules a versioned suborchestrator function with retry", async () => { + const orchestrator = TestOrchestrations.SayHelloWithVersionedSubOrchestratorRetry; + const name = "World"; + const id = uuidv1(); + const childId = `${id}:0`; + const mockContext = new DummyOrchestrationContext(); + const orchestrationInput = new DurableOrchestrationInput( + id, + TestHistories.GetOrchestratorStart( + "SayHelloWithVersionedSubOrchestratorRetry", + moment.utc().toDate(), + name + ), + name + ); + + const result = await orchestrator(orchestrationInput, mockContext); + + expect(result).to.be.deep.equal( + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new CallSubOrchestratorWithRetryAction( + "SayHelloInline", + new RetryOptions(10000, 2), + name, + childId, + "v2" + ), + ], + ], + schemaVersion: ReplaySchema.V1, + }, + true + ) + ); + }); + it("retries a failed suborchestrator function if < max attempts", async () => { const orchestrator = TestOrchestrations.SayHelloWithSubOrchestratorRetry; const name = "World"; @@ -1864,14 +1954,18 @@ describe("Orchestrator", () => { .to.be.an("object") .that.deep.include({ isDone: false, + // Note: In error paths, actions may be serialized without undefined fields. + // Using a plain object literal avoids setting expectations on the presence + // of any property that would cause deep.include to fail. actions: [ [ - new CallSubOrchestratorWithRetryAction( - "SayHelloInline", - new RetryOptions(10000, 2), - name, - childId - ), + { + actionType: ActionType.CallSubOrchestratorWithRetry, + functionName: "SayHelloInline", + retryOptions: new RetryOptions(10000, 2), + instanceId: childId, + input: name, + }, ], ], }); diff --git a/test/testobjects/TestOrchestrations.ts b/test/testobjects/TestOrchestrations.ts index 339cd21c..f65a2a73 100644 --- a/test/testobjects/TestOrchestrations.ts +++ b/test/testobjects/TestOrchestrations.ts @@ -278,6 +278,20 @@ export class TestOrchestrations { return output; }); + public static SayHelloWithVersionedSubOrchestrator = createOrchestrator(function* ( + context: OrchestrationContext + ) { + const input = context.df.getInput(); + const childId = context.df.instanceId + ":0"; + const output = yield context.df.callSubOrchestrator( + "SayHelloWithActivity", + input, + childId, + "v2" + ); + return output; + }); + public static SayHelloWithSubOrchestratorNoSubId: any = createOrchestrator(function* ( context: OrchestrationContext ) { @@ -314,6 +328,22 @@ export class TestOrchestrations { return output; }); + public static SayHelloWithVersionedSubOrchestratorRetry: any = createOrchestrator(function* ( + context: any + ) { + const input = context.df.getInput(); + const childId = context.df.instanceId + ":0"; + const retryOptions = new df.RetryOptions(10000, 2); + const output = yield context.df.callSubOrchestratorWithRetry( + "SayHelloInline", + retryOptions, + input, + childId, + "v2" + ); + return output; + }); + public static SayHelloWithSubOrchestratorRetryFanout: any = createOrchestrator(function* ( context: any ) { diff --git a/types/durableClient.d.ts b/types/durableClient.d.ts index c8699c34..8573f31b 100644 --- a/types/durableClient.d.ts +++ b/types/durableClient.d.ts @@ -372,6 +372,12 @@ export interface StartNewOptions { * JSON-serializable input value for the orchestrator function. */ input?: unknown; + + /** + * The version to use for the new orchestration instance. If not specified, + * the default version from host.json will be used. + */ + version?: string; } /** diff --git a/types/orchestration.d.ts b/types/orchestration.d.ts index 04e71e77..41e80b2d 100644 --- a/types/orchestration.d.ts +++ b/types/orchestration.d.ts @@ -189,8 +189,10 @@ export declare class DurableOrchestrationContext { * @param instanceId A unique ID to use for the sub-orchestration instance. * If `instanceId` is not specified, the extension will generate an id in * the format `:<#>` + * @param version The version to use for the sub-orchestration instance. + * If not specified, the default version from host.json will be used. */ - callSubOrchestrator(name: string, input?: unknown, instanceId?: string): Task; + callSubOrchestrator(name: string, input?: unknown, instanceId?: string, version?: string): Task; /** * Schedules an orchestrator function named `name` for execution with retry @@ -201,12 +203,15 @@ export declare class DurableOrchestrationContext { * @param input The JSON-serializable input to pass to the orchestrator * function. * @param instanceId A unique ID to use for the sub-orchestration instance. + * @param version The version to use for the sub-orchestration instance. + * If not specified, the default version from host.json will be used. */ callSubOrchestratorWithRetry( name: string, retryOptions: RetryOptions, input?: unknown, - instanceId?: string + instanceId?: string, + version?: string ): Task; /**