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;
}
}