Activity Heartbeat Does Not Receive Cancellation Signal When Activity is Started Inside Promise Callbacks (thenApply/thenCompose)

Summary

Activities started inside Promise.thenApply() or Promise.thenCompose() callbacks do not receive workflow cancellation signals via heartbeat, while the same activities started in the main workflow execution context receive cancellation signals correctly.

Problem Description

We have implemented a custom ActivityInboundCallsInterceptor that sends heartbeats to the Temporal server at regular intervals. When the workflow is cancelled from the Temporal UI, the heartbeat call (activityExecutionContext.heartbeat(null)) should throw ActivityCanceledException, which we catch to gracefully terminate the activity.

This works correctly when activities are started directly in the workflow execution context.

This does NOT work when activities are started inside thenApply() or thenCompose() Promise callbacks - the heartbeat returns normally instead of throwing ActivityCanceledException.

Expected Behavior

When a workflow is cancelled:

  1. All running activities (regardless of how they were scheduled) should receive the cancellation signal

  2. activityExecutionContext.heartbeat() should throw ActivityCanceledException for all activities belonging to the cancelled workflow

Actual Behavior

When a workflow is cancelled:

  1. Activities started in the main workflow context receive cancellation via heartbeat

  2. Activities started inside thenApply()/thenCompose() callbacks do NOT receive cancellation via heartbeat

  3. Only termination (not cancellation) is received for activities in Promise callbacks

Minimal Reproducible Example

Activity Interface

@ActivityInterface
public interface HelloWorldActivities {
    ComposableResult hello();
    ComposableResult hello2();
}

Activity Implementation (with long-running work)

@Component
public class HelloWorldActivitiesImpl implements HelloWorldActivities {
    private static final Logger LOGGER = LoggerFactory.getLogger(HelloWorldActivitiesImpl.class);

    @Override
    public ComposableResult hello() {
        ComposableResult result = new ComposableResult();
        for (int i = 0; i < 1000; i++) {
            LOGGER.info("Processing element: {}", i);
            Thread.sleep(2000);
            // Check for cancellation via our interceptor
            if (isCancelled()) {
                LOGGER.info("Activity cancelled!");
                break;
            }
        }
        return result;
    }

    @Override
    public ComposableResult hello2() {
        return new ComposableResult();
    }

    private boolean isCancelled() {
        try {
            Activity.getExecutionContext().heartbeat(null);
            return true;
        } catch (Exception e) {
            LOGGER.error("Exception in heartbeat: ", e);
            return false;
        }
    }
}

WORKING Pattern - Activity in main workflow context

@Override
public WorkflowResponse execute() {
    // hello2 completes first
    Promise<ComposableResult> hello2Promise = Async.function(helloWorldActivities::hello2);
    ComposableResult result = hello2Promise.get();
    
    // hello is started in MAIN workflow context
    Promise<ComposableResult> helloPromise = Async.function(helloWorldActivities::hello);
    result.merge(helloPromise.get());
    
    // When workflow is cancelled, hello's heartbeat receives ActivityCanceledException ✓
    return new WorkflowResponse(RevenueJobStatus.COMPLETED, "");
}

BROKEN Pattern - Activity inside thenApply callback

@Override
public WorkflowResponse execute() {
    Promise<ComposableResult> helloResultPromise = Async.function(helloWorldActivities::hello2)
        .thenApply((result) -> {
            // hello is started inside thenApply callback
            result.merge(Async.function(helloWorldActivities::hello).get());
            return result;
        });

    helloResultPromise.get();
    
    // When workflow is cancelled, hello's heartbeat does NOT receive ActivityCanceledException ✗
    // Only TERMINATION works, not CANCELLATION
    return new WorkflowResponse(RevenueJobStatus.COMPLETED, "");
}

BROKEN Pattern - Activity inside thenCompose callback

@Override
public WorkflowResponse execute() {
    Promise<ComposableResult> helloResultPromise = Async.function(helloWorldActivities::hello2)
        .thenCompose(result -> 
            Async.function(helloWorldActivities::hello)
                .thenApply(helloResult -> {
                    result.merge(helloResult);
                    return result;
                })
        );

    helloResultPromise.get();
    
    // When workflow is cancelled, hello's heartbeat does NOT receive ActivityCanceledException ✗
    return new WorkflowResponse(RevenueJobStatus.COMPLETED, "");
}

Steps to Reproduce

  1. Create a workflow with the “BROKEN Pattern” code above

  2. Start the workflow

  3. Wait for hello activity to start (it’s a long-running activity with 2-second sleeps)

  4. Cancel the workflow from Temporal UI (not terminate)

  5. Observe that the activity’s heartbeat thread continues to receive successful heartbeat responses

  6. The activity does NOT receive ActivityCanceledException from heartbeat() call

Additional Context

  • The ActivityExecutionContext is correctly initialized in the interceptor

  • Heartbeats ARE being sent to the server (we can see them in logs)

  • The heartbeat timeout is configured (5 minutes)

  • The same activity code receives cancellation correctly when not started from Promise callbacks

  • Termination (force kill) works in both cases - only cancellation (graceful) is affected

Workarounds Attempted

  1. Using thenCompose instead of thenApply - did not work

  2. Wrapping activity in Async.function() at utility level - did not work

Impact

This bug prevents graceful cancellation of activities that are part of Promise chains, which is a common pattern for sequential activity execution with error handling. Users are forced to either:

  1. Avoid Promise chaining entirely (limiting code organization options)

  2. Use termination instead of cancellation (no graceful cleanup)

Environment

  • Temporal Java SDK Version: 1.31.0

  • Java Version: 17