Saga management library for jvm services.
CI build | Test coverage | Release |
---|---|---|
Saga pattern is just one tool in our belt for distributed or long-running transaction management.
Nevertheless, saga is a powerful mechanism and easy readable api can help to integrate this pattern into different projects.
Microsaga library provides simple and readable api for saga actions and their compensations, giving possibility to declare sagas in composable way.
Inspired by cats-saga.
Contains one and only dependency to failsafe which allows to use retry behavior in a flexible way.
Additional example of usage can be found in this article: Saga management in Java with microsaga library.
More complex example with Spring Boot, persistence layer and re-compensations can be found in simpleservice example.
Add dependency to your project with gradle
:
implementation group: 'io.github.rmaiun', name: 'microsaga', version: '1.1.0'
or you can use another build tools.
Actual version also could be checked at mvnrepository.com
To declare Saga
we need to have some saga steps. To create saga step, we need to prepare 2 main parts:
- action, which is mandatory
- compensation, which is optional
Action represents the logical block, which is a part of long-running transaction.
It consists of name and Callable<A>
action, which are mandatory attributes and optionally allows to describe RetryPolicy<A>
retryPolicy.
saga
. Sagas
class contains a lot of useful methods to cooperate with sagas and their parts.
To create saga action we can easily call:
Saga<User> createUserAction = Sagas.action("createUser",() -> myService.createUser(user));
// or using retry policy
Saga<User> createUserAction= Sagas.retryableAction("createUser", () -> myService.createUser(user), new RetryPolicy<>().withMaxRetries(3));
Action can have a compensation, which can be also created using Sagas
class:
SagaCompensation removeUserCompensation = Sagas.compensation("removeUserFromDb",
() -> userService.deleteUserByCriteria());
// or using retry policy
SagaCompensation removeUserCompensation = Sagas.retryableCompensation("removeUserFromDb",
() -> userService.deleteUserByCriteria(),
new RetryPolicy<>().withDelay(Duration.ofSeconds(2)));
The main difference here is that action is Callable<A>
because next action can be dependent on result of previous one.
Compensation is Runnable
because it hasn't any dependency to other ones.
While we have both action and compensation, we can combine them to some saga step:
Saga<User> saga = createUserAction.compensate(removeUserCompensation);
// or we can declare full saga in a one place
Saga<User> saga = Sagas.action("createUser",() -> myService.createUser(user))
.retryableCompensation("removeUserFromDb",
() -> userService.deleteUserByCriteria(), new RetryPolicy<>().withDelay(Duration.ofSeconds(2)));
There different combination operators available in microsaga library:
- then() sequentially runs 2 saga steps where second step doesn't require output of first step
sagaStep1.then(sagaStep2);
- flatmap() gives possibility for second step to consume output dto of first step as input param
sagaStep1.flatMap(step1DtoOut -> runSagaStep2(step1DtoOut));
- zipWith uses the same principle as flatMap but is extended with transformer function, which can change output dto based on input and output of particular saga step
sagaStep1.zipWith(step1DtoOut -> runSagaStep2(step1DtoOut), (step2Input,step2Output) -> new Step2ResultModified());
// or if you don't need step1DtoOut
sagaStep1.zipWith(runSagaStep2(), step2Output -> new Step2ResultModified());
By default saga will use UUID.randomUUID()
as sagaId. User can customize saga identification using
sagaManager.saga(someSaga).withId("customSagaId")
approach.
However, there is possibility to pass sagaId implicitly to any action or compensation. To do this, need to use different api.
For example:
// action with sagaId
Sagas.action("testAction", sagaId -> doSomething(sagaId, dtoIn));
// compensation with sagaId
Sagas.compensation("compensation#1", sagaId -> deleteAllBySagaId(sagaId));
Saga runner will use predefined sagaId and propagate it to all actions and compensations which need it.
Saga supports lazy evaluation, so it will not be run until we ask for it.
To launch it, we should create instance of SagaManager
or call SagaManager.use(saga)
static method. This class is responsible for saga transactions
so lets run our saga:
// returns EvaluationResult (1)
EvaluationResult<User> result = SagaManager.use(saga).transact();
// returns value or throws RuntimeException (2)
User user = SagaManager.use(saga).transactOrThrow();
where
1 - EvaluationResult contains value or exception with evaluation history, which included steps with calculation time and saga name, which can be customized using SagaManager
2 - there are 2 types of exceptions SagaActionFailedException
and SagaCompensationFailedException
related to particular failed saga part. However, user can define exception transformer.
As it was mentioned above, saga steps are composable, so it is possible to write quite complex sagas, like:
AtomicInteger x = new AtomicInteger();
Saga<Integer> saga = Sagas.action("initX", x::incrementAndGet).compensate("intoToZero", () -> x.set(0))
.then(Sagas.action("multiplyBy2", () -> x.get() * 2).compensate("divideBy2", () -> x.set(x.get() / 2)))
.flatmap(a -> Sagas.action("intoToString", a::toString).withoutCompensation())
.flatmap(str -> Sagas.retryableAction("changeString", () -> "prefix=" + str, new RetryPolicy<String>().withMaxRetries(2)).withoutCompensation())
.map(res -> res.split("=").length);
EvaluationResult
includes value or error with the history of evaluations. Each evaluation in EvaluationHistory
contains information about name, type, duration and result of particular evaluation.
Need to mention that evaluation result itself has useful methods for result processing, such as:
valueOrThrow
returns value if saga evaluation finishes successfully or throws evaluation errororElseThrow
void method that returns nothing if saga result is ok or throws an errorvalueOrThrow(Function<Throwable, ? extends RuntimeException> errorTransformer)
is the same as previous one, but gives possibility to transform errorpeek, peekValue, peekError
apply side effect toEvaluationResult
, its value or errorfold
takes value and error transformers (A -> B, error -> B) as input params and returns transformed value as a resultadaptError
applies error transformer (err -> A) to evaluation result in case it was unsuccessful or returns normal result
In alternating scenario evaluation result can contain one of2 runtime errors: SagaActionFailedException
and SagaCompensationFailedException
.
Example of EvaluationResult
processing:
Saga<String> saga = ...;
Function<RuntimeException, String> errorAdopter = err -> {
if (err instanceof SagaActionFailedException) {
return "default result";
} else {
throw err;
}
};
String result = SagaManager.use(saga)
.transact()
.peekValue(v -> logger.info("Obtained value {}", v))
.adaptError(errorAdopter)
.valueOrThrow();