Tuesday, July 20, 2021

How to use Long Running Actions between microservices

Introduction

In my last post I showed how to run a Long Running Action (LRA) within a single JAX-RS resource method using quarkus features to build and run the application. I showed how to create and start an LRA coordinator and then generated a basic hello application, showing how to modify the application to run with a long running action (by adding dependencies on the org.eclipse.microprofile.lra:microprofile-lra-api and org.jboss.narayana.rts:narayana-lra artifacts, which together provide annotations for controlling the lifecycle of LRAs). That post also includes links to the the LRA specification and to the javadoc for the annotation API.

In this follow up post I will indicate how to include a second resource in the LRA. To keep things interesting I’ll deploy the second resource to another microservice and use quarkus’s MicroProfile Rest Client support to implement the remote service invocations. The main difference between this example and the one I developed in the earlier post, apart from the technicalities of using Rest Client, is that we will set the LRA.end attribute to false in the remote service so that the LRA will remain active when the call returns. In this way the initiating service method has the option of calling other microservices before ending the LRA.

Creating and starting an LRA coordinator

LRA relies on a coordinator to manage the lifecycle of LRAs so you will need one to be running for this demo to work successfully. The previous post showed how to build and run coordinators. Alternatively, download or view some scripts which execute all of the steps required in the current post and it includes a shell script called coordinator.sh which will build a runnable coordinator jar (it’s fairly simple and short so you can just read it and create your own jar or just run it as is).

Generate a project for booking tickets

Since the example will be REST based, include the resteasy and rest-client extensions (on line 6 next):

    1: mvn io.quarkus:quarkus-maven-plugin:2.0.1.Final:create \
    2:     -DprojectGroupId=org.acme \
    3:     -DprojectArtifactId=ticket \
    4:     -DclassName="org.acme.ticket.TicketResource" \
    5:     -Dpath="/tickets" \
    6:     -Dextensions="resteasy,rest-client"
    7: cd ticket

You will need the mvn program to run the plugin (but the generated projects will include the mvnw maven wrapper).

Modify the generated TicketResource.java source file to add Microprofile LRA support. The changes that you will need for LRA are on lines 26 and 27. Line 26 says that the bookTicket method must run with an LRA (if one is not present when the method is invoked then one will be automatically created). Note that we have set the end attribute to false to stop the LRA from being automatically closed when the method finishes. By keeping the LRA active when the ticket is booked, the caller can invoke other services in the context of the same LRA. Most services will require the LRA context for tracking updates which typically will be useful for knowing which actions to compensate for if the LRA is later cancelled: the context is injected as a JAX-RS method parameter on line 27.

You will also need to include callbacks for when the LRA is later closed or cancelled (the methods are defined on lines 37 and line 46, respectively).

    1: package org.acme.ticket;
    2:
    3: import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
    4:
    5: // import annotation definitions
    6: import org.eclipse.microprofile.lra.annotation.ws.rs.LRA;
    7: import org.eclipse.microprofile.lra.annotation.Compensate;
    8: import org.eclipse.microprofile.lra.annotation.Complete;
    9: // import the definition of the LRA context header
   10: import static org.eclipse.microprofile.lra.annotation.ws.rs.LRA.LRA_HTTP_CONTEXT_HEADER;
   11:
   12: // import some JAX-RS types
   13: import javax.ws.rs.GET;
   14: import javax.ws.rs.PUT;
   15: import javax.ws.rs.Path;
   16: import javax.ws.rs.Produces;
   17: import javax.ws.rs.core.Response;
   18: import javax.ws.rs.HeaderParam;
   19:
   20: @Path("/tickets")
   21: @Produces(APPLICATION_JSON)
   22: public class TicketResource {
   23:
   24:     @GET
   25:     @Path("/book")
   26:     @LRA(value = LRA.Type.REQUIRED, end = false) // an LRA will be started before method execution if none exists and will not be ended after method execution
   27:     public Response bookTicket(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) String lraId) {
   28:         System.out.printf("TicketResource.bookTicket: %s%n", lraId);
   29:         String ticket = "1234"
   30:         return Response.ok(ticket).build();
   31:     }
   32:
   33:     // ask to be notified if the LRA closes:
   34:     @PUT // must be PUT
   35:     @Path("/complete")
   36:     @Complete
   37:     public Response completeWork(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) String lraId) {
   38:         System.out.printf("TicketResource.completeWork: %s%n", lraId);
   39:         return Response.ok().build();
   40:     }
   41:
   42:     // ask to be notified if the LRA cancels:
   43:     @PUT // must be PUT
   44:     @Path("/compensate")
   45:     @Compensate
   46:     public Response compensateWork(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) String lraId) {
   47:         System.out.printf("TicketResource.compensateWork: %s%n", lraId);
   48:         return Response.ok().build();
   49:     }
   50: }

Skip the tests:

rm src/test/java/org/acme/ticket/*

Add dependencies on microprofile-lra-api and narayana-lra to the pom to include the MicroProfile LRA annotations and the narayana implementation of them so that the LRA context will be propagated during interservice communications:

    <dependencies>
      <dependency>
        <groupId>org.eclipse.microprofile.lra</groupId>
        <artifactId>microprofile-lra-api</artifactId>
        <version>1.0</version>
      </dependency>
      <dependency>
        <groupId>org.jboss.narayana.rts</groupId>
        <artifactId>narayana-lra<\/artifactId>
        <version>5.12.0.Final</version>
      </dependency>

We are creating ticket and trip microservices so they need to listen on different ports, configure the ticket service to run on port 8081:

    1: quarkus.arc.exclude-types=io.narayana.lra.client.internal.proxy.nonjaxrs.LRAParticipantRegistry,io.narayana.lra.filter.ServerLRAFilter,io.narayana.lra.client.internal.proxy.nonjaxrs.LRAParticipantResource
    2: quarkus.http.port=8081
    3: quarkus.http.test-port=8081

The excludes are pulled in by the org.jboss.narayana.rts:narayana-lra maven dependency. As mentioned in my previous post this step will not be necessary when the pull request for the io.quarkus:quarkus-narayana-lra extension is approved. Now build and test the ticket service, making sure that you have already started a coordinator as described in the previous blog (or you can use the shell scripts linked above):

./mvnw clean package -DskipTests # skip tests
java -jar target/quarkus-app/quarkus-run.jar & # run the application in the background
curl http://localhost:8081/tickets/book
TicketResource.bookTicket: http://localhost:8080/lra-coordinator/0_ffffc0a8000e_8b2b_60f6a8d4_2
1234

The bookTicket() method prints the method name and the id of the active LRA followed by the hard-coded booking id 1234.

Generate a project for booking trips

Now create a second microservice which will be used for booking trips. It will invoke other microservices to complete trip bookings. In order to simplify the example there is just the single remote ticket service involved in the booking process.

First generate the project. Like the ticket service, the example will be REST based so include the resteasy and rest-client extensions:

mvn io.quarkus:quarkus-maven-plugin:2.0.1.Final:create \
    -DprojectGroupId=org.acme \
    -DprojectArtifactId=trip \
    -DclassName="org.acme.trip.TripResource" \
    -Dpath="/trips" \
    -Dextensions="resteasy,rest-client"

cd trip

The rest-client extension includes support for MicroProfile REST Client which we shall use to perform the remote REST invocations from the trip to the ticket service. For REST Client we need a TicketService and we need to register it as shown on line 12 of the following listing:

    1: package org.acme.trip;
    2:
    3: import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
    4:
    5: import javax.ws.rs.GET;
    6: import javax.ws.rs.Path;
    7: import javax.ws.rs.Produces;
    8: import javax.ws.rs.core.MediaType;
    9:
   10: @Path("/tickets")
   11: @Produces(MediaType.APPLICATION_JSON)
   12: @RegisterRestClient
   13: public interface TicketService {
   14:
   15:     @GET
   16:     @Path("/book")
   17:     String bookTicket();
   18: }

Let’s also create a TripService and inject an instance of the TicketService into it, marking it with the @RestClient annotation on line 11. The quarkus rest client support will configure this injected instance such that it will perform remote REST calls to the ticket service (the remote endpoint for the ticket service will be configured below in the application.properties file):

    1: package org.acme.trip;
    2:
    3: import org.eclipse.microprofile.rest.client.inject.RestClient;
    4: import javax.enterprise.context.ApplicationScoped;
    5: import javax.inject.Inject;
    6:
    7: @ApplicationScoped
    8: public class TripService {
    9:
   10:     @Inject
   11:     @RestClient
   12:     TicketService ticketService;
   13:
   14:     String bookTrip() {
   15:         return ticketService.bookTicket(); // only one service will be used for the trip booking
   16:
   17:         // if other services need to be part of the trip they would be called here
   18:         // and the TripService would associate each step of the booking with the id of the LRA
   19:         // (although I've not shown it being passed in this example) and that would form the
   20:         // basis of the ability to compensate or clean up depending upon the outcome.
   21:         // We may include a more comprehensive/realistic example in a later blog.
   22:     }
   23: }

And now we can inject an instance of this service into the generated TripResource (src/main/java/org/acme/trip/TripResource.java) on line 26. I have also annotated the bookTrip() method with an LRA annotation so that a new LRA will be started before the method is started (if one wasn’t already present) and I have added @Complete and @Compensate callback methods (these will be called when the LRA closes or cancels, respectively):

    1: package org.acme.trip;
    2:
    3: import javax.inject.Inject;
    4: import javax.ws.rs.GET;
    5: import javax.ws.rs.Path;
    6: import javax.ws.rs.Produces;
    7: import javax.ws.rs.core.Response;
    8:
    9: import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
   10:
   11: // import annotation definitions
   12: import org.eclipse.microprofile.lra.annotation.ws.rs.LRA;
   13: import org.eclipse.microprofile.lra.annotation.Compensate;
   14: import org.eclipse.microprofile.lra.annotation.Complete;
   15: // import the definition of the LRA context header
   16: import static org.eclipse.microprofile.lra.annotation.ws.rs.LRA.LRA_HTTP_CONTEXT_HEADER;
   17:
   18: // import some JAX-RS types
   19: import javax.ws.rs.PUT;
   20: import javax.ws.rs.HeaderParam;
   21:
   22: @Path("/trips")
   23: @Produces(APPLICATION_JSON)
   24: public class TripResource {
   25:
   26:     @Inject
   27:     TripService service;
   28:
   29:     // annotate the hello method so that it will run in an LRA:
   30:     @GET
   31:     @LRA(LRA.Type.REQUIRED) // an LRA will be started before method execution and ended after method execution
   32:     @Path("/book")
   33:     public Response bookTrip(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) String lraId) {
   34:         System.out.printf("TripResource.bookTrip: %s%n", lraId);
   35:         String ticket = service.bookTrip();
   36:         return Response.ok(ticket).build();
   37:     }
   38:
   39:     // ask to be notified if the LRA closes:
   40:     @PUT // must be PUT
   41:     @Path("/complete")
   42:     @Complete
   43:     public Response completeWork(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) String lraId) {
   44:         System.out.printf("TripResource.completeWork: %s%n", lraId);
   45:         return Response.ok().build();
   46:     }
   47:
   48:     // ask to be notified if the LRA cancels:
   49:     @PUT // must be PUT
   50:     @Path("/compensate")
   51:     @Compensate
   52:     public Response compensateWork(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) String lraId) {
   53:         System.out.printf("TripResource.compensateWork: %s%n", lraId);
   54:         return Response.ok().build();
   55:     }
   56: }

For the blog we can skip the tests:

rm src/test/java/org/acme/trip/*

Configure the trip service to listen on port 8082 (line 2). Also configure the remote ticket endpoint as required by the MicroProfile REST Client specification (line 5):

    1: quarkus.arc.exclude-types=io.narayana.lra.client.internal.proxy.nonjaxrs.LRAParticipantRegistry,io.narayana.lra.filter.ServerLRAFilter,io.narayana.lra.client.internal.proxy.nonjaxrs.LRAParticipantResource
    2: quarkus.http.port=8082
    3: quarkus.http.test-port=8082
    4:
    5: org.acme.trip.TicketService/mp-rest/url=http://localhost:8081
    6: org.acme.trip.TicketService/mp-rest/scope=javax.inject.Singleton

Add dependencies on microprofile-lra-api and narayana-lra to the pom to include the MicroProfile LRA annotations and the narayana implementation of them so that the application can request that the LRA context be propagated during interservice communications:

      <dependency>
        <groupId>org.eclipse.microprofile.lra</groupId>
        <artifactId>microprofile-lra-api</artifactId>
        <version>1.0</version>
      </dependency>
      <dependency>
        <groupId>org.jboss.narayana.rts</groupId>
        <artifactId>narayana-lra</artifactId>
        <version>5.12.0.Final</version>
      </dependency>

and finally, build and run the microservice:

./mvnw clean package -DskipTests
java -jar target/quarkus-app/quarkus-run.jar &

Use curl to book a trip. The HTTP GET request to the trips/book endpoint is handled by the trip service bookTrip() method and it then invokes the ticket service to book a ticket. When the bookTrip() method finishes the LRA will be closed (since the default value for the LRA.end attribute is true), triggering calls to the service @Complete methods of the two services:

curl http://localhost:8082/trips/book
TripResource.bookTrip: http://localhost:8080/lra-coordinator/0_ffffc0a8000e_8b2b_60f6a8d4_52c
TicketResource.bookTrip: http://localhost:8080/lra-coordinator/0_ffffc0a8000e_8b2b_60f6a8d4_52c
TripResource.completeWork: http://localhost:8080/lra-coordinator/0_ffffc0a8000e_8b2b_60f6a8d4_52c
TicketResource.bookTrip: http://localhost:8080/lra-coordinator/0_ffffc0a8000e_8b2b_60f6a8d4_52c
TicketResource.completeWork: http://localhost:8080/lra-coordinator/0_ffffc0a8000e_8b2b_60f6a8d4_52c
1234

Notice the output shows the bookTrip and bookTicket methods being called and also notice that the @Complete methods of both services (completeWork()) were called. The id of the LRA on all calls should be the same value as shown in the example output, this is worthwhile noting since the completion and compensation methods will typically use it in order to determine which actions it should clean up for or compensate for when the LRA closes or cancels.

Not shown here, but if there was a problem booking the ticket then the ticket service should return a JAX-RS status code (4xx and 5xx HTTP codes by default) that triggers the cancellation of the LRA, and this would then cause the @Compensate methods of all services involved in the LRA to be invoked.


No comments: