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
8 changes: 8 additions & 0 deletions packages/azure-functions-durable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ changed:
(testing utilities), `ManagedIdentityTokenSource`, and the entity-lock types above. `TaskFailedError`
is re-exported from the core SDK (aggregate failures surface as JS-native `AggregateError`); use the
core `TestOrchestrationWorker` / `TestOrchestrationClient` for orchestration unit tests.
- **A plain non-generator classic orchestrator is no longer supported.** A classic v3 orchestrator
written as a *synchronous, single-argument, non-generator* function `(context) => context.df.*`
(one that never `yield`s) is now treated as a **core-native** orchestrator and receives the core
`OrchestrationContext`, which has no `.df`. This resolves
[#321](https://github.com/microsoft/durabletask-js/issues/321), where a core-native
`(ctx) => ctx.instanceId` was mis-routed to the classic context. Standard classic orchestrators —
sync **generators** (`function*`) using `context.df.*` — are unaffected; convert any non-generator
classic orchestrator to generator form, or to the core-native `ctx.*` API.

## Getting started

Expand Down
16 changes: 11 additions & 5 deletions packages/azure-functions-durable/src/orchestration-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,12 @@ export type ClassicOrchestrator = (
* generator to `yield` durable tasks), so `async` unambiguously means core-native.
* - `function*` (classic v3 sync generator) is wrapped; the engine cannot drive a sync generator, so
* the wrapper delegates to it via `yield*`.
* - A plain SYNC (non-generator) function is ambiguous, so fall back to arity: a lone `context`
* parameter is treated as classic, `(ctx, input)` as core-native.
* - A plain SYNC (non-generator) function is CORE-NATIVE regardless of arity and passes through
* unchanged, receiving the core {@link OrchestrationContext}. This fixes #321 (a native
* `(ctx) => ctx.instanceId` previously mis-routed by arity to the classic context). BREAKING: a
* classic v3 orchestrator written as a plain non-generator using `context.df.*` is no longer
* supported — convert it to a `function*` generator (the standard classic shape, unaffected) or
* to the core-native `ctx.*` API.
Comment thread
YunchuWang marked this conversation as resolved.
*/
export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): TOrchestrator {
if (typeof handler === "function" && isClassicOrchestrator(handler)) {
Expand Down Expand Up @@ -274,9 +278,11 @@ function isClassicOrchestrator(handler: TOrchestrator | ClassicOrchestrator): bo
if (kind === "GeneratorFunction") {
return true; // classic v3: a sync generator the engine can't drive on its own.
}
// Plain SYNC (non-generator) function: kind is ambiguous, so fall back to arity. A lone
// `context` parameter is the classic v3 shape; `(ctx, input)` is core-native.
return handler.length <= 1;
// Plain SYNC (non-generator) function is CORE-NATIVE regardless of arity: it passes through
// unchanged and receives the core OrchestrationContext. Only a sync generator (`function*`) is
// classic (handled above). A classic v3 orchestrator written as a plain non-generator using
// `context.df.*` is no longer supported (breaking) — see #321 and the package README/CHANGELOG.
return false;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,10 @@ describe("wrapOrchestrator", () => {
});

it("exposes a replay-safe logger as context.log/error on the classic context", async () => {
// A classic orchestrator may be a plain (non-generator) function that returns a value; the
// wrapper still invokes it with the full classic context, so log wiring is exercised here.
const classic = (context: ClassicOrchestrationContext): string => {
// A classic orchestrator is a sync generator (`function*`); the wrapper invokes it with the full
// classic context, so the replay-safe log wiring is exercised here.
// eslint-disable-next-line require-yield
const classic = function* (context: ClassicOrchestrationContext): Generator<Task<unknown>, string, unknown> {
context.log("hi");
context.error("boom");
return "logged";
Expand All @@ -292,6 +293,22 @@ describe("wrapOrchestrator", () => {
expect(replaySafeLogger.info).toHaveBeenCalledWith("hi");
expect(replaySafeLogger.error).toHaveBeenCalledWith("boom");
});

it("returns a plain sync single-argument core-native orchestrator unchanged (#321)", () => {
// #321: a plain sync, single-argument, non-generator `(ctx) => value` is core-native. It must
// pass through wrapOrchestrator UNCHANGED (identity) and receive the core OrchestrationContext.
// Previously arity-based detection mis-routed it to the classic `{ df, log }` context, so
// `ctx.instanceId` was undefined and core members like `ctx.newGuid()` threw.
const native = (ctx: OrchestrationContext): string => `native:${ctx.instanceId}`;

const wrapped = wrapOrchestrator(native as unknown as TOrchestrator);
expect(wrapped).toBe(native as unknown as TOrchestrator); // identity passthrough, not wrapped

const { ctx } = createFakeCoreContext();
const out = (wrapped as unknown as (c: OrchestrationContext) => string)(ctx);
expect(out.startsWith("native:")).toBe(true);
expect(out).not.toContain("undefined");
Comment thread
YunchuWang marked this conversation as resolved.
});
});

describe("wrapOrchestrator end-to-end (real core executor)", () => {
Expand Down
Loading