Skip to content

Commit 8f62a35

Browse files
YunchuWangCopilot
andcommitted
Refine PR #282 review items 1-5: arity detection, getStatus v3 parity, startNew version
- (1) orchestration-context: detect classic vs core-native orchestrators by generator/async kind (async* and async => native; sync generator => classic; plain sync => arity fallback) and drive both sync/async generators; add end-to-end regression tests through the real core executor. - (2) createTimer already returns cancelable TimerTask (type now visible); add type/Task.any cancel test. - (3) doc-only: classic contexts no longer extend InvocationContext (entity ctx is {df}); add replay-nondeterminism rationale to CHANGELOG/README. - (4) createCheckStatusResponse already accepts undefined request with baseUrl fallback (v3-faithful). - (5) getStatus returns non-optional DurableOrchestrationStatus and throws on not-found (v3 parity); showHistory populates core history; showInput maps to fetchPayloads; startNew forwards v3 version option. Docs note gRPC-path boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent f935bce commit 8f62a35

8 files changed

Lines changed: 327 additions & 50 deletions

File tree

packages/azure-functions-durable/CHANGELOG.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,20 @@ Initial gRPC-consolidated Azure Functions Durable provider, built on `@microsoft
1111
### Breaking changes vs `durable-functions` v3
1212

1313
- Classic orchestration and entity contexts no longer extend `InvocationContext`; they expose only
14-
`df` plus replay-safe log helpers (no `invocationId` / `functionName` / `extraInputs`).
14+
`df` plus replay-safe log helpers (no `invocationId` / `functionName` / `extraInputs`), and the
15+
classic entity context exposes only `{ df }`. Rationale: reading `InvocationContext` members such as
16+
`invocationId` / `extraInputs` inside an orchestrator is replay-nondeterministic and was never
17+
recommended, so they are intentionally not surfaced.
1518
- Task result shape follows the core SDK: use `isComplete` / `isFailed` / `getResult()` (v3 used
1619
`isCompleted` / `isFaulted` / `result`). `context.df.createTimer(...)` returns a cancelable
1720
`TimerTask`, so the timeout-race pattern (`Task.any` then `timer.cancel()`) keeps working.
18-
- `client.getStatus()` returns `DurableOrchestrationStatus | undefined` (was non-optional) and honors
19-
only `showInput`; `showHistory` / `showHistoryOutput` are ignored and `history` is not populated.
20-
- `client.startNew()` drops the v3 `version` option.
21+
- `client.getStatus()` matches the v3 signature: it returns a non-optional `DurableOrchestrationStatus`
22+
and throws when the instance does not exist. `showInput`, `showHistory`, and `showHistoryOutput` are
23+
accepted, with two consolidated-path caveats: `showInput` maps to the core `fetchPayloads` flag (so
24+
`showInput: false` also omits output/custom status, where v3 omitted only the input), and
25+
`showHistory` surfaces core `HistoryEvent`s rather than the classic .NET extension's history shape
26+
(`showHistoryOutput` is accepted but does not strip payloads on the gRPC path).
27+
- `client.startNew()` supports the v3 `version` option (forwarded to the core scheduler).
2128
- Removed top-level exports: `DummyOrchestrationContext`, `DummyEntityContext`, `DurableError`,
2229
`AggregatedError`, `ManagedIdentityTokenSource`, `TokenSource`. `TaskFailedError` is re-exported
2330
from the core SDK and aggregate failures surface as JS-native `AggregateError`. For orchestration

packages/azure-functions-durable/README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,17 @@ changed. See [`CHANGELOG.md`](./CHANGELOG.md) for the full list; the highlights:
2727

2828
- **Node.js >= 22** is required (v3 supported Node 18/20).
2929
- **Classic contexts no longer extend `InvocationContext`** — only `df` plus replay-safe log helpers
30-
are available (no `invocationId` / `functionName` / `extraInputs`).
30+
are available (no `invocationId` / `functionName` / `extraInputs`; the classic entity context is
31+
just `{ df }`). Reading those `InvocationContext` members inside an orchestrator is
32+
replay-nondeterministic and was never recommended.
3133
- **Task result shape follows the core SDK** — use `isComplete` / `isFailed` / `getResult()` instead
3234
of v3's `isCompleted` / `isFaulted` / `result`. `context.df.createTimer(...)` still returns a
3335
cancelable `TimerTask` for the timeout-race pattern.
34-
- **`client.getStatus()` may return `undefined`** and honors only `showInput` (`showHistory` /
35-
`showHistoryOutput` are ignored); **`client.startNew()` drops the `version` option**.
36+
- **`client.getStatus()` keeps the v3 shape** — it returns a non-optional `DurableOrchestrationStatus`
37+
and throws when the instance is missing. `showInput` / `showHistory` / `showHistoryOutput` are
38+
accepted, but `showInput` also gates output/custom status and `showHistory` returns core
39+
`HistoryEvent`s (not the v3 extension history shape). **`client.startNew()` supports the `version`
40+
option.**
3641
- **Some v3 top-level exports were removed**`DummyOrchestrationContext` / `DummyEntityContext`,
3742
`DurableError` / `AggregatedError`, and `ManagedIdentityTokenSource` / `TokenSource`.
3843
`TaskFailedError` is re-exported from the core SDK; use the core `TestOrchestrationWorker` /

packages/azure-functions-durable/src/client.ts

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,32 @@ export interface DurableFunctionsClientConfig {
3535

3636
export type DurableFunctionsClientInput = string | DurableFunctionsClientConfig;
3737

38+
/**
39+
* Options for {@link DurableFunctionsClient.getStatus} (classic Durable Functions v3 shape).
40+
*/
41+
export interface GetStatusOptions {
42+
/** Include the execution history in {@link DurableOrchestrationStatus.history}. */
43+
showHistory?: boolean;
44+
/**
45+
* Include input/output payloads in the history entries. Accepted for v3 compatibility; on the
46+
* consolidated gRPC path the core history events always carry their payloads (see
47+
* {@link DurableFunctionsClient.getStatus}).
48+
*/
49+
showHistoryOutput?: boolean;
50+
/** Include the orchestration input/output/custom-status payloads (maps to core `fetchPayloads`). */
51+
showInput?: boolean;
52+
}
53+
54+
/** Options for {@link DurableFunctionsClient.startNew} (classic Durable Functions v3 shape). */
55+
export interface StartNewOptions {
56+
/** JSON-serializable input for the orchestrator. */
57+
input?: unknown;
58+
/** Instance ID to use; a random GUID is generated when omitted. */
59+
instanceId?: string;
60+
/** Orchestration version to assign (forwarded to the core scheduler). */
61+
version?: string;
62+
}
63+
3864
export class DurableFunctionsClient extends TaskHubGrpcClient {
3965
public readonly taskHubName: string;
4066
public readonly connectionName: string;
@@ -153,41 +179,47 @@ export class DurableFunctionsClient extends TaskHubGrpcClient {
153179
*
154180
* @deprecated Use {@link scheduleNewOrchestration} instead.
155181
* @param orchestratorName - The name of the orchestrator to start.
156-
* @param options - Optional input and instance ID.
182+
* @param options - Optional input, instance ID, and version.
157183
* @returns The instance ID of the started orchestration.
158-
*
159-
* @remarks
160-
* Breaking change from v3: the v3 `version` option is not supported and is silently dropped.
161184
*/
162-
async startNew(orchestratorName: string, options?: { input?: unknown; instanceId?: string }): Promise<string> {
163-
return this.scheduleNewOrchestration(
164-
orchestratorName,
165-
options?.input,
166-
options?.instanceId !== undefined ? { instanceId: options.instanceId } : undefined,
167-
);
185+
async startNew(orchestratorName: string, options?: StartNewOptions): Promise<string> {
186+
const scheduleOptions =
187+
options?.instanceId !== undefined || options?.version !== undefined
188+
? { instanceId: options?.instanceId, version: options?.version }
189+
: undefined;
190+
return this.scheduleNewOrchestration(orchestratorName, options?.input, scheduleOptions);
168191
}
169192

170193
/**
171194
* Gets the status of an orchestration instance in the classic Durable Functions (v3) shape.
172195
*
173196
* @deprecated Use {@link getOrchestrationState} instead.
174197
* @param instanceId - The ID of the orchestration instance to query.
175-
* @param options - When `showInput` is `false`, input/output payloads are not fetched.
176-
* @returns The instance status, or `undefined` if the instance does not exist.
198+
* @param options - v3 {@link GetStatusOptions}: `showInput`, `showHistory`, `showHistoryOutput`.
199+
* @returns The instance status.
200+
* @throws If no orchestration instance with the given ID exists (matches v3, which threw on the
201+
* extension's HTTP 404 response).
177202
*
178203
* @remarks
179-
* Breaking changes from v3:
180-
* - The return type is `DurableOrchestrationStatus | undefined` (v3 returned a non-optional value),
181-
* so `(await getStatus(id)).runtimeStatus` must guard against `undefined`.
182-
* - Only `showInput` is honored. The v3 `showHistory` / `showHistoryOutput` options are not
183-
* supported and `history` is never populated.
204+
* v3 parity and its boundaries on the consolidated gRPC path:
205+
* - Returns a non-optional {@link DurableOrchestrationStatus} and throws when the instance is not
206+
* found, exactly like v3's `DurableClient.getStatus`.
207+
* - `showHistory` populates {@link DurableOrchestrationStatus.history} from the core execution
208+
* history (`getOrchestrationHistory`). The entries are core `HistoryEvent`s, not the classic
209+
* .NET extension's history shape (the gRPC path bypasses the extension's HTTP history formatter).
210+
* - `showHistoryOutput` is accepted for API compatibility but does not strip payloads from the
211+
* history events: core history events always carry their intrinsic input/output.
212+
* - `showInput` maps to the core `fetchPayloads` flag. Because that call fetches input, output, and
213+
* custom status together, `showInput: false` also omits output/custom status (v3 omitted only the
214+
* input).
184215
*/
185-
async getStatus(
186-
instanceId: string,
187-
options?: { showInput?: boolean },
188-
): Promise<DurableOrchestrationStatus | undefined> {
216+
async getStatus(instanceId: string, options?: GetStatusOptions): Promise<DurableOrchestrationStatus> {
189217
const state = await this.getOrchestrationState(instanceId, options?.showInput ?? true);
190-
return state ? toDurableOrchestrationStatus(state) : undefined;
218+
if (state === undefined) {
219+
throw new Error(`No orchestration instance with ID '${instanceId}' was found.`);
220+
}
221+
const history = options?.showHistory ? await this.getOrchestrationHistory(instanceId) : undefined;
222+
return toDurableOrchestrationStatus(state, history);
191223
}
192224

193225
/**

packages/azure-functions-durable/src/orchestration-context.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -209,14 +209,17 @@ export type ClassicOrchestrator = (
209209
* orchestrators use `context.df.*` and are wrapped so the engine drives them while `context.df`
210210
* forwards to the core {@link OrchestrationContext}.
211211
*
212-
* Detection is by generator kind, mirroring the engine's own gate (it only drives values exposing
213-
* `Symbol.asyncIterator`), not by arity:
212+
* Detection is by generator/async kind, mirroring the engine's own gate (it only drives values
213+
* exposing `Symbol.asyncIterator`), not by arity:
214214
* - `async function*` (core-native, e.g. `async function*(ctx) { yield ctx.callActivity(...) }`) is
215215
* passed through — wrapping it would swap in a classic `{ df, log }` context and silently break it.
216+
* - `async function` (core-native non-generator, e.g. `async (ctx, input) => ...`) is passed through;
217+
* the engine awaits it. A classic v3 orchestrator is never a plain async function (it must be a
218+
* generator to `yield` durable tasks), so `async` unambiguously means core-native.
216219
* - `function*` (classic v3 sync generator) is wrapped; the engine cannot drive a sync generator, so
217220
* the wrapper delegates to it via `yield*`.
218-
* - A plain (non-generator) function is ambiguous, so fall back to arity: a lone `context` parameter
219-
* is treated as classic, `(ctx, input)` as core-native.
221+
* - A plain SYNC (non-generator) function is ambiguous, so fall back to arity: a lone `context`
222+
* parameter is treated as classic, `(ctx, input)` as core-native.
220223
*/
221224
export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): TOrchestrator {
222225
if (typeof handler === "function" && isClassicOrchestrator(handler)) {
@@ -257,19 +260,22 @@ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator):
257260
/**
258261
* @hidden
259262
* Classifies a handler as a classic v3 orchestrator (must be wrapped) versus a core-native one
260-
* (must pass through). Prefers generator kind over arity so that single-parameter core-native
261-
* `async function*(ctx)` orchestrators are not mis-detected as classic.
263+
* (must pass through). Prefers generator/async kind over arity so that single-parameter core-native
264+
* `async function*(ctx)` / `async (ctx) => ...` orchestrators are not mis-detected as classic.
262265
*/
263266
function isClassicOrchestrator(handler: TOrchestrator | ClassicOrchestrator): boolean {
264267
const kind = (handler as { constructor?: { name?: string } }).constructor?.name;
265-
if (kind === "AsyncGeneratorFunction") {
266-
return false; // core-native: the engine drives it directly.
268+
if (kind === "AsyncGeneratorFunction" || kind === "AsyncFunction") {
269+
// Core-native: the engine drives async generators directly and awaits plain async
270+
// orchestrators. A classic v3 orchestrator is never a plain async function — it must be a
271+
// (sync) generator to `yield` durable tasks — so `async` unambiguously means core-native.
272+
return false;
267273
}
268274
if (kind === "GeneratorFunction") {
269275
return true; // classic v3: a sync generator the engine can't drive on its own.
270276
}
271-
// Plain/async (non-generator) function: fall back to arity. A lone `context` parameter is the
272-
// classic v3 shape; `(ctx, input)` is core-native.
277+
// Plain SYNC (non-generator) function: kind is ambiguous, so fall back to arity. A lone
278+
// `context` parameter is the classic v3 shape; `(ctx, input)` is core-native.
273279
return handler.length <= 1;
274280
}
275281

packages/azure-functions-durable/src/orchestration-status.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,16 @@ export function fromOrchestrationRuntimeStatus(status: OrchestrationRuntimeStatu
107107
*
108108
* Payloads are deserialized with `JSON.parse`, matching the plain-JSON wire contract the core client
109109
* and worker use.
110+
*
111+
* @param state - The core orchestration state to map.
112+
* @param history - Optional execution history to attach (see {@link DurableFunctionsClient.getStatus}
113+
* `showHistory`). The entries are core {@link OrchestrationState} history events; the consolidated
114+
* gRPC path surfaces core `HistoryEvent`s rather than the classic .NET extension's history shape.
110115
*/
111-
export function toDurableOrchestrationStatus(state: OrchestrationState): DurableOrchestrationStatus {
116+
export function toDurableOrchestrationStatus(
117+
state: OrchestrationState,
118+
history?: unknown[],
119+
): DurableOrchestrationStatus {
112120
return new DurableOrchestrationStatus({
113121
name: state.name,
114122
instanceId: state.instanceId,
@@ -118,6 +126,7 @@ export function toDurableOrchestrationStatus(state: OrchestrationState): Durable
118126
output: parseJson(state.serializedOutput),
119127
runtimeStatus: toOrchestrationRuntimeStatus(state.runtimeStatus),
120128
customStatus: parseJson(state.serializedCustomStatus),
129+
history,
121130
});
122131
}
123132

packages/azure-functions-durable/test/unit/client-query.spec.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ describe("DurableFunctionsClient query methods", () => {
5656
}
5757
});
5858

59-
it("getStatus returns undefined when the instance does not exist", async () => {
59+
it("getStatus throws when the instance does not exist (v3 not-found behavior)", async () => {
6060
const client = new DurableFunctionsClient(CLIENT_CONFIG);
6161
try {
6262
jest.spyOn(client, "getOrchestrationState").mockResolvedValue(undefined);
63-
await expect(client.getStatus("missing")).resolves.toBeUndefined();
63+
await expect(client.getStatus("missing")).rejects.toThrow(/No orchestration instance with ID 'missing'/);
6464
} finally {
6565
await client.stop();
6666
}
@@ -96,9 +96,7 @@ describe("DurableFunctionsClient query methods", () => {
9696
it("purgeInstanceHistory maps the core purge result", async () => {
9797
const client = new DurableFunctionsClient(CLIENT_CONFIG);
9898
try {
99-
jest
100-
.spyOn(client, "purgeOrchestration")
101-
.mockResolvedValue({ deletedInstanceCount: 1 } as never);
99+
jest.spyOn(client, "purgeOrchestration").mockResolvedValue({ deletedInstanceCount: 1 } as never);
102100

103101
const result = await client.purgeInstanceHistory("abc");
104102
expect(result).toBeInstanceOf(PurgeHistoryResult);
@@ -193,9 +191,7 @@ describe("DurableFunctionsClient query methods", () => {
193191
const client = new DurableFunctionsClient(CLIENT_CONFIG);
194192
const request = new HttpRequest({ method: "GET", url: "http://localhost:7071/api/x" });
195193
try {
196-
jest
197-
.spyOn(client, "waitForOrchestrationCompletion")
198-
.mockRejectedValue(new Error("Timed out"));
194+
jest.spyOn(client, "waitForOrchestrationCompletion").mockRejectedValue(new Error("Timed out"));
199195

200196
const response = await client.waitForCompletionOrCreateCheckStatusResponse(request, "abc", {
201197
timeoutInMilliseconds: 1000,

0 commit comments

Comments
 (0)