Skip to content

rmaiun/microsaga

Repository files navigation

Screenshot

Saga management library for jvm services.

Support Ukraine

CI build Test coverage Release
Build Status Coverage Status Release Artifacts

Summary

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.

Usage

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

Api description

Core Components

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

Available Operators

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());

Saga identification

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.

Evaluation

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 error
  • orElseThrow void method that returns nothing if saga result is ok or throws an error
  • valueOrThrow(Function<Throwable, ? extends RuntimeException> errorTransformer) is the same as previous one, but gives possibility to transform error
  • peek, peekValue, peekError apply side effect to EvaluationResult, its value or error
  • fold takes value and error transformers (A -> B, error -> B) as input params and returns transformed value as a result
  • adaptError 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();