diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7b016a8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/cookbooks/child-workflows/child-workflows-megafood.md b/cookbooks/child-workflows/child-workflows-megafood.md index 57bc828..a113e26 100644 --- a/cookbooks/child-workflows/child-workflows-megafood.md +++ b/cookbooks/child-workflows/child-workflows-megafood.md @@ -162,7 +162,6 @@ to get our Instafood application running we first need to register a domain for *Connection Info* tab, and will look like this: "ab-cd12ef23-45gh-4baf-ad99-df4xy-azba45bc0c8da111.elb.us-east-1.amazonaws.com". We'll call this the . - 3. We can now test our connection by listing current domains: ```bash @@ -193,15 +192,23 @@ to get our Instafood application running we first need to register a domain for cadenceHost= ``` -3. You can now run the app by - ```bash - cadence-cookbooks-instafood/instafood$ ./gradlew run - ``` - or executing *InstafoodApplication* main class from your IDE: +3. Run the megaburgers API: + + ```bash + cadence-cookbooks-instafood/megaburgers$ ./gradlew run + ``` + +4. You can now run the app by + + ```bash + cadence-cookbooks-instafood/instafood$ ./gradlew run + ``` + + or executing *InstafoodApplication* main class from your IDE: - ![Running Instafood app](images/run_instafood.png) + ![Running Instafood app](images/run_instafood.png) -4. Check it is running by looking into its terminal output: +5. Check it is running by looking into its terminal output: ![Instafood running terminal output](images/instafood_app_running.png) diff --git a/cookbooks/workflow versioning/images/Diagram_3.png b/cookbooks/workflow versioning/images/Diagram_3.png new file mode 100644 index 0000000..34e0fa9 Binary files /dev/null and b/cookbooks/workflow versioning/images/Diagram_3.png differ diff --git a/cookbooks/workflow versioning/images/Instaclustr_Product_Managed_Cadence.png b/cookbooks/workflow versioning/images/Instaclustr_Product_Managed_Cadence.png new file mode 100644 index 0000000..b06699c Binary files /dev/null and b/cookbooks/workflow versioning/images/Instaclustr_Product_Managed_Cadence.png differ diff --git a/cookbooks/workflow versioning/images/instafood_app_running.png b/cookbooks/workflow versioning/images/instafood_app_running.png new file mode 100644 index 0000000..7ce6ef8 Binary files /dev/null and b/cookbooks/workflow versioning/images/instafood_app_running.png differ diff --git a/cookbooks/workflow versioning/images/run_instafood.png b/cookbooks/workflow versioning/images/run_instafood.png new file mode 100644 index 0000000..6d2c36b Binary files /dev/null and b/cookbooks/workflow versioning/images/run_instafood.png differ diff --git a/cookbooks/workflow versioning/images/run_megaburger_api.png b/cookbooks/workflow versioning/images/run_megaburger_api.png new file mode 100644 index 0000000..3ce5566 Binary files /dev/null and b/cookbooks/workflow versioning/images/run_megaburger_api.png differ diff --git a/cookbooks/workflow versioning/images/workflow-history.gif b/cookbooks/workflow versioning/images/workflow-history.gif new file mode 100644 index 0000000..f596561 Binary files /dev/null and b/cookbooks/workflow versioning/images/workflow-history.gif differ diff --git a/cookbooks/workflow versioning/workflow-versioning-megafood.md b/cookbooks/workflow versioning/workflow-versioning-megafood.md new file mode 100644 index 0000000..f50eb11 --- /dev/null +++ b/cookbooks/workflow versioning/workflow-versioning-megafood.md @@ -0,0 +1,484 @@ +# Instafood: Cadence Workflow Versioning Cookbook + +![Instaclustr Managed Cadence](images/Instaclustr_Product_Managed_Cadence.png) + +## Introduction + +### Who is this cookbook for? + +This cookbook is for developers and engineers of all levels looking to understand how to change workflows in Cadence using the versioning API. +The recipe in this book provides *"Hello World!"* type examples based on simple scenarios and use cases. + +### What you will learn + +How to setup a simple Cadence application which implements workflow versioning on Instaclustr's Managed Service Platform. + +### What you will need + +- An account on Instaclustr’s managed service platform (sign up for a free trial using the + following [signup link](https://console2.instaclustr.com/signup)) +- Basic Java 11 and Gradle installation +- IntelliJ Community Edition, Visual Studio Code or any other IDE with Gradle support +- Docker (optional: only needed to run Cadence command line client) + +### What is Cadence? + +A large number of use cases span beyond a single request-reply, require tracking of a complex state, respond to asynchronous events, and communicate to external unreliable dependencies. The usual approach to building such applications is a hodgepodge of stateless services, databases, cron jobs, and queuing systems. This negatively impacts developer productivity as most of the code is dedicated to plumbing, obscuring the actual business logic behind a myriad of low-level details. + +Cadence is an orchestration framework that helps developers write fault-tolerant, long-running applications, also known as workflows. In essence, it provides a durable virtual memory that is not linked to a specific process or host, and is able to rebuild application state by replaying individual steps. This includes function stacks, with local variables across all sorts of host and software failures. This allows you to write code using the full power of a programming language while Cadence takes care of durability, availability, and scalability of the application. + +## What is workflow versioning? + +Cadence's core abstraction is a fault-oblivious stateful **workflow**. Workflow definitions are built by combining fully deterministic code, and calls to external services using the **activities** interface, to define a replayable, fault tolerant workflow. + +Over time, it will become neccesary to modify the workflow, or activity code, when requirements change or interfaces are updated. This can introduce problems for workflows that are currently running. + +How do we introduce changes that both satisfy our new requirements and do not introduce non-deterministic changes for workflows that are currently executing? + +Cadence supports this with the **versioning api**. + +### Cadence's state recovery and determinisim requirement + +Before we can understand how versioning is implemented, we must first understand the most powerful feature that Cadence offers: **fault tolerant workflow execution**. + +In order to deliver that functionality, Cadence must be able to recover a workflow process that has failed mid-execution, and continue execution as if no problem has occured. + +So how does it do this? With a combination of re-executing workflow code and persisting the result of activity calls. + +Lets consider a simple example workflow with 3 activity calls. + +```java + Result1 r1 = activity.step1(); + r1.field = r1.field + 1; + + // possible crash here! + + Result2 r2 = activity.step2(r1); + r2.field = r2.field - 1; + + Result3 r3 = activity.step3(r2); + + return r3; +``` + +Now lets imagine our workflow code is being executed, and the worker process crashes after step 1 completes, but before step 2 executes. + +When a new Cadence worker comes online, it will execute the workflow from the start. When encountering a call to an activity, Cadence first checks to see if there is an event and result in the history table, and if there is, will instantly return that result to the workflow worker. + +If we return to our example, the workflow will immediately progress to step 2, having restored the history in the workflow, and continue execution. + +![Cadence History Recovery](images/workflow-history.gif) + + +### Why do we need deterministic code? + +In our previous example, imagine what would happen if our recovering workflow code did not execute the activities in the same order as they did the first time. + +```java + Result1 r1 = activity.step1(); + r1.field = r1.field + 1; + + Result2 r2 = null; + boolean skipStep2 = random.nextInt(10) < 5; + + if (!skipStep2) { + r2 = activity.step2(r1); + r2.field = r2.field - 1; + } + + // possible crash here! + + Result3 r3 = activity.step3(r2); + + return r3; +``` + +If our workflow code was not deterministic, instead of executing activities *1-2-3*, we might have some code that calls activity 3 without calling activity 2, *1-3*, and this would change the value of the parameter being passed. + +How can Cadence recover a workflow which may execute activities in different order or calculate different values every iteration? Short answer, it can't. + +If our non-deterministic workflow were to fail and a worker started to recover it, when Cadence encounters a call to an activity that isn't in the history it would throw an exception. + +## Workflow versioning explained + +So now we understand how Cadence persists activity results, and how it can use the event history to recover a failed worker process. +We also understand that our workflow code must be deterministic, otherwise the workflow is not reliably recoverable. + +This leaves us with a problem, what do we do when we **need** to update our workflow code with changes that will make *existing* workflows behave non-deterministically? + +Thankfully, Cadence has support for this. + +### Updated example + +Lets update our example workflow in response to a new requirement: + +```diff + Result1 r1 = activity.step1(); + r1.field = r1.field + 1; + +- Result2 r2 = activity.step2(r1); ++ Result2 r2 = activity.updatedStep2(r1); + r2.field = r2.field - 1; + + Result3 r3 = activity.step3(r2); + + return r3; +``` + +We have replaced step 2 with an updated activity call and we want to deploy it, but active workflows will not be able replay history with the newly updated workflow. When Cadence encounters the new activity call, it will throw an exception. + +With the Cadence SDK, we can introduce branching logic using the *workflow.getVersion* procedure call. + +```java + Result1 r1 = activity.step1(); + r1.field = r1.field + 1; + + Result r2 = null; + int version = Workflow.getVersion("step2Updated", Workflow.DEFAULT_VERSION, 1); + + if (version == Workflow.DEFAULT_VERSION) { + // previous code path + r2 = activity.step2(r1); + r2.field = r2.field - 1; + } + else { + // new code path + r2 = activity.alternateStep2(r1); + } + + Result3 r3 = activity.step3(r2); + + return r3; +``` + +Our new workflow code uses *workflow.getVersion* to determine the version of the "step2Updated" feature. Then we can decide if it should execute the old or new code paths. + +How does this work for workflows that started executing before the version check was implemented? + +First, lets break down the 3 parameters in this call: + +1. the *changeId*, a unique identifier that represents the change made. +2. *minSupported* the lowest version supported by this workflow. In our example, *Workflow.DEFAULT_VERSION*. +3. *maxSupported* the highest version supported. In our example, 1. + +When a workflow encounters this call, it will check the version history for the *changeId* and then evaluates the following scenarios: + +1. If we are replaying history, due to a worker recovery, and we encounter *workflow.getVersion* for the first time - Record the *minSupported* value and return it. + + - Cadence has correctly identified that a new version has been introduced, and our inflight workflow execution didn't originally support it, so it returns the minimal value. + +2. If we are executing this new workflow for the first time - Record the *maxSupported* value and return it. + + - New instances of a workflow will always return the highest available value for *workflow.getVersion* calls. + +3. If there is an existing entry in the history, return the recorded value. + + - Additional version updates will not impact in-flight workflows, the version they recorded the first time will always be the same. + +#### Making additional changes + +*workflow.getVersion* can support multiple updates. We can increase the *maxSupported* value when we add additional changes and introduce even more branching paths to our workflow code. + +```java + Result1 r1 = activity.step1(); + r1.field = r1.field + 1; + + Result r2 = null; + int version = Workflow.getVersion("step2Updated", Workflow.DEFAULT_VERSION, 2); + + if (version == Workflow.DEFAULT_VERSION) { + // initial code path + r2 = activity.step2(r1); + r2.field = r2.field - 1; + } + else if (version == 1) { + // second version code path + r2 = activity.alternateStep2(r1); + } + else { + // newest code path + r2 = activity.secondAlternateStep2(r1); + } + + Result3 r3 = activity.step3(r2); + + return r3; +``` + +Eventually, all the workflows running the old logic will complete, and we can consider them no long supported. In this case we can update the *minSupported* value, and then we can remove the workflow branch that supported that version. + +```java + Result1 r1 = activity.step1(); + r1.field = r1.field + 1; + + Result r2 = null; + int version = Workflow.getVersion("step2Updated", 1, 2); + + if (version == 1) { + // second version code path + r2 = activity.alternateStep2(r1); + } + else { + // newest code path + r2 = activity.secondAlternateStep2(r1); + } + + Result3 r3 = activity.step3(r2); + + return r3; +``` + +#### Conclusion + +Phew! Our workflow has been updated, and it remains deterministic. The first time we encounter any version check, the returned value is persisted. +If we ever replay the history, the value will always remain the same and we can build our workflow code around that guarantee. + +### Instafood Brief + +Instafood is an online app-based meal delivery service. Customers can place an order for food from their favorite local restaurants via Instafood’s mobile app. Orders can be for pickup or delivery. If delivery is chosen, Instafood will organize to have one of their many delivery drivers pickup the order from the restaurant and deliver it to the customer. Instafood provides each restaurant a kiosk/tablet which is used for communication between Instafood and the restaurant. Instafood notifies the restaurant when an order is placed, and then the restaurant can accept the order, provide an ETA, mark it as ready, etc. For delivery orders, Instafood will coordinate to have a delivery driver pick up based on the ETA. + +## Use Case Example: Instafood courier integration + +In order to see workflow versioning in action, we'll be updating our Instafood workflow with new functionality. + +Our Instafood application dispatches couriers when the customer selects home delivery for their order. For the last few months, customers have been requesting GPS tracking for their couriers to achieve feature parity with our competitors. + +The team responsible for courier integration has been working hard and has finally delivered GPS tracking! It's being offered as an API that accepts the restaurant and delivery address as parameters to register the trip. Once registered, the GPS tracking is delivered by a separate process outside the scope of this article. + +``` java + boolean registerDeliveryGPSTracking(String pickupLocation, String deliveryLocation); +``` + +Now we can integrate it into our *CourerDeliveryWorkflow*, but we can't just add the functionality and deploy the new workflow for all the reasons we just learnt. Any existing workflow that is recovered or has its history replayed will fail. + +So, we will use the versioning API to add a version check for our new feature into *CourerDeliveryWorkflow* + +``` java + // Added new GPS tracking functionality + int workflowVersion = Workflow.getVersion("GPSTrackingSupported", Workflow.DEFAULT_VERSION, 1); + if (workflowVersion >= 1) { + courierGPSActivities.registerDeliveryGPSTracking(courierDeliveryJob.getRestaurant().toString(), courierDeliveryJob.getAddress()); + } +``` + +New workflow instances will be able to register for GPS tracking and the customer will receive updates, and existing workflows will skip over this new functionality if they are being replayed. + +## Setting up Instafood Project + +In order to run the sample project yourself you’ll need to set up a Cadence cluster. We’ll be using Instaclustr’s Managed Service platform to do so. + +### Step 1 - Creating Instaclustr Managed Clusters + +A Cadence cluster requires an Apache Cassandra® cluster to connect to for its persistence layer. In order to set up both Cadence and Cassandra clusters we’ll follow ["Creating a Cadence Cluster" documentation.](https://www.instaclustr.com/support/documentation/cadence/getting-started-with-cadence/creating-a-cadence-cluster/) + +By using Instaclustr platform, the following operations are handled automatically for you: + +- Firewall rules will automatically get configured on the Cassandra cluster for Cadence nodes. +- Authentication between Cadence and Cassandra will get configured, including client encryption settings. +- The Cadence default and visibility keyspaces will be created automatically in Cassandra. +- A link will be created between the two clusters, ensuring you don’t accidentally delete the Cassandra cluster before + Cadence. +- A Load Balancer will be created. It is recommended to use the load balancer address to connect to your cluster. + +### Step 2 - Setting up Cadence Domain + +Cadence is backed by a multi-tenant service where the unit of isolation is called a domain. In order +to get our Instafood application running we first need to register a domain for it. + +1. In order to interact with our Cadence cluster, we need to install its command line interface client. + + **macOS** + + If using a macOS client the Cadence CLI can be installed with Homebrew as follows: + + ```bash + brew install cadence-workflow + # run command line client + cadence + ``` + + **Other Systems** + + If not, the CLI can be used via Docker Hub image `ubercadence/cli`: + + ```bash + # run command line client + docker run --network=host --rm ubercadence/cli:master + ``` + + For the rest of the steps we'll use `cadence` to refer to the client. + +2. In order to connect, it is recommended to use the load balancer address to connect to your cluster. This can be found at the top of the + *Connection Info* tab, and will look like this: "ab-cd12ef23-45gh-4baf-ad99-df4xy-azba45bc0c8da111.elb.us-east-1.amazonaws.com". We'll call this the . + +3. We can now test our connection by listing current domains: + + ```bash + cadence --ad :7933 admin domain list + ``` + +4. Add `instafood` domain: + + ```bash + cadence --ad :7933 --do instafood domain register + ``` + +5. Check it was registered accordingly: + + ```bash + cadence --ad :7933 --do instafood domain describe + ``` + +### Step 3 - Run Instafood Sample Project + +1. Clone Gradle project + from [Instafood project git repository](https://github.com/instaclustr/cadence-cookbooks-instafood). + +2. Open property file at `instafood/src/main/resources/instafood.properties` and replace `cadenceHost` value with your load balancer address: + + ```properties + cadenceHost= + ``` + +3. Run the megaburgers API: + + ```bash + cadence-cookbooks-instafood/megaburgers$ ./gradlew run + ``` + +4. You can now run the app by + + ```bash + cadence-cookbooks-instafood/instafood$ ./gradlew run + ``` + + or executing *InstafoodApplication* main class from your IDE: + + ![Running Instafood app](images/run_instafood.png) + +5. Check it is running by looking into its terminal output: + + ![Instafood running terminal output](images/instafood_app_running.png) + +## Running a Happy-Path Scenario + +To wrap-up, let’s run a whole order scenario. This scenario is part of the test suite included with our sample project. The only requirement is running both Instafood and MegaBurger server as described in the previous steps. This test case describes a client ordering through Instafood MegaBurger’s new *Vegan Burger* for pick-up: + +Let's start by running the server. This can be accomplished by running + + ```bash + cadence-cookbooks-instafood/instafood$ ./gradlew test + ``` + +or *InstafoodApplicationTest* from your IDE + +```java +class InstafoodApplicationTest { + + // ... + + @Test + public void givenAnOrderWithDeliveryItShoulBeSentToMegaBurgerAndDeliveredByACourierAccordingly() { + FoodOrder order = new FoodOrder(Restaurant.MEGABURGER, "vegan burger", 2, "+54 112343-2324", + "Díaz velez 433, La lucila", false); + + // Client orders food + WorkflowExecution workflowExecution = WorkflowClient.start(orderWorkflow::orderFood, order); + + // Wait until order is pending Megaburger's acceptance + await().until(() -> OrderStatus.PENDING.equals(orderWorkflow.getStatus())); + + // Megaburger accepts order and sends ETA + megaBurgerOrdersApiClient.updateStatusAndEta(getLastOrderId(), "ACCEPTED", 15); + + // Wait until order is accepted and we have an ETA + await().until(() -> OrderStatus.ACCEPTED.equals(orderWorkflow.getStatus())); + await().until(() -> orderWorkflow.getEtaInMinutes() != -1); + + // Megaburger marks order as ready + megaBurgerOrdersApiClient.updateStatus(getLastOrderId(), "READY"); + + await().until(() -> getOpenCourierDeliveryWorkflowsWithParentId(workflowExecution.getWorkflowId()) + .size() != 0); + String courierDeliveryWorkflowId = getOpenCourierDeliveryWorkflowsWithParentId( + workflowExecution.getWorkflowId()).get(0) + .getExecution().getWorkflowId(); + CourierDeliveryWorkflow courierDeliveryWorkflow = workflowClient.newWorkflowStub( + CourierDeliveryWorkflow.class, + courierDeliveryWorkflowId); + + // Courier accepts order + courierDeliveryWorkflow.updateStatus(CourierDeliveryStatus.ACCEPTED); + await().until(() -> OrderStatus.COURIER_ACCEPTED.equals(orderWorkflow.getStatus())); + + // All new courier workflows should support GPS tracking, since this is a new + // job it will return true + assertTrue(courierDeliveryWorkflow.courierSupportsGPSTracking()); + + // Courier picked up order + courierDeliveryWorkflow.updateStatus(CourierDeliveryStatus.PICKED_UP); + // Megaburger marks order as delivered + megaBurgerOrdersApiClient.updateStatus(getLastOrderId(), "RESTAURANT_DELIVERED"); + + // Courier delivered order + courierDeliveryWorkflow.updateStatus(CourierDeliveryStatus.DELIVERED); + await().until(() -> OrderStatus.COURIER_DELIVERED.equals(orderWorkflow.getStatus())); + + await().until( + () -> workflowHistoryHasEvent(workflowClient, workflowExecution, + EventType.WorkflowExecutionCompleted)); + + } +} +``` + +We have 3 actors in this scenario: Instafood, MegaBurger and the Client. + +1. The Client sends order to Instafood. +2. Once the order reaches MegaBurger (order status is `PENDING`), MegaBurgers marks it as `ACCEPTED` and sends an ETA. +3. We then have the whole sequence of status updates: + 1. MegaBurger marks order as `COOKING`. + 2. MegaBurger marks order as `READY` (this means it's ready for delivery/pickup). + 3. MegaBurger marks order as `RESTAURANT_DELIVERD`. +4. Since this was an order created as delivery, we now start the Courier workflow to deliver the order + 1. The courier accepts the job - since we now support GPS tracking we can query this support and assert it returns true + 2. Then picks up the order + 3. Finally, the order is delivered +5. Once the order is delivered, our entire workflow is completed. + +## Regression testing old workflows + +As we mentioned before, our workflow versioning logic is built to ensure legacy and in-flight workflows are not made non-deterministic by adding new functionality. + +So how can we test this? + +Cadence supports this with the *WorkflowReplayer* class. This class can take the history of a workflow and replay it against the current implementation. Any non-determinism errors will be detected and thrown by the unit test. + +The history can be retrieved directly from the Cadence cluster, or loaded from a *json* file, as demonstrated in the following example. + +```java +class InstafoodApplicationTest { + + // ... + + @Test + public void givenCourierWorkflowWhenGpsNotSupportedThenHistoryReplaysCorrectly() throws Exception { + // We have stored the history for a workflow that was executed before GPS + // support was added into a file - "resources/history-gps-not-supported.json" + + // We use the workflow replayer to ensure that our legacy workflow can still + // execute correctly. + + WorkflowReplayer.replayWorkflowExecutionFromResource("history-gps-not-supported.json", + CourierDeliveryWorkflowImpl.class); + + // If we did not implement our version check, this method would throw an + // exception -- try it yourself by editing the CourierDeliveryWorkflowImpl + // class! + } +} +``` + +## Wrapping Up + +In this article we introduced the concept of Cadence workflow versioning, and went into a bit of detail explaining how and why it works the way it does. We also showed you how to get a Cadence cluster running with our Instaclustr platform and how easy it is to get an application connect to it. If you’re interested in Cadence and want to learn more about it, you may read about other use cases and documentation at [Cadence workflow - Use cases](https://cadenceworkflow.io/docs/use-cases/). diff --git a/instafood/src/main/java/InstafoodApplication.java b/instafood/src/main/java/InstafoodApplication.java index dcc6eae..33d5993 100644 --- a/instafood/src/main/java/InstafoodApplication.java +++ b/instafood/src/main/java/InstafoodApplication.java @@ -1,5 +1,6 @@ import com.google.common.base.Strings; import com.instafood.orders.delivery.CourierDeliveryWorkflowImpl; +import com.instafood.orders.delivery.activities.CourierGPSActivitiesImpl; import com.instafood.orders.dispatcher.OrderWorkflowImpl; import com.instafood.orders.megaburger.MegaBurgerOrderWorkflowImpl; import com.instafood.orders.megaburger.activities.MegaBurgerRestApiOrderActivities; @@ -30,7 +31,8 @@ public static void main(String[] args) { Worker worker = factory.newWorker(TASK_LIST); worker.registerWorkflowImplementationTypes(OrderWorkflowImpl.class, MegaBurgerOrderWorkflowImpl.class, CourierDeliveryWorkflowImpl.class); - worker.registerActivitiesImplementations(new MegaBurgerRestApiOrderActivities()); + worker.registerActivitiesImplementations(new MegaBurgerRestApiOrderActivities(), + new CourierGPSActivitiesImpl()); factory.start(); } diff --git a/instafood/src/main/java/com/instafood/orders/delivery/CourierDeliveryWorkflow.java b/instafood/src/main/java/com/instafood/orders/delivery/CourierDeliveryWorkflow.java index 7bf0a28..3e909a2 100644 --- a/instafood/src/main/java/com/instafood/orders/delivery/CourierDeliveryWorkflow.java +++ b/instafood/src/main/java/com/instafood/orders/delivery/CourierDeliveryWorkflow.java @@ -1,5 +1,6 @@ package com.instafood.orders.delivery; +import com.uber.cadence.workflow.QueryMethod; import com.uber.cadence.workflow.SignalMethod; import com.uber.cadence.workflow.WorkflowMethod; @@ -9,4 +10,7 @@ public interface CourierDeliveryWorkflow { @SignalMethod void updateStatus(CourierDeliveryStatus status); + + @QueryMethod + boolean courierSupportsGPSTracking(); } diff --git a/instafood/src/main/java/com/instafood/orders/delivery/CourierDeliveryWorkflowImpl.java b/instafood/src/main/java/com/instafood/orders/delivery/CourierDeliveryWorkflowImpl.java index 7e4c400..504ccb3 100644 --- a/instafood/src/main/java/com/instafood/orders/delivery/CourierDeliveryWorkflowImpl.java +++ b/instafood/src/main/java/com/instafood/orders/delivery/CourierDeliveryWorkflowImpl.java @@ -1,12 +1,26 @@ package com.instafood.orders.delivery; +import java.time.Duration; + +import com.instafood.orders.delivery.activities.CourierGPSActivities; import com.instafood.orders.dispatcher.OrderWorkflow; import com.instafood.orders.dispatcher.domain.OrderStatus; +import com.uber.cadence.activity.ActivityOptions; +import com.uber.cadence.common.RetryOptions; import com.uber.cadence.workflow.Workflow; public class CourierDeliveryWorkflowImpl implements CourierDeliveryWorkflow { private CourierDeliveryStatus currentStatus = CourierDeliveryStatus.CREATED; + private boolean supportsGpsTracking = false; + + private final CourierGPSActivities courierGPSActivities = Workflow.newActivityStub(CourierGPSActivities.class, + new ActivityOptions.Builder() + .setRetryOptions(new RetryOptions.Builder() + .setInitialInterval(Duration.ofSeconds(10)) + .setMaximumAttempts(3) + .build()) + .setScheduleToCloseTimeout(Duration.ofMinutes(5)).build()); @Override public void deliverOrder(CourierDeliveryJob courierDeliveryJob) { @@ -20,6 +34,14 @@ public void deliverOrder(CourierDeliveryJob courierDeliveryJob) { } parentOrderWorkflow.updateStatus(OrderStatus.COURIER_ACCEPTED); + // Added new GPS tracking functionality + int workflowVersion = Workflow.getVersion("GPSTrackingSupported", Workflow.DEFAULT_VERSION, 1); + if (workflowVersion >= 1) { + supportsGpsTracking = courierGPSActivities.registerDeliveryGPSTracking( + courierDeliveryJob.getRestaurant().toString(), + courierDeliveryJob.getAddress()); + } + Workflow.await(() -> CourierDeliveryStatus.PICKED_UP.equals(currentStatus)); parentOrderWorkflow.updateStatus(OrderStatus.PICKED_UP); @@ -36,4 +58,10 @@ private OrderWorkflow getParentOrderWorkflow() { public void updateStatus(CourierDeliveryStatus status) { this.currentStatus = status; } + + @Override + public boolean courierSupportsGPSTracking() { + // TODO Auto-generated method stub + return supportsGpsTracking; + } } diff --git a/instafood/src/main/java/com/instafood/orders/delivery/activities/CourierGPSActivities.java b/instafood/src/main/java/com/instafood/orders/delivery/activities/CourierGPSActivities.java new file mode 100644 index 0000000..ccdb835 --- /dev/null +++ b/instafood/src/main/java/com/instafood/orders/delivery/activities/CourierGPSActivities.java @@ -0,0 +1,8 @@ +package com.instafood.orders.delivery.activities; + +import com.uber.cadence.activity.ActivityMethod; + +public interface CourierGPSActivities { + @ActivityMethod + boolean registerDeliveryGPSTracking(String pickupLocation, String deliveryLocation); +} diff --git a/instafood/src/main/java/com/instafood/orders/delivery/activities/CourierGPSActivitiesImpl.java b/instafood/src/main/java/com/instafood/orders/delivery/activities/CourierGPSActivitiesImpl.java new file mode 100644 index 0000000..c6ce929 --- /dev/null +++ b/instafood/src/main/java/com/instafood/orders/delivery/activities/CourierGPSActivitiesImpl.java @@ -0,0 +1,20 @@ +package com.instafood.orders.delivery.activities; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CourierGPSActivitiesImpl implements CourierGPSActivities{ + + private static Logger logger = LoggerFactory.getLogger(CourierGPSActivitiesImpl.class); + + public CourierGPSActivitiesImpl() { + + } + + @Override + public boolean registerDeliveryGPSTracking(String pickupLocation, String deliveryLocation) { + // register a delivery trip + logger.info("GPS tracking enabled. Pickup: {}, delivery to {}", pickupLocation, deliveryLocation); + return true; + } +} diff --git a/instafood/src/test/java/InstafoodApplicationTest.java b/instafood/src/test/java/InstafoodApplicationTest.java index b4c3e5a..2334b8c 100644 --- a/instafood/src/test/java/InstafoodApplicationTest.java +++ b/instafood/src/test/java/InstafoodApplicationTest.java @@ -1,5 +1,6 @@ import com.instafood.orders.delivery.CourierDeliveryStatus; import com.instafood.orders.delivery.CourierDeliveryWorkflow; +import com.instafood.orders.delivery.CourierDeliveryWorkflowImpl; import com.instafood.orders.dispatcher.OrderWorkflow; import com.instafood.orders.dispatcher.domain.FoodOrder; import com.instafood.orders.dispatcher.domain.OrderStatus; @@ -20,6 +21,7 @@ import com.uber.cadence.client.WorkflowOptions; import com.uber.cadence.serviceclient.ClientOptions; import com.uber.cadence.serviceclient.WorkflowServiceTChannel; +import com.uber.cadence.testing.WorkflowReplayer; import org.apache.thrift.TException; import org.awaitility.Awaitility; import org.junit.jupiter.api.BeforeEach; @@ -31,6 +33,7 @@ import java.util.stream.Collectors; import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertTrue; class InstafoodApplicationTest { @@ -174,6 +177,26 @@ public void givenAnOrderWithDeliveryItShoulBeSentToMegaBurgerAndDeliveredByACour await().until( () -> workflowHistoryHasEvent(workflowClient, workflowExecution, EventType.WorkflowExecutionCompleted)); + + // All new courier workflows should support GPS tracking, since this is a new + // job it will return true + assertTrue(courierDeliveryWorkflow.courierSupportsGPSTracking()); + } + + @Test + public void givenCourierWorkflowWhenGpsNotSupportedThenHistoryReplaysCorrectly() throws Exception { + // We have stored the history for a workflow that was executed before GPS + // support was added into a file - "resources/history-gps-not-supported.json" + + // We use the workflow replayer to ensure that our legacy workflow can still + // execute correctly. + + WorkflowReplayer.replayWorkflowExecutionFromResource("history-gps-not-supported.json", + CourierDeliveryWorkflowImpl.class); + + // If we did not implement our version check, this method would throw an + // exception -- try it yourself by editing the CourierDeliveryWorkflowImpl + // class! } private List getOpenCourierDeliveryWorkflowsWithParentId(String parentWorkflowId) { diff --git a/instafood/src/test/resources/history-gps-not-supported.json b/instafood/src/test/resources/history-gps-not-supported.json new file mode 100644 index 0000000..91af869 --- /dev/null +++ b/instafood/src/test/resources/history-gps-not-supported.json @@ -0,0 +1,459 @@ +[ + { + "eventId": 1, + "timestamp": 1673493956391823134, + "eventType": "WorkflowExecutionStarted", + "version": -24, + "taskId": 1048576, + "workflowExecutionStartedEventAttributes": { + "workflowType": { + "name": "CourierDeliveryWorkflow::deliverOrder" + }, + "parentWorkflowDomain": "instafood", + "parentWorkflowExecution": { + "workflowId": "8e3ae8a9-72dc-4d18-afca-866ef375cfd4", + "runId": "f8f77ce0-b425-4ed9-bec1-9198952ec940" + }, + "parentInitiatedEventId": 27, + "taskList": { + "name": "test-worker-task-list" + }, + "input": "eyJyZXN0YXVyYW50IjoiTUVHQUJVUkdFUiIsImFkZHJlc3MiOiJEw61heiB2ZWxleiA0MzMsIExhIGx1Y2lsYSIsInRlbGVwaG9uZSI6Iis1NCAxMTIzNDMtMjMyNCJ9", + "executionStartToCloseTimeoutSeconds": 300, + "taskStartToCloseTimeoutSeconds": 10, + "originalExecutionRunId": "e2b097a9-9ed6-4115-ac5e-7e0c1e6425f1", + "firstExecutionRunId": "e2b097a9-9ed6-4115-ac5e-7e0c1e6425f1", + "firstDecisionTaskBackoffSeconds": 0 + } + }, + { + "eventId": 2, + "timestamp": 1673493956503757897, + "eventType": "DecisionTaskScheduled", + "version": -24, + "taskId": 1048580, + "decisionTaskScheduledEventAttributes": { + "taskList": { + "name": "test-worker-task-list" + }, + "startToCloseTimeoutSeconds": 10 + } + }, + { + "eventId": 3, + "timestamp": 1673493956551816330, + "eventType": "DecisionTaskStarted", + "version": -24, + "taskId": 1048583, + "decisionTaskStartedEventAttributes": { + "scheduledEventId": 2, + "identity": "96024@jdelcast-mac-0", + "requestId": "d8ee531b-8f68-4ca6-8782-96704afdeee8" + } + }, + { + "eventId": 4, + "timestamp": 1673493956841852154, + "eventType": "DecisionTaskCompleted", + "version": -24, + "taskId": 1048586, + "decisionTaskCompletedEventAttributes": { + "scheduledEventId": 2, + "startedEventId": 3, + "identity": "96024@jdelcast-mac-0" + } + }, + { + "eventId": 5, + "timestamp": 1673493958372505399, + "eventType": "WorkflowExecutionSignaled", + "version": -24, + "taskId": 1048588, + "workflowExecutionSignaledEventAttributes": { + "signalName": "CourierDeliveryWorkflow::updateStatus", + "input": "IkFDQ0VQVEVEIg==" + } + }, + { + "eventId": 6, + "timestamp": 1673493958372513988, + "eventType": "DecisionTaskScheduled", + "version": -24, + "taskId": 1048590, + "decisionTaskScheduledEventAttributes": { + "taskList": { + "name": "sticky:jdelcast-mac-0:5b31d8c2-2941-4eb2-82cc-45b9f134b815" + }, + "startToCloseTimeoutSeconds": 10 + } + }, + { + "eventId": 7, + "timestamp": 1673493958432202781, + "eventType": "DecisionTaskStarted", + "version": -24, + "taskId": 1048594, + "decisionTaskStartedEventAttributes": { + "scheduledEventId": 6, + "identity": "96024@jdelcast-mac-0", + "requestId": "e9ae7afd-b0e1-4624-811e-1b58d0b35d97" + } + }, + { + "eventId": 8, + "timestamp": 1673493958702798423, + "eventType": "DecisionTaskCompleted", + "version": -24, + "taskId": 1048597, + "decisionTaskCompletedEventAttributes": { + "scheduledEventId": 6, + "startedEventId": 7, + "identity": "96024@jdelcast-mac-0" + } + }, + { + "eventId": 9, + "timestamp": 1673493958703331600, + "eventType": "SignalExternalWorkflowExecutionInitiated", + "version": -24, + "taskId": 1048598, + "signalExternalWorkflowExecutionInitiatedEventAttributes": { + "decisionTaskCompletedEventId": 8, + "domain": "instafood", + "workflowExecution": { + "workflowId": "8e3ae8a9-72dc-4d18-afca-866ef375cfd4" + }, + "signalName": "OrderWorkflow::updateStatus", + "input": "IkNPVVJJRVJfQUNDRVBURUQi", + "control": "MA==" + } + }, + { + "eventId": 10, + "timestamp": 1673493958810034745, + "eventType": "ExternalWorkflowExecutionSignaled", + "version": -24, + "taskId": 1048601, + "externalWorkflowExecutionSignaledEventAttributes": { + "initiatedEventId": 9, + "domain": "instafood", + "workflowExecution": { + "workflowId": "8e3ae8a9-72dc-4d18-afca-866ef375cfd4" + }, + "control": "MA==" + } + }, + { + "eventId": 11, + "timestamp": 1673493958810057895, + "eventType": "DecisionTaskScheduled", + "version": -24, + "taskId": 1048603, + "decisionTaskScheduledEventAttributes": { + "taskList": { + "name": "sticky:jdelcast-mac-0:5b31d8c2-2941-4eb2-82cc-45b9f134b815" + }, + "startToCloseTimeoutSeconds": 10 + } + }, + { + "eventId": 12, + "timestamp": 1673493958912713751, + "eventType": "DecisionTaskStarted", + "version": -24, + "taskId": 1048607, + "decisionTaskStartedEventAttributes": { + "scheduledEventId": 11, + "identity": "96024@jdelcast-mac-0", + "requestId": "4976e710-acae-4960-b4e0-d0ac5da5ce19" + } + }, + { + "eventId": 13, + "timestamp": 1673493959231295247, + "eventType": "DecisionTaskCompleted", + "version": -24, + "taskId": 1048610, + "decisionTaskCompletedEventAttributes": { + "scheduledEventId": 11, + "startedEventId": 12, + "identity": "96024@jdelcast-mac-0" + } + }, + { + "eventId": 14, + "timestamp": 1673493959233775319, + "eventType": "MarkerRecorded", + "version": -24, + "taskId": 1048611, + "markerRecordedEventAttributes": { + "markerName": "Version", + "details": "LTE=", + "decisionTaskCompletedEventId": 13, + "header": { + "fields": { + "MutableMarkerHeader": "eyJpZCI6IkdQU1RyYWNraW5nU3VwcG9ydGVkIiwiZXZlbnRJZCI6MTQsImFjY2Vzc0NvdW50IjowfQ==" + } + } + } + }, + { + "eventId": 15, + "timestamp": 1673493959252533139, + "eventType": "UpsertWorkflowSearchAttributes", + "version": -24, + "taskId": 1048612, + "upsertWorkflowSearchAttributesEventAttributes": { + "decisionTaskCompletedEventId": 13, + "searchAttributes": { + "indexedFields": { + "CadenceChangeVersion": "WyJHUFNUcmFja2luZ1N1cHBvcnRlZC0tMSJd" + } + } + } + }, + { + "eventId": 19, + "timestamp": 1673493959632514571, + "eventType": "DecisionTaskScheduled", + "version": -24, + "taskId": 1048623, + "decisionTaskScheduledEventAttributes": { + "taskList": { + "name": "sticky:jdelcast-mac-0:5b31d8c2-2941-4eb2-82cc-45b9f134b815" + }, + "startToCloseTimeoutSeconds": 10 + } + }, + { + "eventId": 20, + "timestamp": 1673493959674857258, + "eventType": "DecisionTaskStarted", + "version": -24, + "taskId": 1048627, + "decisionTaskStartedEventAttributes": { + "scheduledEventId": 19, + "identity": "96024@jdelcast-mac-0", + "requestId": "6410e213-cd35-4dc8-bce5-58aec5e1c25c" + } + }, + { + "eventId": 21, + "timestamp": 1673493959983283178, + "eventType": "DecisionTaskCompleted", + "version": -24, + "taskId": 1048630, + "decisionTaskCompletedEventAttributes": { + "scheduledEventId": 19, + "startedEventId": 20, + "identity": "96024@jdelcast-mac-0" + } + }, + { + "eventId": 22, + "timestamp": 1673493960156957853, + "eventType": "WorkflowExecutionSignaled", + "version": -24, + "taskId": 1048632, + "workflowExecutionSignaledEventAttributes": { + "signalName": "CourierDeliveryWorkflow::updateStatus", + "input": "IlBJQ0tFRF9VUCI=" + } + }, + { + "eventId": 23, + "timestamp": 1673493960156966427, + "eventType": "DecisionTaskScheduled", + "version": -24, + "taskId": 1048634, + "decisionTaskScheduledEventAttributes": { + "taskList": { + "name": "sticky:jdelcast-mac-0:5b31d8c2-2941-4eb2-82cc-45b9f134b815" + }, + "startToCloseTimeoutSeconds": 10 + } + }, + { + "eventId": 24, + "timestamp": 1673493960215330232, + "eventType": "DecisionTaskStarted", + "version": -24, + "taskId": 1048638, + "decisionTaskStartedEventAttributes": { + "scheduledEventId": 23, + "identity": "96024@jdelcast-mac-0", + "requestId": "70b23c7a-039b-4e53-92f5-5f3ee74793bb" + } + }, + { + "eventId": 25, + "timestamp": 1673493960504422318, + "eventType": "DecisionTaskCompleted", + "version": -24, + "taskId": 1048641, + "decisionTaskCompletedEventAttributes": { + "scheduledEventId": 23, + "startedEventId": 24, + "identity": "96024@jdelcast-mac-0" + } + }, + { + "eventId": 26, + "timestamp": 1673493960504485959, + "eventType": "SignalExternalWorkflowExecutionInitiated", + "version": -24, + "taskId": 1048642, + "signalExternalWorkflowExecutionInitiatedEventAttributes": { + "decisionTaskCompletedEventId": 25, + "domain": "instafood", + "workflowExecution": { + "workflowId": "8e3ae8a9-72dc-4d18-afca-866ef375cfd4" + }, + "signalName": "OrderWorkflow::updateStatus", + "input": "IlBJQ0tFRF9VUCI=", + "control": "Mg==" + } + }, + { + "eventId": 27, + "timestamp": 1673493960458292737, + "eventType": "WorkflowExecutionSignaled", + "version": -24, + "taskId": 1048643, + "workflowExecutionSignaledEventAttributes": { + "signalName": "CourierDeliveryWorkflow::updateStatus", + "input": "IkRFTElWRVJFRCI=" + } + }, + { + "eventId": 28, + "timestamp": 1673493960504499013, + "eventType": "DecisionTaskScheduled", + "version": -24, + "taskId": 1048647, + "decisionTaskScheduledEventAttributes": { + "taskList": { + "name": "sticky:jdelcast-mac-0:5b31d8c2-2941-4eb2-82cc-45b9f134b815" + }, + "startToCloseTimeoutSeconds": 10 + } + }, + { + "eventId": 29, + "timestamp": 1673493960652669336, + "eventType": "ExternalWorkflowExecutionSignaled", + "version": -24, + "taskId": 1048652, + "externalWorkflowExecutionSignaledEventAttributes": { + "initiatedEventId": 26, + "domain": "instafood", + "workflowExecution": { + "workflowId": "8e3ae8a9-72dc-4d18-afca-866ef375cfd4" + }, + "control": "Mg==" + } + }, + { + "eventId": 30, + "timestamp": 1673493960672887726, + "eventType": "DecisionTaskStarted", + "version": -24, + "taskId": 1048654, + "decisionTaskStartedEventAttributes": { + "scheduledEventId": 28, + "identity": "96024@jdelcast-mac-0", + "requestId": "53329f7f-644b-4ed0-9fab-5318eae9cd7c" + } + }, + { + "eventId": 31, + "timestamp": 1673493960962377046, + "eventType": "DecisionTaskCompleted", + "version": -24, + "taskId": 1048657, + "decisionTaskCompletedEventAttributes": { + "scheduledEventId": 28, + "startedEventId": 30, + "identity": "96024@jdelcast-mac-0" + } + }, + { + "eventId": 32, + "timestamp": 1673493960966488529, + "eventType": "SignalExternalWorkflowExecutionInitiated", + "version": -24, + "taskId": 1048658, + "signalExternalWorkflowExecutionInitiatedEventAttributes": { + "decisionTaskCompletedEventId": 31, + "domain": "instafood", + "workflowExecution": { + "workflowId": "8e3ae8a9-72dc-4d18-afca-866ef375cfd4" + }, + "signalName": "OrderWorkflow::updateStatus", + "input": "IkNPVVJJRVJfREVMSVZFUkVEIg==", + "control": "Mw==" + } + }, + { + "eventId": 33, + "timestamp": 1673493961021731696, + "eventType": "ExternalWorkflowExecutionSignaled", + "version": -24, + "taskId": 1048661, + "externalWorkflowExecutionSignaledEventAttributes": { + "initiatedEventId": 32, + "domain": "instafood", + "workflowExecution": { + "workflowId": "8e3ae8a9-72dc-4d18-afca-866ef375cfd4" + }, + "control": "Mw==" + } + }, + { + "eventId": 34, + "timestamp": 1673493961021744957, + "eventType": "DecisionTaskScheduled", + "version": -24, + "taskId": 1048663, + "decisionTaskScheduledEventAttributes": { + "taskList": { + "name": "sticky:jdelcast-mac-0:5b31d8c2-2941-4eb2-82cc-45b9f134b815" + }, + "startToCloseTimeoutSeconds": 10 + } + }, + { + "eventId": 35, + "timestamp": 1673493962093177180, + "eventType": "DecisionTaskStarted", + "version": -24, + "taskId": 1048667, + "decisionTaskStartedEventAttributes": { + "scheduledEventId": 34, + "identity": "96024@jdelcast-mac-0", + "requestId": "e6046984-558b-436e-85c7-8bb49b43657a" + } + }, + { + "eventId": 36, + "timestamp": 1673493962522386562, + "eventType": "DecisionTaskCompleted", + "version": -24, + "taskId": 1048670, + "decisionTaskCompletedEventAttributes": { + "scheduledEventId": 34, + "startedEventId": 35, + "identity": "96024@jdelcast-mac-0" + } + }, + { + "eventId": 37, + "timestamp": 1673493962524148686, + "eventType": "WorkflowExecutionCompleted", + "version": -24, + "taskId": 1048671, + "workflowExecutionCompletedEventAttributes": { + "decisionTaskCompletedEventId": 36 + } + } +] \ No newline at end of file