@@ -413,6 +413,17 @@ export class OrchestrationExecutor {
413413 // Event names are case-insensitive
414414 const eventName = event . getEventraised ( ) ?. getName ( ) ?. toLowerCase ( ) ;
415415
416+ // On the classic (Azure Storage / DurableTask.Core) backend, an entity call's response is
417+ // delivered as an EVENTRAISED whose name equals the entity call's requestId (a lowercase GUID)
418+ // and whose input is a DTFx ResponseMessage JSON, rather than as an ENTITYOPERATIONCOMPLETED.
419+ // Route it back to the pending entity call so the callEntity task resolves/rejects. On DTS this
420+ // branch never fires (responses arrive as ENTITYOPERATIONCOMPLETED and the pending call is
421+ // removed by handleEntityOperationCompleted before any EVENTRAISED could match).
422+ if ( eventName && ctx . _entityFeature . pendingEntityCalls . has ( eventName ) ) {
423+ await this . handleEntityResponseFromEventRaised ( ctx , eventName , event ) ;
424+ return ;
425+ }
426+
416427 if ( ! ctx . _isReplaying ) {
417428 WorkerLogs . orchestrationEventRaised ( this . _logger , ctx . _instanceId , eventName ! ) ;
418429 }
@@ -462,16 +473,147 @@ export class OrchestrationExecutor {
462473 }
463474 }
464475
476+ /**
477+ * Handles an entity operation response that arrived as an EVENTRAISED event on the classic
478+ * (Azure Storage / DurableTask.Core) backend. In that code path the Durable Functions gRPC shim
479+ * wraps entity responses in a DTFx ResponseMessage JSON keyed by the call's requestId, rather than
480+ * emitting an ENTITYOPERATIONCOMPLETED proto event. This decodes the wrapper and completes (or
481+ * fails) the pending callEntity task. Mirrors the Java SDK's handleEntityResponseFromEventRaised.
482+ */
483+ private async handleEntityResponseFromEventRaised (
484+ ctx : RuntimeOrchestrationContext ,
485+ requestId : string ,
486+ event : pb . HistoryEvent ,
487+ ) : Promise < void > {
488+ const pendingCall = ctx . _entityFeature . pendingEntityCalls . get ( requestId ) ;
489+ if ( ! pendingCall ) {
490+ return ;
491+ }
492+
493+ // Remove from pending calls and recover any critical-section lock before completing.
494+ ctx . _entityFeature . pendingEntityCalls . delete ( requestId ) ;
495+ ctx . _entityFeature . recoverLockAfterCall ( pendingCall . entityId ) ;
496+
497+ const decoded = this . decodeEntityResponseMessage ( event . getEventraised ( ) ?. getInput ( ) ?. getValue ( ) ) ;
498+
499+ if ( decoded . isFailure ) {
500+ const exception = new EntityOperationFailedException ( pendingCall . entityId , pendingCall . operationName , {
501+ errorType : decoded . errorType ,
502+ errorMessage : decoded . errorMessage ,
503+ stackTrace : decoded . stackTrace ,
504+ } ) ;
505+ pendingCall . task . failWithError ( exception ) ;
506+ } else {
507+ pendingCall . task . complete ( decoded . result ) ;
508+ }
509+
510+ await ctx . resume ( ) ;
511+ }
512+
513+ /**
514+ * Decodes a DTFx ResponseMessage JSON wrapper (as delivered via EVENTRAISED on the classic
515+ * backend) into either a success result or a failure. Mirrors the Java SDK's decoding, with one
516+ * deliberate enhancement (see below).
517+ *
518+ * The wrapper shape is:
519+ * - `result` — the serialized operation result (double-encoded string; may be null/absent).
520+ * - `exceptionType` — present ⇒ failure. Misleading name: it carries the error message string
521+ * (the C# property is ErrorMessage but its [DataMember(Name = "exceptionType")]
522+ * overrides the JSON key). Omitted when null.
523+ * - `failureDetails` — optional structured `{ ErrorType, ErrorMessage, StackTrace, ... }` (PascalCase).
524+ *
525+ * NOTE (intentionally beyond the Java SDK): the Java SDK builds `new FailureDetails(errorType,
526+ * errorMessage, null, false)`, dropping the stack trace. Over gRPC the classic backend uses
527+ * DurableTask.Core native entities, whose ResponseMessage.FailureDetails carries a real StackTrace,
528+ * so we propagate it into `EntityOperationFailedException.failureDetails.stackTrace`. Do not "fix"
529+ * this back to null for Java parity — the extra fidelity is the point.
530+ */
531+ private decodeEntityResponseMessage (
532+ rawInput : string | undefined ,
533+ ) :
534+ | { isFailure : false ; result : any }
535+ | { isFailure : true ; errorType : string ; errorMessage : string ; stackTrace ?: string } {
536+ if ( rawInput === undefined || rawInput === "" ) {
537+ return { isFailure : false , result : undefined } ;
538+ }
539+
540+ let parsed : any ;
541+ try {
542+ parsed = JSON . parse ( rawInput ) ;
543+ } catch {
544+ // Not JSON at all — treat the raw string as the result value.
545+ return { isFailure : false , result : rawInput } ;
546+ }
547+
548+ const isObject = parsed !== null && typeof parsed === "object" && ! Array . isArray ( parsed ) ;
549+ if ( ! isObject || ! Object . prototype . hasOwnProperty . call ( parsed , "result" ) ) {
550+ // Not a recognized ResponseMessage wrapper — the parsed value itself is the result.
551+ // (Mirrors Java's fallback to direct deserialization for a possible future raw-result format.)
552+ return { isFailure : false , result : parsed } ;
553+ }
554+
555+ const exceptionType = parsed . exceptionType ;
556+ const failureDetails = parsed . failureDetails ;
557+ const hasExceptionType = exceptionType !== undefined && exceptionType !== null ;
558+ const hasFailureDetails = failureDetails !== undefined && failureDetails !== null ;
559+
560+ if ( hasExceptionType || hasFailureDetails ) {
561+ // The "exceptionType" JSON field actually carries the error message (misleading name).
562+ let errorMessage = hasExceptionType ? String ( exceptionType ) : "Entity operation failed" ;
563+ let errorType = "unknown" ;
564+ let stackTrace : string | undefined ;
565+
566+ if ( hasFailureDetails && typeof failureDetails === "object" ) {
567+ if ( failureDetails . ErrorType !== undefined && failureDetails . ErrorType !== null ) {
568+ errorType = String ( failureDetails . ErrorType ) ;
569+ }
570+ if ( failureDetails . ErrorMessage !== undefined && failureDetails . ErrorMessage !== null ) {
571+ errorMessage = String ( failureDetails . ErrorMessage ) ;
572+ }
573+ if ( failureDetails . StackTrace !== undefined && failureDetails . StackTrace !== null ) {
574+ stackTrace = String ( failureDetails . StackTrace ) ;
575+ }
576+ }
577+
578+ return { isFailure : true , errorType, errorMessage, stackTrace } ;
579+ }
580+
581+ // Success — extract the inner (double-encoded) result value.
582+ const resultNode = parsed . result ;
583+ if ( resultNode === null || resultNode === undefined ) {
584+ return { isFailure : false , result : undefined } ;
585+ }
586+
587+ const innerResult = typeof resultNode === "string" ? resultNode : JSON . stringify ( resultNode ) ;
588+ try {
589+ return { isFailure : false , result : JSON . parse ( innerResult ) } ;
590+ } catch {
591+ // Defensive: if the inner payload isn't valid JSON, use the raw string.
592+ return { isFailure : false , result : innerResult } ;
593+ }
594+ }
595+
465596 private async handleEventSent ( ctx : RuntimeOrchestrationContext , event : pb . HistoryEvent ) : Promise < void > {
466597 // This history event confirms that a sendEvent action was successfully processed by the sidecar.
467598 const eventId = event . getEventid ( ) ;
468599 const action = ctx . _pendingActions [ eventId ] ;
469600
470- const isSendEventAction = action ?. hasSendevent ( ) ;
471-
472601 if ( ! action ) {
473602 throw getNonDeterminismError ( eventId , getName ( ctx . sendEvent ) ) ;
474- } else if ( ! isSendEventAction ) {
603+ }
604+
605+ // On the classic (Azure Storage / DurableTask.Core) backend, entity operations do not use
606+ // the DTS entity protocol. The Durable Functions gRPC shim translates a sendEntityMessage
607+ // action (call / signal / lock / unlock) into a classic send-event, so its confirmation
608+ // arrives here as an EVENTSENT rather than as ENTITYOPERATIONCALLED/SIGNALED. Treat that as
609+ // a valid confirmation: remove the pending action so it isn't re-sent. The matching entity
610+ // response (for a call) is delivered later as an EVENTRAISED and routed by handleEventRaised.
611+ if ( action . hasSendentitymessage ( ) ) {
612+ delete ctx . _pendingActions [ eventId ] ;
613+ return ;
614+ }
615+
616+ if ( ! action . hasSendevent ( ) ) {
475617 const expectedMethodName = getName ( ctx . sendEvent ) ;
476618 throw new NonDeterminismError (
477619 `A previous execution called ${ expectedMethodName } with ID=${ eventId } , but the current execution is instead trying to call a different method as part of rebuilding its history.` ,
0 commit comments