Update-With-Start where Update method awaits value change from Workflow method

I have a question about Update-With-Start patterns.

tl;dr: does the below proposed implementation snippet below work reliably, specifically the await call in the Update method that relies on a changing value in the Workflow method?

My team is looking at utilizing Update-With-Start for the first time (we had another use case where we considered it but went in a different direction) for a Saga pattern. The end goal is that we can return the success/failure result of the Saga as soon as possible, even before compensation steps have been run. We also want the retry parameters for the initial saga steps to be different than the compensation steps.

My initial thought was to have the Update method run the forward steps, and, in the case of error, alter a state value to indicate to the Workflow method that it should run the compensation steps. However, this makes it such that running the workflow method without Update-With-Start is a no-op. Not harmful, exactly, but a potentially unexpected consequence for future callers.

Then an engineer on my team proposed the following. (I’ve simplified it to remove extraneous details.) I haven’t seen any examples like this, so I was curious if it worked, specifically using a Workflow.await in the Update method, that then relies on the Workflow method running an altering a value for the Update method to return. (We have some tests of the usage which seem to suggest it works, though it’s possible those tests could have errors.)

Proposed Implementation

class MySagaWorkflowImpl {
  private final ForwardActivities forwardActivities =
        Workflow.newActivityStub(
            ForwardActivities.class,
            ActivityOptions.newBuilder().maxAttempts(5).build());

  private final ReverseActivities reverseActivities =
      Workflow.newActivityStub(
          ReverseActivities.class,
          ActivityOptionsDefaults.atLeastOnce()); // helper for idempotent activities

  @Nullable private success;
  
  @WorkflowMethod
  boolean run() {
    Saga saga = new Saga();

    try {
      saga.addCompensation(reverseActivities::reverseOne);
      forwardActivities.one();

      saga.addCompensation(reverseActivities::reverseTwo);
      forwardActivities.two();

      saga.addCompensation(reverseActivities::reverseThree);
      forwardActivities.three();

      success = true;
    } catch (TemporalFailure failure) {
      success = false;
      saga.compensate();
    }

    return success;
  }

  @UpdateMethod
  boolean runWithAsyncCompenstation() {
    Workflow.await(() -> success != null);
    return success;
  }
}


// Run workflow and get result *after* Saga completes successfully
mySagaWorkflow.run()

// Run workflow and get result, even though Saga compensation may still be executing
workflowClient.executeUpdateWithStart(
        mySagaWorkflow::update, updateOptions, mySagaWorkflow::run);

In this implementation, all the Saga logic remains in the workflow method, meaning it could be invoked and the logic would operate as expected. The Update method, then, is just a optimization if the caller merely wants the result boolean synchronously, rather than the full knowledge that the Saga has compensated. (See caller examples at the bottom.)

My alternative implementation is as follows:

Alternative Implementation

class MySagaWorkflowImpl {
  private final ForwardActivities forwardActivities =
        Workflow.newActivityStub(
            ForwardActivities.class,
            ActivityOptions.newBuilder().maxAttempts(5).build());

  private final ReverseActivities reverseActivities =
      Workflow.newActivityStub(
          ReverseActivities.class,
          ActivityOptionsDefaults.atLeastOnce()); // helper for idempotent activities

  private Saga saga = new Saga();
  @Nullable private success;
  
  @WorkflowMethod
  boolean run() {
    if (!success) {
      saga.compensate();
    }

    return success;
  }

  @UpdateMethod
  boolean getAttemptOutput() {
    try {
      saga.addCompensation(reverseActivities::reverseOne);
      forwardActivities.one();

      saga.addCompensation(reverseActivities::reverseTwo);
      forwardActivities.two();

      saga.addCompensation(reverseActivities::reverseThree);
      forwardActivities.three();

      success = true;
    } catch (TemporalFailure failure) {
      success = false;
    }
    return success;
  }
}

Your alternative implementation is not correct. The issue is that the run method doesn’t wait for the update method to complete. In case of update with start the update method is invoked before the run method. But the moment the update method blocks the run method is woken up. Then it completes, completing the workflow, and the update never returns (or returns workflow completed failure).

So you have to call await on a condition. Consider awaiting on Workflow.isEveryHandlerFinished().