I’m processing a batch of actions and the result of all actions should be combined in a single final artifact. Individual actions are allowed to fail technically until max-attempts and be reported back separately while the rest of the batch continues.
How do I handle the failing activity?
At first I thought of a SAGA compensation, but that is to undo (recover) from successful previous actions. A workaround would be to define a compensation before attempting the activity, but that would be a workaround for something that warrants first class recovery (in generally accepted resilience theory). Alternatively, but even worse imo, is to make the activity aware of the retryOptions and explicitly recover with a different value on the maxth attempt. Yet another workaround would be to put a try…catch around the activity invocation in the workflow code and catch ActivityFailure and then decide what to do, but in my opinion this is the wrong abstraction level.
The recovery logic should be on the activity level, part of the retryOptions or marked with @RecoveryMethod
annotation or an interface an activity can implement where you define the recovery.
So, is there a neat way to define recovery logic for when an activity fails definitively as per retryOptions? As I said I don’t need the whole flow to fail.
Something like this would be ideal:
@ActivityInterface
public interface MyActivity {
@ActivityMethod(name = "My description")
MyResult foo(Object input);
@RecoveryMethod(name = "Do this instead", includeExceptions = { SomeException.class }) // or whatever would be reasonable
MyResult bar(Object input, String type, String msg);
}
As a workaround, to achieve the same result transparently for all recoverable activities I might have, I’m currently employing the following setup. It requires more interface boilerplate and generics than I like, but the end usage is very clean and works across activities:
Generic workaround
interface InvokeActivity<I,O> {
O invoke(I input);
}
interface RecoverableActivity<I,O> {
O recover(I input, String type, String msg);
}
static <I,O> O tryOrRecover(RecoverableActivity<I, O> r, InvokeActivity<I, O> ia, I i) {
try {
return ia.invoke(i);
} catch (ActivityFailure e) {
ApplicationFailure cause = (ApplicationFailure) e.getCause();
return r.recover(i, cause.getType(), cause.getOriginalMessage());
}
}
Then my activity is as follows:
@ActivityInterface
public interface MyActivity extends RecoverableActivity<MyInput, MyOutput> {
@ActivityMethod(name = "Do foo")
MyOutput foo(MyInput input);
}
public class MyActivityImpl implements MyActivity {
@Override
public MyOutput foo(MyInput input) {
return ...result;
}
@Override
public MyOutput recover(MyInput input, String type, String msg) {
return ...fallback;
}
}
This makes invocation from the workflow very clean:
MyOutput result = tryOrRecover(myActivity, myActivity::foo, inputValue);
Unfortunately, because RecoverableActivity uses generics, Temporal incorrectly concludes the first parameter of the method is Object, rather than the passed in generic type. So Temporal is attempting to pass the original input object as a LinkedHashMap resulting in a ClassCastException.
To fix this, the activity should not directly implement this interface, but just the method that complies with the method signature so it can be coalesced as a functional interface:
MyOutput result = tryOrRecover(myActivity:recover, myActivity::foo, inputValue);
This actually works and satisfies my need. However, I wish this was built in.