En este artículo, primero de una nueva serie de video blogs, voy a comentarles respecto a SAGAS, ¿qué es ese patrón? y ¿cómo implementarlo con Quarkus? Si quieres ver el video, puedes observarlo aquí.

En la primera sección cubriremos la teoría general al respecto y en la segunda parte haremos una implementación de ejemplo con Microprofile y Quarkus.

La mayoria de nosotros estamos familiarizados con el concepto de transacciones ACID, así que vamos a iniciar comparando estas con SAGAS.

En primer lugar, las transacciones ACID toman su nombre por las siglas en inglés de sus 4 características fundamentales:

  • Atomicidad Esto quiere decir que cada transacción es tratada como una sola unidad; la cuál finaliza exitosamente como un todo o falla completamente. Si alguno de las sentencias en la transacción falla, la transacción completa es reversada y la base de datos queda sin cambios. Como consecuencia, una transaccion no puede ser observada en proceso por otro cliente de la base de datos; en un momento X del tiempo no ha sucedido y al siguiente ha ocurrido como un todo (o nada ha ocurrido se cancela).

  • Consistencia. Asegura que una transaccion sólo puede llevar a la base de datos de un estado válido a otro; los datos escritos en la base de datos deben ser válidos de acuerdo a todas las reglas definidas: como restricciones, triggers, llaves primarias, entre otros.

  • Aislamiento. Las transaciones generalmente se ejecutan concurrentemente (por ejemplo: múltiples transacciones leen y escriben sobre una tabla simultáneamente). El aislamiento garantiza que la ejecución concurrente de transacciones deja a la base de datos en el mismo estado que se obtiene si tales transacciones se ejecutan secuencialmente. Los efectos de una transaccion incompleta no pueden ser visibles a otras transacciones.

  • Durabilidad. Garantiza que una vez que una transacción ha sido comprometida (commit) va a permanecer en ese estado aún sí se tiene una falla en el sistema. Esto generalmente significa que las transacciones completadas son registradas en algún tipo de memoria no volátil.

Protocolos de Consenso

Hoy en día, para lograr lo anterior se emplean protocolos de consenso. El más comun de ellos es el llamado Two Phase Commit (2PC); aunque existen otros como 3PC, Raft o Paxos.

Si tomamos el ejemplo clásico que involucra reservar un vuelo, un hotel y un automovil. En 2PC se tiene un coordinador; en la primer fase del protocolo, cada microservicio coloca un bloqueo sobre el asiento que reservamos, el cuarto de hotel y el automóvil para evitar que alguien más lo pueda reservar; y le indica al coordinador -por algún medio- que logro bloquear esos recursos.

Fase 1

En la segunda etapa del protocolo, si todos respondieron de manera exitosa -es decir, que la transacción puede ser realizada-, entonces el coordinador le indica a cada servcio que proceda a realizar y comprometer la operación deseada.

Fase 2

Si por el contrario no se logra colocar un bloqueo sobre el recurso, por ejemplo reservar la habitación, se le envía un mensaje al coordinador y este a su vez comunica a los otros participantes que deben liberar los recursos y finalmente la transacción falla.

Fase 2 Failed

Problemática

Uno de los problemas principales de los algoritmos de consenso, especialmente cuando son servicios distribuidos en una red, es que si el coordinador falla o no puede ser contactado luego de la primera fase; tenemos que nuestros tres servicios tienen bloqueos sobre los recursos.

Por tanto si otros servicios o personas requieren hacer una reservación sobre el mismo cuarto de hotel, estos van a quedar en un estado de espera pues el recurso no puede ser liberado. Si a esto le sumamos que todos esperamos que los sistemas nos respondan de manera rápida, una situación como la descrita pueda generar contension en la base de datos, bloqueos y otros problemas.

Patrón SAGA

Es ante este escenario que entra en acción el patrón saga. Este fue presentado inicialmente en 1987 por Hector Garcia y Kenneth Salem de la Universidad de Princeton.

Este patrón se usa en arquitecturas de microservicios para manejar transacciones en ambientes distribuidos. Podemos decir que una saga es una secuencia de operaciones que representan una unidad de trabajo que puede deshacerse por medio de una acción compensatoria.

Esta es una acción que semánticamente deshace la operación que se ejecutó. Por ejemplo; si la operación original era una inserción en la base de datos, la compensación puede ser un borrado de la base de datos. Si es un caso más complejo o que no es idempotente, cómo lo es el enviar el correo con la reservación del hotel, su compensación podría ser enviar otro correo de seguimiento indicando que la reserva tuvo que ser anulada.

Siguiente nuestro ejemplo: la reserva del hotel publica un mensaje o evento que dispara la siguiente transacción en la saga; que sería reservar el tiquete de vuelo y si esta fallase la saga ejecuta una serie de transacciones compensatorias que deshacen los cambios realizados por la precedente.

Saga

Cada operacion puede verse como una transacción local; por lo que ejecuta un commit o un rollback en su propia base de datos, pero que se comunica con todas las demás operaciones que conforman la saga.

La saga garantiza que todas las operaciones se completan exitosamente o que las correspondientes acciones compensatorias son invocadas en todas las operaciones ejecutadas, para cancelar así ese procesamiento parcial.

Podemos apreciar la diferente con el protocolo 2PC; donde una transaccion global distribuida(XA) es creada, involucrando a todos los recursos/servicios que conforman la función del negocio. El patrón saga implementa el concepto de “divide y venceras”, cada servicio corre en una transaccion local y provee acciones de compensación. El conjunto de todas esas operaciones individuales conforma la función del negocio y se comunica por eventos (en un patrón de coreografía) o por medio de un coordinador en un modelo de orquestración.

Consistencia Eventual

Inmediatamente podemos notar como una SAGA violenta el principio de aislamiento de las transacciones ACID. Pues al comprometer parcialmente una operación, esta ya es visible a otras, a pesar de que la saga aun no concluye. La saga usa una aproximación de consistencia eventual, esto ya que se garantiza que eventualmente se alcanzará la consistencia al concluir la saga.

Por ello, este patrón emplea una alternativa a ACID que se denominda BASE; acrónomimo en inglés para las siguientes propiedades:

  • Disponibilidad Basica. Es decir, el sistema garantiza la disponibilidad, según se define en el teorema CAP de Seth Gilbert y Nancy Lynch; y que establece que sólo se puede soportar dos de tres propiedades en un ambiente de cómputo distribuido:
    • Consistencia
    • Disponibilidad
    • Tolerancia al particionamiento
  • Soft State. El estado del sistema puede cambiar conforme pasa el tiempo, aún en momentos que no hay entradas, debido a que pueden darse cambios por la consistencia eventual.

  • Consistencia Eventual. El estado del sistema alcanza eventualmente la consistencia.

Como podemos ver, un elemento crucial es el concepto de compensación. Su rol es deshacer el trabajo realizado por la operación inicial, pero por medio de otra operaciónn y no por el tradicional rollback de la base de datos. Ahora, la responsabilidad de esa labor recae en nosotros como desarrolladores.

Por supuesto esto conlleva a una visión funcional diferente, pues la compensación no necesariamiente debe restaurar los datos a su estado inicial; sino dejar los datos en un estado consistente de acuerdo al dominio del negocio. Más aún, debe ser idempotente; pues es posible que sean invocadas más de una vez.

Siguiendo con el caso de la reservación en una saga, se invoca al servicio de reservar el vuelo; el cual genera alguna información de control de la saga, reserva efectivamente el vuelo, luego envia la información de la saga al servicio de reserva de hotel, el cual hace la reserva efectiva y por último se transfiere la información de la saga al servicio de reservacion de auto y este hace la reserva efectiva, por lo cual eventualmente llegamos a un estado de consistencia.

Saga ok

Si por el contrario, el proceso de reservar el auto fallase, este invocaría el mecanismo de compensación del servicio de reserva de auto (el cual cancela la reservación) y luego a su vez invoca a la compensación de la reservacion del vuelo; para finalmente lograr que toda la transacción sea reversada.

Long Running Actions

Ahora bien, finalmente esto nos lleva a la especificación de MicroProfila LRA o (Long Running Actions) creada por el equipo de Narayana; el cuál es una de las implementaciones del patrón Saga en Java.

En este momento tal especificación se encuentra en el release 1.0-M1 y para esta segunda parte escribiremos nuestro ejemplo con Quarkus empleando una extensión que aún no forma parte del release 1.6.

Cabe destacar que LRA usa un modelo de orquestración, utilizando además APIs ya conocidos como CDI y JAX-RS.

Los dos actores principales que vamos a observar son:

  • LRA Coordinador El cual maneja el procesamiento de la Sagal y entre sus responsabilidades tenemos la inicialización de un LRA, el listado de los participantes y la completitud o compensacióin de una saga.

  • Participantes: Este es el servicio que esta involucrado en un LRA. Y cada uno debe brindar al menos un REST endpoint que sirve como el elemento a ser invocado ante una compensación.

Para realizar la implementación usaremos Quarkus y la extension de nayarada lra. A nuestro proyecto básico debemos agregar esa extensión, la cual se puede listar de esa manera:

mvn quarkus:list-extensions
Narayana LRA - LRA Coordinator and Participant Support

y agregamos la extension lra

mvn quarkus:add-extension -Dextensions="lra"

Ahora vamos a crear un nuevo REST API, que nombraremos Service01Resource con @Path(“/service1”)

@ApplicationScoped
public class Service01Resource

Y crearemos un endpoint que realiza una operacion X

@GET
@Path("/op1")
public Response operacion() {
  System.out.println("---------------------------");
  System.out.println("INVOCANDO LOGICA DE NEGOCIO ");
  System.out.println("---------------------------");

  ejecutarOp();

  return Response.ok().build();
}

En nuestra lógica de negocio, por sencillez, vamos a colocar un sleep de un par de segundos.

private void ejecutarOp() {
    try {
      Thread.sleep(2000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

Para activar una LRA debemos solo usar la anotación @LRA y debemos crear un metodo de compensación (el cual tiene la anotación @Compensate); como este:

@PUT
@Path("/compensate")
@Compensate
public Response compensacion() {
    System.out.println("---------------------------");
    System.out.println("INVOCANDO COMPENSACION ");
    System.out.println("---------------------------");

    return Response.ok().build();
}

luego, podemos agregar tambien un método opcional que se ejecuta cuando el LRA ha concluido (@Complete). Este nos puede ser de utilidad cuando ocupamos liberar algunos recursos que hemos reservado.

@PUT
@Path("/cleanUp")
@Complete
public Response cleanUp() {
    System.out.println("---------------------------");
    System.out.println("INVOCANDO COMPLETE ");
    System.out.println("---------------------------");

    return Response.ok().build();
}

Si corremos nuestro ejemplo tendriamos que se invoca la lógica y el método de conclusión.

http://localhost:8080/service1/op1

---------------------------
INVOCANDO LOGICA DE NEGOCIO
---------------------------

---------------------------
INVOCANDO LOGICA DE COMPLETE
---------------------------

Y esto sucede por que tenemos un coordinador que se brinda automáticamente en la implementación de Narayana. Este coordinador lo podemos consultar en este URL que dicta la especificación: http://localhost:8080/lra-coordinator

Si repetimos la prueba observamos los datos mientras la transacción de LRA esta activa.

[{"active":true,"cancelled":false,"clientId":"Service01Resource#operacion",
"closed":false,"finishTime":0,"lraId":"http://localhost:8080/lra-coordinator/0_ffffac100a03_f6c4_5f2b2d1b_a","recovering":false,
"startTime":0,"status":"Active","timeNow":1596643580529,
"topLevel":true,"zoneOffset":"Z"}]

Debemos recordar que cuando una solución esta construida por múltiples microservicios, existe la necesidad de agrupar y coordinar todos los actores involucrados en esa funcionalidad. En esta implementación, el sistema lista todos los participantes con el coordinador; usando un ID retornado por el coordinador. Al final del proceso, en caso exitoso o fallido, uno de los servicios participantes que conoce ese ID del LRA contacta al coordinador para cerrar o compensar el LRA. El coordinador ejecuta la accion solicitada en cada uno de los participantes listados.

Por tanto, los participantes que estan involucrados en una transaccion de saga pueden tener dos estados:

  • Éxito La actividad concluyó con éxito, por lo cual los participantes pueden considerar la transacción como cerrada.
  • Fallo La actividad se concluyó de manera no exitosa, y todos los participantes involucrados en el LRA deben ejecutar las compensaciones en el orden reverso.

Ahora, si cambiamos nuestro metodo en la operació inicial, para que responda un 500 y no un 200.

return Response.status(500).build();

Y luego ejecutamos nuestro servicio, notaremos que se invoca el metodo de compensación.

http://localhost:8080/service1/op1

---------------------------
INVOCANDO LOGICA DE NEGOCIO
---------------------------

---------------------------
INVOCANDO COMPENSACION
---------------------------

Por omisión, se considera que un servicio anotado con @LRA ha fallado si se recibe un 500 o un 40X; pero esto se puede modificar en la anotación, indicando el cancelOn o la familia de errores @LRA(cancelOn = Response.Status.BAD_REQUEST) o cancelOnFamily = {Response.Status.Family.CLIENT_ERROR}.

Hasta ahora, este ejercicio sólo ha tenido una ejecución a la vez, pero en un ambiente más realista, cada método de compensacion o de completitud puede ser invocado de manera simultaneas por varios LRA. Para esto se tiene un encabezado que tiene la información de Id del LRA. Entonces debemos ajustar nuestor código de esta manera:

@GET
@Path("/op1")
public Response operacion(
    @HeaderParam(LRA.LRA_HTTP_CONTEXT_HEADER) URI lraId)
{
  System.out.println("---------------------------");
  System.out.println("INVOCANDO LOGICA DE NEGOCIO " + lraId);
  System.out.println("---------------------------");

  ejecutarOp();

  return Response.ok().build();
}

Y si lo ejecutamos de nuevo podemos ver los ID de cada LRA.

---------------------------
INVOCANDO LOGICA DE NEGOCIO
 http://localhost:8080/lra-coordinator/0_ffffac100a03_de65_5f2b2f24_c
---------------------------

Pero normalmente no tenemos sólo un microservicio, sino una cadena de ellos; así que vamos a copiar este servicio y ejecutarlo en otro puerto.

cp -a lra-demo  lra-demo-2
mvn compile quarkus:dev -Dquarkus.http.port=8081

Ahora en el servicio original agregamos una dependencia de microprofile para consumir ese servicio REST.

mvn quarkus:add-extension -Dextensions="rest-client"

Y creamos un cliente de REST

import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;

@RegisterRestClient(baseUri = "http://localhost:8081")
@Path("/service1")
public interface Service01RestClient {

    @GET
    @Path("/op1")
      public Response call();
}

En nuestro servicio original vamos a consumir este cliente de la siguiente manera:

@Inject
@RestClient
Service01RestClient client;
....

@GET
@Path("/op1")
public Response operacion(@HeaderParam(LRA.LRA_HTTP_CONTEXT_HEADER) URI lraId)
{
  System.out.println("---------------------------");
  System.out.println("INVOCANDO LOGICA DE NEGOCIO " + lraId);
  System.out.println("---------------------------");

  ejecutarOp();
  client.call();

  return Response.ok().build();
}

Y si ejecutamos el servicio inicial

curl http://localhost:8080/service1/op1

Podemos ver como se cierran ambos servicios de manera exitosa. Si por el contrario, alteramos el servicio para que responda con un 500 tenemos la compensación invocada en ambos participantes.

---------------------------
INVOCANDO LOGICA DE NEGOCIO http://localhost:8080/lra-coordinator/0_ffffac100a03_de65_5f2b2f24_c
---------------------------
Could not close LRA 'http://localhost:8080/lra-coordinator/0_ffffc0a85623_d3aa_5f109ab6_c': coordinator 'http://localhost:8080/lra-coordinator/' responded with status 'Not Found'`.
---------------------------
INVOCANDO COMPENSACION http://localhost:8080/lra-coordinator/0_ffffac100a03_de65_5f2b2f24_c

Observarán que se tiene una advertencia: Could not close LRA 'http://localhost:8080/lra-coordinator/0_ffffc0a85623_d3aa_5f109ab6_c': coordinator 'http://localhost:8080/lra-coordinator/' responded with status 'Not Found'.

Eso sucede ya que por omisión el servicio 2 cierra su transacción al terminar; para que eso no suceda debemos colocar el atributo @LRA(end=false) y con eso solucionamos el problema.

Es importante aclarar que pasar parámetros al metodo @Compensate y @Complete no es posible en este momento, por lo que se recomienda almacenar el LRA ID en algún repositorio y luego tomar esos datos para hacer las operaciones necesarias, por ejemplo: al concluir o compensar un LRA.

Conclusión


Espero que este artículo les haya sido de utilidad y ahora tengan una visión más clara respecto a que es una SAGA y de que manera puede ser implementada.