Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New shopping cart sample #379

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
10 changes: 10 additions & 0 deletions early-return/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,18 @@ This sample demonstrates an early-return from a workflow.
By utilizing Update-with-Start, a client can start a new workflow and synchronously receive
a response mid-workflow, while the workflow continues to run to completion.

See [shopping cart](https://github.com/temporalio/samples-go/tree/main/shoppingcart)
for Update-with-Start being used for lazy initialization.

### Steps to run this sample:
1) Run a [Temporal service](https://github.com/temporalio/samples-go/tree/main/#how-to-use).

NOTE: frontend.enableExecuteMultiOperation=true must be configured for the server
in order to use Update-with-Start. For example:
```
temporal server start-dev --dynamic-config-value frontend.enableExecuteMultiOperation=true
```

2) Run the following command to start the worker
```
go run early-return/worker/main.go
Expand Down
8 changes: 4 additions & 4 deletions early-return/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func Test_CompleteTransaction_Succeeds(t *testing.T) {
env.UpdateWorkflow(UpdateName, uuid.New(), &testsuite.TestUpdateCallback{
OnAccept: func() {},
OnReject: func(err error) {
panic("unexpected rejection")
require.Fail(t, "unexpected rejection")
},
OnComplete: func(i interface{}, err error) {
require.NoError(t, err)
Expand Down Expand Up @@ -50,7 +50,7 @@ func Test_CompleteTransaction_Fails(t *testing.T) {
env.UpdateWorkflow(UpdateName, uuid.New(), &testsuite.TestUpdateCallback{
OnAccept: func() {},
OnReject: func(err error) {
panic("unexpected rejection")
require.Fail(t, "unexpected rejection")
},
OnComplete: func(i interface{}, err error) {
require.NoError(t, err)
Expand All @@ -76,7 +76,7 @@ func Test_CancelTransaction(t *testing.T) {
env.UpdateWorkflow(UpdateName, uuid.New(), &testsuite.TestUpdateCallback{
OnAccept: func() {},
OnReject: func(err error) {
panic("unexpected rejection")
require.Fail(t, "unexpected rejection")
},
OnComplete: func(i interface{}, err error) {
require.ErrorContains(t, err, "invalid Amount")
Expand All @@ -103,7 +103,7 @@ func Test_CancelTransaction_Fails(t *testing.T) {
env.UpdateWorkflow(UpdateName, uuid.New(), &testsuite.TestUpdateCallback{
OnAccept: func() {},
OnReject: func(err error) {
panic("unexpected rejection")
require.Fail(t, "unexpected rejection")
},
OnComplete: func(i interface{}, err error) {
require.ErrorContains(t, err, "invalid Amount")
Expand Down
10 changes: 5 additions & 5 deletions expense/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@ method has to return before it is actually approved. This is done by returning a
the activity is not completed yet.
* When the expense is approved (or rejected), somewhere in the world needs to be notified, and it will need to call
`client.CompleteActivity()` to tell Temporal service that that activity is now completed.
In this sample case, the dummy server does this job. In real world, you will need to register some listener
In this sample case, the sample expense system does this job. In real world, you will need to register some listener
to the expense system or you will need to have your own polling agent to check for the expense status periodically.
* After the wait activity is completed, it does the payment for the expense (dummy step in this sample case).
* After the wait activity is completed, it does the payment for the expense (UI step in this sample case).

This sample relies on an a dummy expense server to work.
This sample relies on an a sample expense system to work.
Get a Temporal service running [here](https://github.com/temporalio/samples-go/tree/main/#how-to-use).

# Steps To Run Sample
* You need a Temporal service running. README.md for more details.
* Start the dummy server
* Start the sample expense system UI
```
go run expense/server/main.go
go run expense/ui/main.go
```
* Start workflow and activity workers
```
Expand Down
2 changes: 1 addition & 1 deletion expense/activities.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func CreateExpenseActivity(ctx context.Context, expenseID string) error {
// waitForDecisionActivity waits for the expense decision. This activity will complete asynchronously. When this method
// returns error activity.ErrResultPending, the Temporal Go SDK recognize this error, and won't mark this activity
// as failed or completed. The Temporal server will wait until Client.CompleteActivity() is called or timeout happened
// whichever happen first. In this sample case, the CompleteActivity() method is called by our dummy expense server when
// whichever happen first. In this sample case, the CompleteActivity() method is called by our sample expense system when
// the expense is approved.
func WaitForDecisionActivity(ctx context.Context, expenseID string) (string, error) {
if len(expenseID) == 0 {
Expand Down
9 changes: 5 additions & 4 deletions expense/server/main.go → expense/ui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

/**
* Dummy server that support to list expenses, create new expense, update expense state and checking expense state.
* Sample expense system that support to list expenses, create new expense, update expense state and checking expense state.
*/

type expenseState string
Expand All @@ -22,7 +22,7 @@ const (
completed expenseState = "COMPLETED"
)

// use memory store for this dummy server
// use memory store for this sample expense system
var (
allExpense = make(map[string]expenseState)
tokenMap = make(map[string][]byte)
Expand All @@ -39,18 +39,19 @@ func main() {
panic(err)
}

fmt.Println("Starting dummy server...")
http.HandleFunc("/", listHandler)
http.HandleFunc("/list", listHandler)
http.HandleFunc("/create", createHandler)
http.HandleFunc("/action", actionHandler)
http.HandleFunc("/status", statusHandler)
http.HandleFunc("/registerCallback", callbackHandler)

fmt.Println("Expense system UI available at http://localhost:8099")
_ = http.ListenAndServe(":8099", nil)
}

func listHandler(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(w, "<h1>DUMMY EXPENSE SYSTEM</h1>"+"<a href=\"/list\">HOME</a>"+
_, _ = fmt.Fprint(w, "<h1>SAMPLE EXPENSE SYSTEM</h1>"+"<a href=\"/list\">HOME</a>"+
"<h3>All expense requests:</h3><table border=1><tr><th>Expense ID</th><th>Status</th><th>Action</th>")
var keys []string
for k := range allExpense {
Expand Down
32 changes: 32 additions & 0 deletions shoppingcart/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Shopping Cart

This sample workflow shows how a shopping cart application can be implemented.
This sample utilizes Update-with-Start and the `WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING`
option to start and continually update the workflow with the same Update-with-Start
call. This is also known as lazy-init. You will see in the Temporal UI, when you checkout
your cart, the current workflow will complete and a new workflow will be created. This sample
only supports a single concurrent shopper, but can be extended to support concurrent shoppers,
identified with the `sessionId` infrastructure shown in this sample.

Another interesting Update-with-Start use case is
[early return](https://github.com/temporalio/samples-go/tree/main/early-return),
which supplements this sample and can be used to handle the transaction and payment
portion of this shopping cart scenario.

### Steps to run this sample:
1) Run a [Temporal service](https://github.com/temporalio/samples-go/tree/main/#how-to-use).

NOTE: frontend.enableExecuteMultiOperation=true must be configured for the server
in order to use Update-with-Start. For example:
```
temporal server start-dev --dynamic-config-value frontend.enableExecuteMultiOperation=true
```

2) Run the following command to start the worker
```
go run shoppingcart/worker/main.go
```
3) Run the following command to start the web app
```
go run shoppingcart/ui/main.go
```
158 changes: 158 additions & 0 deletions shoppingcart/ui/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package main

import (
"context"
"fmt"
"github.com/pborman/uuid"
"github.com/temporalio/samples-go/shoppingcart"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/sdk/client"
"log"
"net/http"
"sort"
)

var (
workflowClient client.Client
// Units are in cents
itemCosts = map[string]int{
"apple": 200,
"banana": 100,
"watermelon": 500,
"television": 100000,
"house": 100000000,
"car": 5000000,
"binder": 1000,
}
sessionId = newSession()
)

func main() {
var err error
workflowClient, err = client.Dial(client.Options{
HostPort: client.DefaultHostPort,
})
if err != nil {
panic(err)
}

http.HandleFunc("/", listHandler)
http.HandleFunc("/action", actionHandler)

fmt.Println("Shopping Cart UI available at http://localhost:8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Println("Error starting server:", err)
}
}

func listHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html") // Set the content type to HTML
_, _ = fmt.Fprint(w, "<h1>SAMPLE SHOPPING WEBSITE</h1>"+
"<a href=\"/list\">HOME</a> <a href=\"/action?type=checkout\">Checkout</a>"+
"<h3>Available Items to Purchase</h3><table border=1><tr><th>Item</th><th>Cost</th><th>Action</th>")

keys := make([]string, 0)
for k := range itemCosts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
actionButton := fmt.Sprintf("<a href=\"/action?type=add&id=%s\">"+
"<button style=\"background-color:#4CAF50;\">Add to Cart</button></a>", k)
dollars := float64(itemCosts[k]) / 100
_, _ = fmt.Fprintf(w, "<tr><td>%s</td><td>$%.2f</td><td>%s</td></tr>", k, dollars, actionButton)
}
_, _ = fmt.Fprint(w, "</table><h3>Current items in cart:</h3>"+
"<table border=1><tr><th>Item</th><th>Quantity</th><th>Action</th>")

cartState := updateWithStartCart("list", "")

// List current items in cart
keys = make([]string, 0)
for k := range cartState.Items {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
removeButton := fmt.Sprintf("<a href=\"/action?type=remove&id=%s\">"+
"<button style=\"background-color:#f44336;\">Remove Item</button></a>", k)
_, _ = fmt.Fprintf(w, "<tr><td>%s</td><td>%d</td><td>%s</td></tr>", k, cartState.Items[k], removeButton)
}
_, _ = fmt.Fprint(w, "</table>")
}

func actionHandler(w http.ResponseWriter, r *http.Request) {
actionType := r.URL.Query().Get("type")
switch actionType {
case "checkout":
err := workflowClient.SignalWorkflow(context.Background(), sessionId, "", "checkout", nil)
if err != nil {
log.Fatalln("Error signaling checkout:", err)
}
sessionId = newSession()
log.Println("Items checked out and workflow completed, starting new workflow")
case "add", "remove", "list":
id := r.URL.Query().Get("id")
updateWithStartCart(actionType, id)
default:
log.Fatalln("Invalid action type:", actionType)
}

// Generate the HTML after communicating with the Temporal workflow.
// "list" already generates HTML, so skip for that scenario
if actionType != "list" {
listHandler(w, r)
}
}

func updateWithStartCart(actionType string, id string) shoppingcart.CartState {
// Handle a client request to add an item to the shopping cart. The user is not logged in, but a session ID is
// available from a cookie, and we use this as the cart ID. The Temporal client was created at service-start
// time and is shared by all request handlers.
//
// A Workflow Type exists that can be used to represent a shopping cart. The method uses update-with-start to
// add an item to the shopping cart, creating the cart if it doesn't already exist.
//
// Note that the workflow handle is available, even if the Update fails.
ctx := context.Background()
startWorkflowOp := workflowClient.NewWithStartWorkflowOperation(client.StartWorkflowOptions{
ID: sessionId,
TaskQueue: shoppingcart.TaskQueueName,
// WorkflowIDConflictPolicy is required when using UpdateWithStartWorkflow.
// Here we use USE_EXISTING, because we want to reuse the running workflow, as it
// is long-running and keeping track of our cart state.
WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING,
}, shoppingcart.CartWorkflow)

updateWithStartOptions := client.UpdateWithStartWorkflowOptions{
StartWorkflowOperation: startWorkflowOp,
UpdateOptions: client.UpdateWorkflowOptions{
UpdateName: shoppingcart.UpdateName,
WaitForStage: client.WorkflowUpdateStageCompleted,
Args: []interface{}{actionType, id},
},
}
updateHandle, err := workflowClient.UpdateWithStartWorkflow(ctx, updateWithStartOptions)
if err != nil {
// For example, a client-side validation error (e.g. missing conflict
// policy or invalid workflow argument types in the start operation), or
// a server-side failure (e.g. failed to start workflow, or exceeded
// limit on concurrent update per workflow execution).
log.Fatalln("Error issuing update-with-start:", err)
}

log.Println("Updated workflow",
"WorkflowID:", updateHandle.WorkflowID(),
"RunID:", updateHandle.RunID())

// Always use a zero variable before calling Get for any Go SDK API
cartState := shoppingcart.CartState{Items: make(map[string]int)}
if err = updateHandle.Get(ctx, &cartState); err != nil {
log.Fatalln("Error obtaining update result:", err)
}
return cartState
}

func newSession() string {
return "session-" + uuid.New()
}
30 changes: 30 additions & 0 deletions shoppingcart/worker/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package main

import (
"log"

"go.temporal.io/sdk/client"
"go.temporal.io/sdk/worker"

"github.com/temporalio/samples-go/shoppingcart"
)

func main() {
// The client and worker are heavyweight objects that should be created once per process.
c, err := client.Dial(client.Options{
HostPort: client.DefaultHostPort,
})
if err != nil {
log.Fatalln("Unable to create client", err)
}
defer c.Close()

w := worker.New(c, shoppingcart.TaskQueueName, worker.Options{})

w.RegisterWorkflow(shoppingcart.CartWorkflow)

err = w.Run(worker.InterruptCh())
if err != nil {
log.Fatalln("Unable to start worker", err)
}
}
Loading