Skip to content

Commit dd8f53e

Browse files
authored
Fix #291: accept EVENTSENT/EVENTRAISED confirmation for entity calls on classic (Azure Storage) backend (#294)
1 parent 333e1c1 commit dd8f53e

2 files changed

Lines changed: 378 additions & 3 deletions

File tree

packages/durabletask-js/src/worker/orchestration-executor.ts

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)