durable-functions v4: fix orchestrator context routing for plain sync single-arg orchestrators (#321)#322
Conversation
… a dual context (#321) A plain synchronous, single-argument, non-generator orchestrator is an ambiguous shape: the identical `(ctx) => ...` signature is used both by a classic v3 body (`context.df.*`) and a core-native body (`ctx.newGuid()`). `wrapOrchestrator` routed it into the classic wrapper and handed it a `{ df, log }`-only context, so core members threw `TypeError: ctx.<method> is not a function` (or read as undefined). Wrap that ambiguous shape with a dual context: a Proxy over the core OrchestrationContext with a classic `df` + replay-safe log overlay, so both the classic and core-native readings work. Forwarded core members stay bound to the real context so getters read live replay state. Unambiguous shapes (async generators, plain async, sync generators, and 2-arg `(ctx, input)` handlers) are unchanged. Adds unit + real-executor regression tests and a CHANGELOG entry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cc772da9-0c2b-4b8e-9b82-6e17377422bd
There was a problem hiding this comment.
Pull request overview
This PR fixes a routing bug in the durable-functions v4 compatibility layer where plain synchronous, single-argument, non-generator orchestrator functions were misclassified and given a classic-only context, breaking core-native access to OrchestrationContext members. The fix introduces a dual context (classic overlay + core forwarding via Proxy) so the ambiguous handler shape works for both classic and core-native intent without changing behavior for unambiguous orchestrator shapes.
Changes:
- Add
createClassicContext()that overlays classicdf+ replay-safe logging while forwarding all other members to the coreOrchestrationContext(#321). - Update orchestrator classification docs and wiring to use the dual context for the ambiguous single-arg sync case.
- Add unit + “real executor” regression tests covering core-member access and combined classic+core usage on the same context.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/azure-functions-durable/src/orchestration-context.ts | Introduces the dual context Proxy overlay to satisfy both classic and core-native surfaces for ambiguous sync single-arg orchestrators. |
| packages/azure-functions-durable/test/unit/orchestration-context.spec.ts | Adds unit + end-to-end (in-memory executor) regression tests validating the dual context behavior. |
| packages/azure-functions-durable/CHANGELOG.md | Documents the behavioral fix and the affected orchestrator shape. |
| const dual = new Proxy(ctx, { | ||
| get(target, prop): unknown { | ||
| if (prop in overlay) { | ||
| return overlay[prop]; | ||
| } |
| has(target, prop): boolean { | ||
| return prop in overlay || prop in target; | ||
| }, |
Adds a real func-host e2e that drives every orchestrator shape through wrapOrchestrator: classic v3 sync generator, core-native async generator, and the ambiguous plain sync/async single-arg non-generator (ctx) => value. The ShapeSyncNative case is the #321/#322 regression guard (pre-fix it completed with the garbage output sync-native:undefined). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ba01db4b-74a7-4cd1-b6ee-d20f94d34235
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
packages/azure-functions-durable/src/orchestration-context.ts:288
prop in overlaychecks the prototype chain, so the proxy will treat keys like "constructor"/"toString" (fromObject.prototype) as overlay hits and returnoverlay[prop]instead of the real core-context member. Since the overlay is intended to override only its own keys (df,log, etc.), switch to an own-property check (e.g.,Object.hasOwn) in both thegetandhastraps.
get(target, prop): unknown {
if (prop in overlay) {
return overlay[prop];
}
// Read from the real core context (never the proxy) so getters and `this`-bound methods run
…ative (#321) Reverses the dual-context approach: a plain sync, single-argument, non-generator orchestrator (ctx) => value is now treated as core-native and receives the core OrchestrationContext directly. Removes the Proxy dual context. BREAKING: a classic v3 orchestrator written as a plain non-generator using context.df.* is no longer supported; use function* (unaffected) or the core-native ctx.* API. Documented in README + CHANGELOG. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ba01db4b-74a7-4cd1-b6ee-d20f94d34235
| * Regression guard for #321/#322: a plain SYNC, single-argument, NON-generator orchestrator | ||
| * `(ctx) => value` is the ambiguous shape. Before #322 it was mis-detected as classic and received a | ||
| * `{ df, log }` context with no core members, so `ctx.instanceId` was `undefined` and it silently | ||
| * completed with the garbage output `sync-native:undefined`. After #322 it is driven with the dual | ||
| * context and returns `sync-native:<instanceId>`. |
| * - A plain SYNC (non-generator) function is treated as CORE-NATIVE and passes through unchanged, | ||
| * regardless of arity, receiving the core {@link OrchestrationContext}. This resolves #321 | ||
| * (`(ctx) => ctx.instanceId` now works). 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. |
Summary
Fixes #321. Triages #315, #317, #318 at the bottom (no code for those — each needs a maintainer decision, an engine primitive, or cross-repo work; details below).
The bug (#321)
In the
durable-functionsv4 compat package,wrapOrchestratorclassifies an orchestrator as classic v3 (wrap it, give it{ df, log }) vs core-native (pass it through to the engine). Detection prefers generator/async kind, but a plain synchronous, single-argument, non-generator function is an ambiguous shape — the identical(ctx) => ...signature is used by both:The old code fell back to arity and routed both into the classic wrapper, handing the body a
{ df, log }-only context. So a core-native body like(ctx) => ctx.newGuid()blew up withTypeError: ctx.newGuid is not a function, andctx.instanceIdread asundefined.The fix — a dual context
Rather than guess which of the two indistinguishable intents the user meant, wrap the ambiguous shape with a dual context that satisfies both surfaces:
Proxyover the coreOrchestrationContext(soctx.newGuid(),ctx.instanceId,ctx.callActivity(...), etc. forward to the real engine context), plusdf,getInput,log/info/warn/error/debug/trace).The overlay wins for its own keys; everything else forwards to the core context. Forwarded methods are
.bind-ed to the real context (never the proxy) so getters read live replay state and can't recurse through the trap.This is replay-safe: every forwarded core member is already replay-safe, and logging goes through
ctx.createReplaySafeLogger, so a classic body reaching a core method introduces no new hazard.Why this approach: it's the only option that breaks nothing.
app.orchestration.classic(...)) doesn't fit the singleapp.orchestration()entry point.async function*, plainasync, syncfunction*, and 2-arg(ctx, input)handlers all route exactly as before.Tests
orchestration-context.spec.ts): a plain-sync single-arg(ctx) => \${ctx.instanceId}:${ctx.newGuid()}:${ctx.getInput()}`now resolves against the core context; a body using **both**context.df.***and**ctx.newGuid()` proves both surfaces coexist on one object.(ctx) => \sync-native:${ctx.instanceId}`scheduled with a known instance id completes with"sync-native:"— before the fix it produced"sync-native:undefined"`.Triage of the other three issues (no code in this PR)
#315 — opaque
2 UNKNOWNon terminate/suspend/resume/raiseEvent against completed/missing instancesThe client-side half is already shipped (PR #282):
client.ts's_mapControlPlaneErrorlooks up the real runtime state viagetOrchestrationStateand maps terminal → no-op, wrong-non-terminal-state → a friendlyCannot <op> ... in the <State> state., and missing → not-found (covered by existingclient.spec.tstests). The remaining work is server-side (the Durable Task Scheduler / extension returningFAILED_PRECONDITION+ detail instead ofUNKNOWN) plus un-skipping the host e2e specs — both live outside this repo. Recommend tracking those under the extension repo.#317 — restore the v3 entity-lock API (
context.df.isLocked()/lock())isLocked()is feasible (map to coreisInCriticalSection().inSection), but it's low value withoutlock(). A faithfullock()bridge must turn the coreTask<LockHandle>into a v3DurableLock(needsownedLocks,release(),[Symbol.dispose],isReleased,inFlightEntityCalls), and coreTask<T>has no.then/.map/derived-task primitive to do that transform. A correct bridge needs an engine-level, determinism-safeTask.map, which is out of scope for a compat-package PR. Recommend a dedicated engine PR (or shippingisLocked()alone only if a consumer specifically needs it).#318 — restore
callHttpas a durable activityToday
DurableOrchestrationContext.callHttpthrows (no core equivalent). Restoring it means auto-registering a fetch-based activity — but in this compat model activities are host-invoked Azure Functions (app.activity()→azFuncApp.generic), not run inside the worker, so this changes the app's Function surface and moves the outbound-HTTP trust boundary into the app process. That's a design decision the issue itself flags, so it needs a maintainer call before implementation.Closes #321.