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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

### 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`.
- 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.
Expand Down
4 changes: 4 additions & 0 deletions packages/azure-functions-durable/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions packages/azure-functions-durable/src/orchestration-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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",
Expand All @@ -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" });
Expand Down
95 changes: 94 additions & 1 deletion packages/azure-functions-durable/test/unit/worker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions packages/durabletask-js/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions packages/durabletask-js/src/task/context/orchestration-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ 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.
* The default implementation returns `undefined` so existing subclasses need no override.
*/
get sourceInstanceId(): string | undefined {
return undefined;
}

/**
* Gets the parent orchestration instance, or `undefined` if this is not a sub-orchestration.
*
Expand Down
8 changes: 7 additions & 1 deletion packages/durabletask-js/src/worker/orchestration-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class OrchestrationExecutor {
oldEvents: pb.HistoryEvent[],
newEvents: pb.HistoryEvent[],
executionId?: string,
sourceInstanceId?: string,
): Promise<OrchestrationExecutionResult> {
if (!newEvents?.length) {
throw new OrchestrationStateError("The new history event list must have at least one event in it");
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
16 changes: 16 additions & 0 deletions packages/durabletask-js/src/worker/task-hub-grpc-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
55 changes: 55 additions & 0 deletions packages/durabletask-js/test/orchestration_executor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "" });
Expand All @@ -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) => {
Expand Down
Loading
Loading