From 42a063e6b9173da1ee980bfaea8ea2aa900c4cab Mon Sep 17 00:00:00 2001 From: Stephan Ewen Date: Tue, 26 Nov 2024 20:41:55 +0000 Subject: [PATCH] Make the Java SAGAs example easier to understand --- patterns-use-cases/sagas/sagas-java/README.md | 5 +- .../dev/restate/patterns/BookingWorkflow.java | 110 ++++++++++++++ .../dev/restate/patterns/Compensations.java | 137 ------------------ .../patterns/activities/CarRentals.java | 34 +++++ .../restate/patterns/activities/Flights.java | 34 +++++ .../restate/patterns/activities/Payment.java | 29 ++++ 6 files changed, 210 insertions(+), 139 deletions(-) create mode 100644 patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/BookingWorkflow.java delete mode 100644 patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/Compensations.java create mode 100644 patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/CarRentals.java create mode 100644 patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/Flights.java create mode 100644 patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/Payment.java diff --git a/patterns-use-cases/sagas/sagas-java/README.md b/patterns-use-cases/sagas/sagas-java/README.md index ffba2b95..a3d4ad08 100644 --- a/patterns-use-cases/sagas/sagas-java/README.md +++ b/patterns-use-cases/sagas/sagas-java/README.md @@ -1,7 +1,7 @@ # SAGAs / Compensations An example of a trip reservation workflow, using the SAGAs pattern to -undo previous steps in csase of an error. +undo previous steps in case of an error. This is a minimal version of the holiday reservation demo in the [Restate Holiday Repository](https://github.com/restatedev/restate-holiday). @@ -12,4 +12,5 @@ Every step pushes a compensation action (an undo operation) to a stack. in the case of an error, those operations are run. The main requirement is that steps are implemented as journalled -operations, like `ctx.run()` or `ctx.rpc()`. +operations, like `ctx.run()` (e.g., direct calls to 3rd party APIs) or +RPCs to other Restate-backed services (e.g., `FlightsClient.fromContex(ctx)`). diff --git a/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/BookingWorkflow.java b/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/BookingWorkflow.java new file mode 100644 index 00000000..481235cc --- /dev/null +++ b/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/BookingWorkflow.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2024 - Restate Software, Inc., Restate GmbH + * + * This file is part of the Restate examples, + * which is released under the MIT license. + * + * You can find a copy of the license in the file LICENSE + * in the root directory of this repository or package or at + * https://github.com/restatedev/examples/ + */ + +package dev.restate.patterns; + +import dev.restate.patterns.activities.*; +import dev.restate.sdk.WorkflowContext; +import dev.restate.sdk.annotation.Workflow; +import dev.restate.sdk.common.TerminalException; + +import java.util.ArrayList; +import java.util.List; + +// +// SAGAs / Compensations +// +// An example of a trip reservation workflow, using the SAGAs pattern to +// undo previous steps in case of an error. +// +// Durable Execution's guarantee to run code to the end in the presence +// of failures, and to deterministically recover previous steps from the +// journal, makes SAGAs easy. +// Every step pushes a compensation action (an undo operation) to a stack. +// in the case of an error, those operations are run. +// +// The main requirement is that steps are implemented as journalled +// operations, like `ctx.run()` or RPC calls/messages executed +// through the Restate Context. +// + +/** + * Trip reservation workflow which has been instrumented with compensations. The workflow tries to + * reserve the flight and the car rental before it processes the payment. If at any point one of + * the calls fails or gets cancelled, then the trip reservation workflow will undo all + * successfully completed steps by running the compensations. + * + *

Note: that the compensation logic is purely implemented in the user code and runs durably + * until it completes. Moreover, an invocation failure and an invocation cancellation are handled + * in the exact same way by the caller. + */ +@Workflow +public class BookingWorkflow { + + // The workflow parameters, like the car and flight to book, the + // payment details (card/token, amount, ...) + public record TravelBookingRequest( /* car, flights, payment info, ... */ ) { } + + @Workflow + public void run(WorkflowContext context, TravelBookingRequest request) throws TerminalException { + // Create a list of compensations to run in case of a failure or cancellation. + final List compensations = new ArrayList<>(); + + try { + // Reserve the flights and let Restate remember the reservation ID + final var flightsRpcClient = FlightsClient.fromContext(context); + final String flightReservationId = + flightsRpcClient + .reserve(new Flights.FlightBookingRequest(request)) + .await(); + // Register the compensation to undo the flight reservation. + compensations.add(() -> flightsRpcClient.cancel(flightReservationId).await()); + + // Reserve the car and let Restate remember the reservation ID + final var carRentalRpcClient = CarRentalsClient.fromContext(context); + final String carReservationId = + carRentalRpcClient + .reserve(new CarRentals.CarRentalRequest(request)) + .await(); + // Register the compensation to undo the car rental reservation. + compensations.add(() -> carRentalRpcClient.cancel(carReservationId).await()); + + // call the payment service to make the payment and let Restate remember + // the payment ID + final var paymentRpcClient = PaymentClient.fromContext(context); + final String paymentId = + paymentRpcClient + .process(new Payment.PaymentRequest(request)) + .await(); + // Register the compensation to undo the payment. + compensations.add(() -> paymentRpcClient.refund(paymentId).await()); + + // confirm the reserved flight / rental + // failures here will still trigger the SAGA compensations + flightsRpcClient.confirm(flightReservationId).await(); + carRentalRpcClient.confirm(carReservationId).await(); + + } catch (TerminalException e) { + + // Run the compensations + for (Runnable compensation : compensations) { + compensation.run(); + } + + // rethrow error to fail this workflow + throw new TerminalException( + e.getCode(), + String.format( + "Failed to reserve the trip: %s. Ran %d compensations.", + e.getMessage(), compensations.size())); + } + } +} diff --git a/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/Compensations.java b/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/Compensations.java deleted file mode 100644 index 49943795..00000000 --- a/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/Compensations.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (c) 2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate examples, - * which is released under the MIT license. - * - * You can find a copy of the license in the file LICENSE - * in the root directory of this repository or package or at - * https://github.com/restatedev/examples/ - */ - -package dev.restate.patterns; - -import dev.restate.sdk.Awaitable; -import dev.restate.sdk.Context; -import dev.restate.sdk.ObjectContext; -import dev.restate.sdk.annotation.Handler; -import dev.restate.sdk.annotation.Service; -import dev.restate.sdk.annotation.VirtualObject; -import dev.restate.sdk.common.TerminalException; -import java.util.ArrayDeque; -import java.util.Deque; -import java.util.Iterator; - -// -// SAGAs / Compensations -// -// An example of a trip reservation workflow, using the SAGAs pattern to -// undo previous steps in case of an error. -// -// Durable Execution's guarantee to run code to the end in the presence -// of failures, and to deterministically recover previous steps from the -// journal, makes SAGAs easy. -// Every step pushes a compensation action (an undo operation) to a stack. -// in the case of an error, those operations are run. -// -// The main requirement is that steps are implemented as journalled -// operations, like `ctx.run()` or RPC calls/messages executed -// through the Restate Context. -// - -public class Compensations { - - /** - * Trip reservation workflow which has been instrumented with compensations. The workflow tries to - * reserve the flight and the car rental before it processes the payment. If at any point one of - * the calls fails or gets cancelled, then the trip reservation workflow will undo all - * successfully completed steps by running the compensations. - * - *

Note: that the compensation logic is purely implemented in the user code and runs durably - * until it completes. Moreover, an invocation failure and an invocation cancellation are handled - * in the exact same way by the caller. - */ - @VirtualObject - public static class Travels { - - public static class TravelBookingRequest { } - - @Handler - public void reserve(ObjectContext context, TravelBookingRequest request) throws TerminalException { - final FlightsClient.ContextClient flightsService = FlightsClient.fromContext(context); - final CarRentalsClient.ContextClient carRentalService = CarRentalsClient.fromContext(context); - final PaymentClient.ContextClient paymentService = PaymentClient.fromContext(context); - - // Create a list of compensations to run in case of a failure or cancellation. - final Deque compensations = new ArrayDeque<>(); - - try { - final String flightBookingId = - flightsService - .reserve(new Flights.FlightBookingRequest()) - .await(); - // Register the compensation to undo the flight reservation. - compensations.add(() -> flightsService.cancel(flightBookingId).await()); - - final String carRentalId = - carRentalService - .reserve(new CarRentals.CarRentalBookingRequest()) - .await(); - // Register the compensation to undo the car rental reservation. - compensations.add(() -> carRentalService.cancel(carRentalId).await()); - - final String paymentId = - paymentService - .process(new Payment.PaymentRequest()) - .await(); - // Register the compensation to undo the payment. - compensations.add(() -> paymentService.refund(paymentId).await()); - - Awaitable.all( - flightsService.confirm(flightBookingId), carRentalService.confirm(carRentalId)) - .await(); - } catch (TerminalException e) { - // Run the compensations in reverse order - final Iterator compensationsIterator = compensations.descendingIterator(); - - while (compensationsIterator.hasNext()) { - compensationsIterator.next().run(); - } - - throw new TerminalException( - e.getCode(), - String.format( - "Failed to reserve the trip: %s. Ran %d compensations.", - e.getMessage(), compensations.size())); - } - } - } - - // --- Interfaces for Flights, CarRental and Payment components - - @Service(name = "Flights") - interface Flights { - class FlightBookingRequest { } - - @Handler String reserve(Context context, FlightBookingRequest request); - @Handler void confirm(Context context, String flightBookingId); - @Handler void cancel(Context context, String flightBookingId); - } - - @Service(name = "CarRentals") - interface CarRentals { - class CarRentalBookingRequest { } - - @Handler String reserve(Context context, CarRentalBookingRequest request); - @Handler void confirm(Context context, String carRentalBookingId); - @Handler void cancel(Context context, String carRentalBookingId); - } - - @Service(name = "Payment") - interface Payment { - class PaymentRequest { } - - @Handler String process(Context context, PaymentRequest request); - @Handler void refund(Context context, String paymentId); - } -} diff --git a/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/CarRentals.java b/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/CarRentals.java new file mode 100644 index 00000000..6b7e81d3 --- /dev/null +++ b/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/CarRentals.java @@ -0,0 +1,34 @@ +package dev.restate.patterns.activities; + +import dev.restate.patterns.BookingWorkflow.TravelBookingRequest; +import dev.restate.sdk.Context; +import dev.restate.sdk.annotation.Handler; +import dev.restate.sdk.annotation.Service; + +import java.util.UUID; + +@Service +public class CarRentals { + + public record CarRentalRequest(TravelBookingRequest req /* rental details */) {} + + @Handler + public String reserve(Context ctx, CarRentalRequest request) { + // this should implement the communication with the rental + // provider's APIs + // just return a mock random id representing the reservation + return "car-" + UUID.randomUUID().toString(); + } + + @Handler + public void confirm(Context ctx, String carRentalBookingId) { + // this should implement the communication with the rental + // provider's APIs + } + + @Handler + public void cancel(Context ctx, String carRentalBookingId) { + // this should implement the communication with the rental + // provider's APIs + } +} diff --git a/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/Flights.java b/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/Flights.java new file mode 100644 index 00000000..0115b6d1 --- /dev/null +++ b/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/Flights.java @@ -0,0 +1,34 @@ +package dev.restate.patterns.activities; + +import dev.restate.patterns.BookingWorkflow.TravelBookingRequest; +import dev.restate.sdk.Context; +import dev.restate.sdk.annotation.Handler; +import dev.restate.sdk.annotation.Service; + +import java.util.UUID; + +@Service +public class Flights { + + public record FlightBookingRequest(TravelBookingRequest req /* flight number, class, ... */) {} + + @Handler + public String reserve(Context ctx, FlightBookingRequest request) { + // this should implement the communication with the rental + // provider's APIs + // just return a mock random id representing the reservation + return "car-" + UUID.randomUUID().toString(); + } + + @Handler + public void confirm(Context ctx, String flightBookingId) { + // this should implement the communication with the rental + // provider's APIs + } + + @Handler + public void cancel(Context ctx, String flightBookingId) { + // this should implement the communication with the rental + // provider's APIs + } +} diff --git a/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/Payment.java b/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/Payment.java new file mode 100644 index 00000000..f8be7e89 --- /dev/null +++ b/patterns-use-cases/sagas/sagas-java/src/main/java/dev/restate/patterns/activities/Payment.java @@ -0,0 +1,29 @@ +package dev.restate.patterns.activities; + +import dev.restate.patterns.BookingWorkflow.TravelBookingRequest; +import dev.restate.sdk.Context; +import dev.restate.sdk.annotation.Handler; +import dev.restate.sdk.annotation.Service; + +import java.util.UUID; + +@Service +public class Payment { + + public record PaymentRequest(TravelBookingRequest req /* paymentToken, amount, ... */ ) {} + + @Handler + String process(Context context, PaymentRequest request) { + // this should implement the actual payment processing, or communication + // to the external provider's APIs + // just return a mock random id representing the payment + return UUID.randomUUID().toString(); + } + + @Handler + public void refund(Context context, String paymentId) { + // refund the payment identified by this paymentId + // this should implement the actual payment processing, or communication + // to the external provider's APIs + } +} \ No newline at end of file