Skip to content

durable-functions v4: fix orchestrator context routing for plain sync single-arg orchestrators (#321)#322

Closed
YunchuWang wants to merge 3 commits into
mainfrom
yunchuwang-fix-classic-orchestrator-dual-context
Closed

durable-functions v4: fix orchestrator context routing for plain sync single-arg orchestrators (#321)#322
YunchuWang wants to merge 3 commits into
mainfrom
yunchuwang-fix-classic-orchestrator-dual-context

Conversation

@YunchuWang

Copy link
Copy Markdown
Member

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-functions v4 compat package, wrapOrchestrator classifies 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:

app.orchestration("classic", (context) => context.df.callActivity("A"));   // classic v3
app.orchestration("native",  (ctx)     => ctx.newGuid());                   // core-native

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 with TypeError: ctx.newGuid is not a function, and ctx.instanceId read as undefined.

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:

  • a Proxy over the core OrchestrationContext (so ctx.newGuid(), ctx.instanceId, ctx.callActivity(...), etc. forward to the real engine context), plus
  • an overlay providing the classic surface (df, 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.

  • Flip the arity default (treat single-arg as core-native) reverses documented behavior and breaks the existing "replay-safe logger on the classic context" test.
  • Explicit tagging (app.orchestration.classic(...)) doesn't fit the single app.orchestration() entry point.
  • The dual context is a strict superset of both surfaces, so unambiguous shapes are untouched: async function*, plain async, sync function*, and 2-arg (ctx, input) handlers all route exactly as before.

Tests

  • Unit (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.
  • Real-executor regression (in-memory backend + worker + client): a plain-sync (ctx) => \sync-native:${ctx.instanceId}`scheduled with a known instance id completes with"sync-native:"— before the fix it produced"sync-native:undefined"`.
  • Full package unit suite: 105/105 green. Build + ESLint + Prettier clean.

Triage of the other three issues (no code in this PR)

#315 — opaque 2 UNKNOWN on terminate/suspend/resume/raiseEvent against completed/missing instances

The client-side half is already shipped (PR #282): client.ts's _mapControlPlaneError looks up the real runtime state via getOrchestrationState and maps terminal → no-op, wrong-non-terminal-state → a friendly Cannot <op> ... in the <State> state., and missing → not-found (covered by existing client.spec.ts tests). The remaining work is server-side (the Durable Task Scheduler / extension returning FAILED_PRECONDITION + detail instead of UNKNOWN) 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 core isInCriticalSection().inSection), but it's low value without lock(). A faithful lock() bridge must turn the core Task<LockHandle> into a v3 DurableLock (needs ownedLocks, release(), [Symbol.dispose], isReleased, inFlightEntityCalls), and core Task<T> has no .then/.map/derived-task primitive to do that transform. A correct bridge needs an engine-level, determinism-safe Task.map, which is out of scope for a compat-package PR. Recommend a dedicated engine PR (or shipping isLocked() alone only if a consumer specifically needs it).

#318 — restore callHttp as a durable activity

Today DurableOrchestrationContext.callHttp throws (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.

… 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
Copilot AI review requested due to automatic review settings July 22, 2026 18:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 classic df + replay-safe logging while forwarding all other members to the core OrchestrationContext (#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.

Comment on lines +283 to +287
const dual = new Proxy(ctx, {
get(target, prop): unknown {
if (prop in overlay) {
return overlay[prop];
}
Comment on lines +293 to +295
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
Copilot AI review requested due to automatic review settings July 22, 2026 20:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 overlay checks the prototype chain, so the proxy will treat keys like "constructor"/"toString" (from Object.prototype) as overlay hits and return overlay[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 the get and has traps.
    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
Copilot AI review requested due to automatic review settings July 22, 2026 23:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment on lines +13 to +17
* 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>`.
Comment on lines +221 to +225
* - 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.
@YunchuWang YunchuWang closed this Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[durable-functions v4] isClassicOrchestrator can't distinguish a plain sync single-arg core-native orchestrator from a classic one

2 participants