From 58d613c6002faa7e5fbf45d179b88075c5b41c6e Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Thu, 8 Oct 2026 21:56:28 -0700 Subject: [PATCH 1/2] initial commit --- CHANGELOG.md | 5 + packages/azure-functions-durable/CHANGELOG.md | 4 + .../src/orchestration-context.ts | 10 ++ .../test/unit/orchestration-context.spec.ts | 11 ++- .../test/unit/worker.spec.ts | 95 ++++++++++++++++++- packages/durabletask-js/src/client/client.ts | 4 + .../src/task/context/orchestration-context.ts | 10 ++ .../src/worker/orchestration-executor.ts | 8 +- .../worker/runtime-orchestration-context.ts | 5 + .../src/worker/task-hub-grpc-worker.ts | 16 ++++ .../test/orchestration_executor.spec.ts | 55 +++++++++++ .../test/worker-response-delivery.spec.ts | 37 ++++++++ 12 files changed, 257 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c90183d..c47d1e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ### New +- Expose readonly `OrchestrationContext.sourceInstanceId` from the existing protobuf + request properties on initial execution and replay, independently of parent identity. + Strings are preserved verbatim; null or missing metadata returns `undefined`. + Malformed value kinds fail explicitly. Backend lineage emission is required and + standalone DTS/Dapr support is unverified; restart APIs and protocol are unchanged. - Expose optional `PurgeResult.isComplete` from the backend for single-instance and filtered purges: `true` means finished, `false` means partial, and `undefined` means completion was not reported. Existing `deletedInstanceCount` values and count-only construction are unchanged. diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index c1c06dfd..0948e769 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -2,6 +2,10 @@ ### New +- Expose readonly `context.df.sourceInstanceId`, forwarding core clone lineage on + initial execution and replay. Null or missing metadata returns `undefined`. + Actual Functions lineage requires extension lineage support and provider tag persistence. + Restart APIs remain unchanged; destination-ID/version options are not supported by the protocol. - 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. diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index fd278be4..d20b7e90 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -71,6 +71,16 @@ export class DurableOrchestrationContext { return this._ctx.instanceId; } + /** + * The immediate clone source ID, independent of {@link parentInstanceId}. + * Missing or null backend metadata returns `undefined`; supplied strings are preserved. + * Availability during execution and replay requires extension/backend lineage support + * and provider tag persistence. Sub-orchestrations do not inherit parent clone lineage. + */ + get sourceInstanceId(): string | undefined { + return this._ctx.sourceInstanceId; + } + /** The ID of the parent orchestration, or `undefined` if this is a top-level orchestration. */ get parentInstanceId(): string | undefined { return this._ctx.parent?.instanceId; 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 e39a3e62..39f6fd9c 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -26,7 +26,7 @@ import type { HttpRetryOptions as PublicHttpRetryOptions } from "../../src"; import { BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME } from "../../src/http/builtin"; /** Builds a fake core OrchestrationContext whose methods return sentinel values via jest mocks. */ -function createFakeCoreContext() { +function createFakeCoreContext(sourceInstanceId?: string) { const entities = { callEntity: jest.fn().mockReturnValue("callEntity-task"), signalEntity: jest.fn(), @@ -40,6 +40,7 @@ function createFakeCoreContext() { const ctx = { name: "LogicalOrch", instanceId: "instance-1", + sourceInstanceId, isReplaying: true, currentUtcDateTime: new Date("2026-01-02T03:04:05.000Z"), version: "1.2.3", @@ -58,6 +59,14 @@ function createFakeCoreContext() { } describe("DurableOrchestrationContext", () => { + it.each([undefined, "", "source:@Case/123"])("forwards read-only source lineage %s", (sourceInstanceId) => { + const { ctx } = createFakeCoreContext(sourceInstanceId); + const df = new DurableOrchestrationContext(ctx, undefined); + expect(df.sourceInstanceId).toBe(sourceInstanceId); + expect(df.parentInstanceId).toBeUndefined(); + expect(Reflect.set(df, "sourceInstanceId", "changed")).toBe(false); + }); + it("exposes identity/replay properties and getInput from the core context", () => { const { ctx } = createFakeCoreContext(); const df = new DurableOrchestrationContext(ctx, { city: "Tokyo" }); diff --git a/packages/azure-functions-durable/test/unit/worker.spec.ts b/packages/azure-functions-durable/test/unit/worker.spec.ts index 4bcd89c1..e6d8848f 100644 --- a/packages/azure-functions-durable/test/unit/worker.spec.ts +++ b/packages/azure-functions-durable/test/unit/worker.spec.ts @@ -2,12 +2,19 @@ // Licensed under the MIT License. import { DurableFunctionsWorker } from "../../src/worker"; -import { NoOpLogger } from "@microsoft/durabletask-js"; +import { NoOpLogger, type OrchestrationContext, type TOrchestrator } from "@microsoft/durabletask-js"; +import { createRequire } from "module"; 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 { RetryOptions, TaskCancelledError, type SubOrchestrationOptions } from "../../src"; +// Match the generated stubs' protobuf runtime, not a potentially different tooling dependency. +const { Value }: typeof import("google-protobuf/google/protobuf/struct_pb") = + createRequire(require.resolve("../../../durabletask-js/src/proto/orchestrator_service_pb"))( + "google-protobuf/google/protobuf/struct_pb", + ); + const DAY = 24 * 60 * 60 * 1000; const START = new Date("2026-01-01T00:00:00Z"); @@ -30,6 +37,92 @@ async function timerActions(worker: DurableFunctionsWorker) { } describe("DurableFunctionsWorker", () => { + it.each([ + ["string", Value.fromJavaScript("Source:@Case/with.punctuation-123"), "Source:@Case/with.punctuation-123"], + ["empty string", Value.fromJavaScript(""), ""], + ["null", Value.fromJavaScript(null), undefined], + ["missing", undefined, undefined], + ] as const)("exposes %s lineage in classic and core-native protobuf handlers during replay", async (_, value, expected) => { + const observed = jest.fn(); + const worker = new DurableFunctionsWorker({ logger: new NoOpLogger() }); + const classic = wrapOrchestrator(function* (ctx: ClassicOrchestrationContext) { + observed(ctx.df.sourceInstanceId, ctx.df.parentInstanceId, ctx.df.isReplaying); + yield ctx.df.createTimer(new Date(START.getTime() + 1000)); + observed(ctx.df.sourceInstanceId, ctx.df.parentInstanceId, ctx.df.isReplaying); + return "done"; + }); + const native: TOrchestrator = async function* (ctx: OrchestrationContext) { + observed(ctx.sourceInstanceId, ctx.parent?.instanceId, ctx.isReplaying); + yield ctx.createTimer(new Date(START.getTime() + 1000)); + observed(ctx.sourceInstanceId, ctx.parent?.instanceId, ctx.isReplaying); + return "done"; + }; + worker.addNamedOrchestrator("classic", classic); + worker.addNamedOrchestrator("native", native); + for (const name of ["classic", "native"]) { + for (const parent of [undefined, { name: "Parent", instanceId: "parent-id", taskScheduledId: 42 }]) { + observed.mockClear(); + const events = [ + ph.newOrchestratorStartedEvent(START), + ph.newExecutionStartedEvent(name, "clone-id", undefined, parent), + ]; + const request = new pb.OrchestratorRequest().setInstanceid("clone-id").setNeweventsList(events); + if (value !== undefined) request.getPropertiesMap().set("sourceInstanceId", value); + else expect(request.getPropertiesMap().has("sourceInstanceId")).toBe(false); + const execute = async () => { + const response = await worker.handleOrchestratorRequest( + Buffer.from(request.serializeBinary()).toString("base64"), + ); + return pb.OrchestratorResponse.deserializeBinary(Buffer.from(response, "base64")).getActionsList(); + }; + const initial = await execute(); + expect(observed.mock.calls).toEqual([[expected, parent?.instanceId, false]]); + expect(initial).toHaveLength(1); + expect(initial[0].getCreatetimer()?.getFireat()?.toDate()).toEqual(new Date(START.getTime() + 1000)); + observed.mockClear(); + request.setPasteventsList([...events, ph.newTimerCreatedEvent(1, new Date(START.getTime() + 1000))]); + request.setNeweventsList([ph.newTimerFiredEvent(1, new Date(START.getTime() + 1000))]); + const replay = await execute(); + expect(observed.mock.calls).toEqual([ + [expected, parent?.instanceId, true], + [expected, parent?.instanceId, false], + ]); + expect(replay).toHaveLength(1); + expect(replay[0].getCompleteorchestration()?.getOrchestrationstatus()).toBe( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED, + ); + } + } + }); + + it.each([ + Value.fromJavaScript(42), + Value.fromJavaScript(false), + Value.fromJavaScript({ source: "invalid" }), + Value.fromJavaScript(["invalid"]), + new Value(), + ])("logs and fails malformed lineage without invoking user code", async (value) => { + const logger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }; + const worker = new DurableFunctionsWorker({ logger }); + const handler = jest.fn(); + worker.addNamedOrchestrator("Flow", handler); + const request = new pb.OrchestratorRequest().setInstanceid("instance").setNeweventsList([ + ph.newExecutionStartedEvent("Flow", "instance"), + ]); + request.getPropertiesMap().set("sourceInstanceId", value); + const response = await worker.handleOrchestratorRequest(Buffer.from(request.serializeBinary()).toString("base64")); + const actions = pb.OrchestratorResponse.deserializeBinary(Buffer.from(response, "base64")).getActionsList(); + expect(handler).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + expect(actions).toHaveLength(1); + const completed = actions[0].getCompleteorchestration(); + expect(completed?.getOrchestrationstatus()).toBe(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); + expect(completed?.getFailuredetails()?.getErrortype()).toBe("TypeError"); + expect(completed?.getFailuredetails()?.getErrormessage()).toBe( + "OrchestratorRequest sourceInstanceId must be a string or null.", + ); + }); + it("dispatches versioned registrations and child defaults through the classic wrapper", async () => { const worker = new DurableFunctionsWorker({ logger: new NoOpLogger(), diff --git a/packages/durabletask-js/src/client/client.ts b/packages/durabletask-js/src/client/client.ts index c9ed88f9..54d3bb7c 100644 --- a/packages/durabletask-js/src/client/client.ts +++ b/packages/durabletask-js/src/client/client.ts @@ -693,6 +693,10 @@ export class TaskHubGrpcClient { * instance. The restarted orchestration will use the same input that was provided * when the orchestration was originally started. * + * Restarting with a new ID is a restart-as-clone, not execution-history cloning. + * The context's `sourceInstanceId` is available only if the backend supplies + * lineage metadata. The current protocol does not support choosing a destination ID or version. + * * @param instanceId - The unique ID of the orchestration instance to restart. * @param restartWithNewInstanceId - If true, the restarted orchestration will be assigned * a new instance ID. If false (default), the same instance ID will be reused. diff --git a/packages/durabletask-js/src/task/context/orchestration-context.ts b/packages/durabletask-js/src/task/context/orchestration-context.ts index fb4bc291..824e3d35 100644 --- a/packages/durabletask-js/src/task/context/orchestration-context.ts +++ b/packages/durabletask-js/src/task/context/orchestration-context.ts @@ -33,6 +33,16 @@ export abstract class OrchestrationContext { */ abstract get instanceId(): string; + /** + * The immediate source instance ID from which this orchestration was cloned by restart. + * + * This read-only lineage is independent of {@link parent}; sub-orchestrations do not + * inherit their parent's clone lineage. It is available during replay when supplied + * by the backend. Missing or null metadata returns `undefined`; strings are preserved + * verbatim, including empty strings. Backend support and metadata persistence are required. + */ + abstract get sourceInstanceId(): string | undefined; + /** * Gets the parent orchestration instance, or `undefined` if this is not a sub-orchestration. * diff --git a/packages/durabletask-js/src/worker/orchestration-executor.ts b/packages/durabletask-js/src/worker/orchestration-executor.ts index 8c47c81b..29e6a67d 100644 --- a/packages/durabletask-js/src/worker/orchestration-executor.ts +++ b/packages/durabletask-js/src/worker/orchestration-executor.ts @@ -70,6 +70,7 @@ export class OrchestrationExecutor { oldEvents: pb.HistoryEvent[], newEvents: pb.HistoryEvent[], executionId?: string, + sourceInstanceId?: string, ): Promise { if (!newEvents?.length) { throw new OrchestrationStateError("The new history event list must have at least one event in it"); @@ -92,7 +93,12 @@ export class OrchestrationExecutor { return buildRewindResult(oldEvents, newEvents); } - const ctx = new RuntimeOrchestrationContext(instanceId, this._maximumTimerIntervalMs, this._defaultVersion); + const ctx = new RuntimeOrchestrationContext( + instanceId, + this._maximumTimerIntervalMs, + this._defaultVersion, + sourceInstanceId, + ); // Seed the execution ID from the authoritative source (the OrchestratorRequest on the gRPC path, // or the backend record on the in-memory path). The ExecutionStarted event replayed below may // also carry it; handleExecutionStarted reconciles the two. diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index 95ae1383..9371c557 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -62,6 +62,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { instanceId: string, maximumTimerIntervalMs?: number | null, private readonly _defaultVersion?: string, + private readonly _sourceInstanceId?: string, ) { super(); this._maximumTimerIntervalMs = resolveMaximumTimerInterval(maximumTimerIntervalMs); @@ -99,6 +100,10 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { return this._instanceId; } + get sourceInstanceId(): string | undefined { + return this._sourceInstanceId; + } + get entities(): OrchestrationEntityFeature { return this._entityFeature; } 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 d6e78548..962b6fec 100644 --- a/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts +++ b/packages/durabletask-js/src/worker/task-hub-grpc-worker.ts @@ -12,6 +12,7 @@ import { TOrchestrator } from "../types/orchestrator.type"; import { TOutput } from "../types/output.type"; import { GrpcClient } from "../client/client-grpc"; import { Empty } from "google-protobuf/google/protobuf/empty_pb"; +import { Value } from "google-protobuf/google/protobuf/struct_pb"; import * as pbh from "../utils/pb-helper.util"; import { callWithMetadata, MetadataGenerator } from "../utils/grpc-helper.util"; import { OrchestrationExecutor } from "./orchestration-executor"; @@ -1210,6 +1211,20 @@ export class TaskHubGrpcWorker { let res; try { + const source = req.getPropertiesMap().get("sourceInstanceId"); + let sourceInstanceId: string | undefined; + if (source !== undefined) { + switch (source.getKindCase()) { + case Value.KindCase.STRING_VALUE: + sourceInstanceId = source.getStringValue(); + break; + case Value.KindCase.NULL_VALUE: + break; + default: + throw new TypeError("OrchestratorRequest sourceInstanceId must be a string or null."); + } + } + const executor = new OrchestrationExecutor( this._registry, this._logger, @@ -1221,6 +1236,7 @@ export class TaskHubGrpcWorker { req.getPasteventsList(), req.getNeweventsList(), req.getExecutionid()?.getValue(), + sourceInstanceId, ); // Process actions to inject trace context into scheduled tasks, sub-orchestrations, etc. diff --git a/packages/durabletask-js/test/orchestration_executor.spec.ts b/packages/durabletask-js/test/orchestration_executor.spec.ts index 421238e1..5ee64022 100644 --- a/packages/durabletask-js/test/orchestration_executor.spec.ts +++ b/packages/durabletask-js/test/orchestration_executor.spec.ts @@ -40,6 +40,16 @@ const testLogger = new NoOpLogger(); const TEST_INSTANCE_ID = "abc123"; describe("Orchestration Context", () => { + it("exposes getter-only source lineage with an undefined manual default", () => { + const ctx = new RuntimeOrchestrationContext(TEST_INSTANCE_ID); + expect(ctx.sourceInstanceId).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(RuntimeOrchestrationContext.prototype, "sourceInstanceId")).toMatchObject({ + get: expect.any(Function), + set: undefined, + }); + expect(Reflect.set(ctx, "sourceInstanceId", "changed")).toBe(false); + }); + it("keeps manual construction compatible with an empty, getter-only name", () => { const ctx = new RuntimeOrchestrationContext(TEST_INSTANCE_ID); expect(ctx).toMatchObject({ instanceId: TEST_INSTANCE_ID, name: "", version: "" }); @@ -52,6 +62,51 @@ describe("Orchestration Context", () => { }); describe("Orchestration Executor", () => { + it.each([undefined, "", "Source:@Case/with.punctuation-123"])( + "preserves source %s independently of parent, identity, version, actions, and replay", + async (sourceInstanceId) => { + const parent = { name: "Parent", instanceId: "parent-id", taskScheduledId: 42 }; + const observed = jest.fn(); + const registry = new Registry(); + registry.addNamedOrchestrator("Flow", async function* (ctx, input) { + observed(ctx.sourceInstanceId, ctx.parent, ctx.instanceId, ctx.version, ctx.isReplaying, input); + yield ctx.callActivity("Work"); + observed(ctx.sourceInstanceId, ctx.parent, ctx.instanceId, ctx.version, ctx.isReplaying, input); + ctx.continueAsNew(input, true); + }); + for (const parentMetadata of [undefined, parent]) { + observed.mockClear(); + const startEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent("Flow", TEST_INSTANCE_ID, '"input"', parentMetadata, undefined, "1.0"), + ]; + const initial = await new OrchestrationExecutor(registry, testLogger).execute( + TEST_INSTANCE_ID, [], startEvents, "execution-id", sourceInstanceId, + ); + expect(observed).toHaveBeenCalledWith(sourceInstanceId, parentMetadata, TEST_INSTANCE_ID, "1.0", false, "input"); + expect(initial.actions).toHaveLength(1); + expect(initial.actions[0].getId()).toBe(1); + expect(initial.actions[0].getScheduletask()?.getName()).toBe("Work"); + + observed.mockClear(); + const replay = await new OrchestrationExecutor(registry, testLogger).execute( + TEST_INSTANCE_ID, + [...startEvents, newTaskScheduledEvent(1, "Work")], + [newTaskCompletedEvent(1, '"done"')], + "execution-id", + sourceInstanceId, + ); + expect(observed.mock.calls).toEqual([ + [sourceInstanceId, parentMetadata, TEST_INSTANCE_ID, "1.0", true, "input"], + [sourceInstanceId, parentMetadata, TEST_INSTANCE_ID, "1.0", false, "input"], + ]); + const completed = getAndValidateSingleCompleteOrchestrationAction(replay); + expect(completed?.getOrchestrationstatus()).toBe(pb.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW); + expect(completed?.getResult()?.getValue()).toBe('"input"'); + } + }, + ); + it.each([undefined, "1.0.0"])( "preserves each logical name on initial execution and replay with registration version %s", async (registrationVersion) => { diff --git a/packages/durabletask-js/test/worker-response-delivery.spec.ts b/packages/durabletask-js/test/worker-response-delivery.spec.ts index 568377e4..7de57a9d 100644 --- a/packages/durabletask-js/test/worker-response-delivery.spec.ts +++ b/packages/durabletask-js/test/worker-response-delivery.spec.ts @@ -5,6 +5,8 @@ import * as grpc from "@grpc/grpc-js"; import { EventEmitter } from "events"; import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb"; import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; +import { Value } from "google-protobuf/google/protobuf/struct_pb"; +import { newExecutionStartedEvent, newOrchestratorStartedEvent } from "../src/utils/pb-helper.util"; import { TaskEntity } from "../src/entities/task-entity"; import * as pb from "../src/proto/orchestrator_service_pb"; import * as stubs from "../src/proto/orchestrator_service_grpc_pb"; @@ -54,6 +56,41 @@ describe("Worker response retries", () => { jest.restoreAllMocks(); }); + it.each([undefined, null, "", "Source:@Case/123"])( + "forwards source %s through streamed work-item dispatch without changing response identity", + async (sourceInstanceId) => { + const observed = jest.fn(); + worker.addNamedOrchestrator("Flow", async (ctx) => { + observed(ctx.sourceInstanceId, ctx.instanceId, ctx.version); + return "done"; + }); + const send = jest.spyOn(stub, "completeOrchestratorTask").mockImplementation( + (_request, _metadata, optionsOrCallback: Partial | Callback, callback?: Callback) => { + const respond = typeof optionsOrCallback === "function" ? optionsOrCallback : callback!; + respond(null, new pb.CompleteTaskResponse()); + return unaryCall(); + }, + ); + const request = new pb.OrchestratorRequest().setInstanceid("clone-id").setNeweventsList([ + newOrchestratorStartedEvent(), + newExecutionStartedEvent("Flow", "clone-id", undefined, undefined, "execution-id", "1.0"), + ]); + request.setExecutionid(new StringValue().setValue("execution-id")); + if (sourceInstanceId !== undefined) request.getPropertiesMap().set("sourceInstanceId", Value.fromJavaScript(sourceInstanceId)); + const item = new pb.WorkItem().setCompletiontoken("token").setOrchestratorrequest(request); + worker["_dispatchWorkItem"](pb.WorkItem.deserializeBinary(item.serializeBinary()), stub); + await Promise.all(worker["_pendingWorkItems"]); + expect(observed.mock.calls).toEqual([[sourceInstanceId ?? undefined, "clone-id", "1.0"]]); + expect(send).toHaveBeenCalledTimes(1); + const response = send.mock.calls[0][0]; + expect(response.getInstanceid()).toBe("clone-id"); + expect(response.getCompletiontoken()).toBe("token"); + expect(response.getActionsList()[0].getCompleteorchestration()?.getOrchestrationstatus()).toBe( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED, + ); + }, + ); + it.each([grpc.status.UNAVAILABLE, grpc.status.UNKNOWN, grpc.status.DEADLINE_EXCEEDED, grpc.status.INTERNAL])( "retries transient status %s with the same request and fresh metadata", async (code) => { From 29b869fe7046cf8812aea744c8fcb050034b7025 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Mon, 12 Oct 2026 10:59:53 -0700 Subject: [PATCH 2/2] Make sourceInstanceId getter backward-compatible --- CHANGELOG.md | 2 -- .../src/task/context/orchestration-context.ts | 5 ++- ...rchestration-context-compatibility.spec.ts | 36 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 packages/durabletask-js/test/orchestration-context-compatibility.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c47d1e00..ed87a15c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,6 @@ - Expose readonly `OrchestrationContext.sourceInstanceId` from the existing protobuf request properties on initial execution and replay, independently of parent identity. Strings are preserved verbatim; null or missing metadata returns `undefined`. - Malformed value kinds fail explicitly. Backend lineage emission is required and - standalone DTS/Dapr support is unverified; restart APIs and protocol are unchanged. - Expose optional `PurgeResult.isComplete` from the backend for single-instance and filtered purges: `true` means finished, `false` means partial, and `undefined` means completion was not reported. Existing `deletedInstanceCount` values and count-only construction are unchanged. diff --git a/packages/durabletask-js/src/task/context/orchestration-context.ts b/packages/durabletask-js/src/task/context/orchestration-context.ts index 824e3d35..e8158290 100644 --- a/packages/durabletask-js/src/task/context/orchestration-context.ts +++ b/packages/durabletask-js/src/task/context/orchestration-context.ts @@ -40,8 +40,11 @@ export abstract class OrchestrationContext { * inherit their parent's clone lineage. It is available during replay when supplied * by the backend. Missing or null metadata returns `undefined`; strings are preserved * verbatim, including empty strings. Backend support and metadata persistence are required. + * The default implementation returns `undefined` so existing subclasses need no override. */ - abstract get sourceInstanceId(): string | undefined; + get sourceInstanceId(): string | undefined { + return undefined; + } /** * Gets the parent orchestration instance, or `undefined` if this is not a sub-orchestration. diff --git a/packages/durabletask-js/test/orchestration-context-compatibility.spec.ts b/packages/durabletask-js/test/orchestration-context-compatibility.spec.ts new file mode 100644 index 00000000..f89e2e2a --- /dev/null +++ b/packages/durabletask-js/test/orchestration-context-compatibility.spec.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { OrchestrationContext } from "../src/task/context/orchestration-context"; + +class ExistingOrchestrationContext extends OrchestrationContext { + readonly name = "existing-orchestrator"; + readonly instanceId = "existing-instance"; + readonly parent = undefined; + readonly currentUtcDateTime = new Date("2026-01-01T00:00:00Z"); + readonly isReplaying = false; + readonly version = ""; + + get entities(): OrchestrationContext["entities"] { + throw new Error("Entity operations are not implemented by this test context."); + } + + createTimer: OrchestrationContext["createTimer"] = jest.fn(); + callActivity: OrchestrationContext["callActivity"] = jest.fn(); + callSubOrchestrator: OrchestrationContext["callSubOrchestrator"] = jest.fn(); + waitForExternalEvent: OrchestrationContext["waitForExternalEvent"] = jest.fn(); + continueAsNew: OrchestrationContext["continueAsNew"] = jest.fn(); + setCustomStatus: OrchestrationContext["setCustomStatus"] = jest.fn(); + sendEvent: OrchestrationContext["sendEvent"] = jest.fn(); + newGuid: OrchestrationContext["newGuid"] = jest.fn(); +} + +describe("OrchestrationContext subclass compatibility", () => { + it("allows a concrete subclass without a sourceInstanceId implementation", () => { + const context: OrchestrationContext = new ExistingOrchestrationContext(); + + expect(context.sourceInstanceId).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(ExistingOrchestrationContext.prototype, "sourceInstanceId")).toBeUndefined(); + expect(Reflect.set(context, "sourceInstanceId", "changed")).toBe(false); + }); +});