-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathmain.go
More file actions
421 lines (382 loc) · 14.9 KB
/
Copy pathmain.go
File metadata and controls
421 lines (382 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
// A guided tour of the code generated for the example.v1.Orders service.
//
// Start the temporal dev stack (docker compose up -d) and the worker
// (go run ./example/worker), then run this:
//
// go run ./example/client
//
// Each step is narrated in the logs and maps to a feature of the plugin:
//
// step 1: workflows, queries and updates on the happy path
// step 2: rejected updates (validation)
// step 3: cancelling a workflow with a signal
// step 4: transient activity failures and retry policies
// step 5: non-retryable failures (how errors surface to the caller)
// step 6: schedules
// step 7: blocking updates and continue-as-new (the entity pattern)
//
// The workflow executions are also visible in the temporal UI, by default on
// http://localhost:8080
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"time"
"github.com/charmbracelet/log"
examplev1 "github.com/thomas-maurice/protoc-gen-go-tmprl/gen/example/v1"
"go.temporal.io/sdk/client"
tlog "go.temporal.io/sdk/log"
"go.temporal.io/sdk/temporal"
"google.golang.org/protobuf/types/known/emptypb"
)
// step prints a visible banner so the walkthrough is easy to follow in the
// terminal.
func step(n int, title string) {
fmt.Println()
fmt.Printf("---[ step %d: %s ]---\n", n, title)
fmt.Println()
}
// someItems is what every demo order contains.
func someItems() []*examplev1.OrderItem {
return []*examplev1.OrderItem{
{Sku: "die-d6", Quantity: 2},
{Sku: "die-d20", Quantity: 1},
{Sku: "dice-tray", Quantity: 1},
}
}
func main() {
logger := slog.New(
log.NewWithOptions(os.Stderr, log.Options{
Level: log.InfoLevel,
ReportTimestamp: true,
TimeFormat: time.RFC3339,
Formatter: log.TextFormatter,
}),
)
// TEMPORAL_ADDRESS overrides the target server, defaults to localhost:7233.
c, err := client.NewLazyClient(client.Options{
HostPort: os.Getenv("TEMPORAL_ADDRESS"),
Logger: tlog.NewStructuredLogger(logger),
})
if err != nil {
logger.Error("could not create temporal client", "error", err)
os.Exit(1)
}
defer c.Close()
// The generated, typed client: every workflow/activity/signal/query/update
// of the service is a method on it. Passing no task queue uses the default
// one declared in the proto ("orders").
orders, err := examplev1.NewOrdersClient(c)
if err != nil {
logger.Error("could not create orders client", "error", err)
os.Exit(1)
}
ctx := context.Background()
// -----------------------------------------------------------------------
step(1, "the happy path: start a workflow, query it, update it")
// -----------------------------------------------------------------------
// Start the workflow asynchronously: we get a future back immediately.
// With gen-workflow-prefix=true the generator picks a readable workflow ID
// (example.v1.Orders.ProcessOrder/<uuid>) for us.
future, err := orders.ExecuteWorkflowProcessOrder(ctx, &examplev1.ProcessOrderRequest{
OrderId: "order-happy",
Items: someItems(),
AmountCents: 4200,
CardToken: "tok-ok",
ShippingAddress: "1 rue de la Paix, Paris",
})
if err != nil {
logger.Error("could not start workflow", "error", err)
os.Exit(1)
}
logger.Info("workflow started", "workflow_id", future.GetID(), "run_id", future.GetRunID())
// Wrap the run in the generated workflow object: it carries typed
// Query/Update/Signal/Result methods for this specific execution.
order := orders.GetProcessOrderFromRun(future)
// Watch the order move through its lifecycle with the GetOrderStatus
// query. Queries are read only and cheap; poll away.
watchCtx, stopWatching := context.WithCancel(ctx)
watcherDone := make(chan struct{})
go func() {
defer close(watcherDone)
last := ""
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-watchCtx.Done():
return
case <-ticker.C:
st, err := order.QueryGetOrderStatus(watchCtx, &emptypb.Empty{})
if err != nil {
continue
}
if st.Status.String() != last {
last = st.Status.String()
logger.Info("query: order status changed", "status", st.Status, "ships_to", st.ShippingAddress)
}
}
}
}()
// Give the order a moment to get paid and packed...
time.Sleep(2 * time.Second)
// ...then re-route it mid-flight with an update. This call BLOCKS until
// the workflow's update handler ran and returns its typed response:
// request/response semantics against a running workflow.
moved, err := order.UpdateChangeShippingAddress(ctx, &examplev1.ChangeShippingAddressRequest{
Address: "42 quai des Orfevres, Paris",
})
if err != nil {
logger.Error("could not update the shipping address", "error", err)
os.Exit(1)
}
logger.Info("update: shipping address changed", "previous_address", moved.PreviousAddress)
// -----------------------------------------------------------------------
step(2, "a rejected update: validators run before anything is recorded")
// -----------------------------------------------------------------------
// An empty address does not pass the validator the worker registered.
// The update is rejected before it ever reaches the workflow history and
// the error comes straight back to us. This time we use the client-level
// helper (addressing the execution by ID) instead of the workflow object.
_, err = orders.UpdateChangeShippingAddress(ctx, future.GetID(), future.GetRunID(), &examplev1.ChangeShippingAddressRequest{
Address: "",
})
if err != nil {
logger.Info("update rejected as expected", "error", err)
} else {
logger.Error("the empty address should have been rejected")
os.Exit(1)
}
// Wait for the order to ship and collect the typed result.
result, err := order.Result(ctx)
if err != nil {
logger.Error("workflow failed", "error", err)
os.Exit(1)
}
stopWatching()
<-watcherDone
logger.Info("workflow finished",
"status", result.Status,
"shipped_to", result.ShippingAddress,
"tracking_number", result.TrackingNumber,
)
// Queries still work on completed workflows, as long as a worker runs.
st, err := order.QueryGetOrderStatus(ctx, &emptypb.Empty{})
if err != nil {
logger.Error("could not query the finished workflow", "error", err)
os.Exit(1)
}
logger.Info("query still answers after completion", "status", st.Status)
// -----------------------------------------------------------------------
step(3, "cancelling an order with a signal")
// -----------------------------------------------------------------------
future, err = orders.ExecuteWorkflowProcessOrder(ctx, &examplev1.ProcessOrderRequest{
OrderId: "order-cancelled",
Items: someItems(),
AmountCents: 4200,
CardToken: "tok-ok",
ShippingAddress: "1 rue de la Paix, Paris",
})
if err != nil {
logger.Error("could not start workflow", "error", err)
os.Exit(1)
}
logger.Info("workflow started", "workflow_id", future.GetID())
// Let it get paid, then change our mind. Signals are fire and forget:
// this returns as soon as the signal is recorded, there is no response.
time.Sleep(1500 * time.Millisecond)
err = orders.SendSignalCancelOrder(ctx, future.GetID(), future.GetRunID(), &examplev1.CancelOrderRequest{
Reason: "changed my mind",
})
if err != nil {
logger.Error("could not send signal", "error", err)
os.Exit(1)
}
logger.Info("signal: cancellation sent")
result, err = orders.GetProcessOrderFromRun(future).Result(ctx)
if err != nil {
logger.Error("workflow failed", "error", err)
os.Exit(1)
}
logger.Info("workflow finished", "status", result.Status, "cancel_reason", result.CancelReason)
// -----------------------------------------------------------------------
step(4, "transient failures: the retry policy does its job")
// -----------------------------------------------------------------------
// The "tok-flaky" card makes the ChargePayment activity fail twice before
// succeeding. The retry policy declared in the proto (1s initial interval,
// backoff 2.0) retries it transparently: from here it just looks slow.
// Watch the worker logs to see the two failed attempts.
logger.Info("ordering with a flaky payment provider, watch the worker logs...")
start := time.Now()
result, err = orders.ExecuteWorkflowProcessOrderSync(ctx, &examplev1.ProcessOrderRequest{
OrderId: "order-flaky",
Items: someItems(),
AmountCents: 4200,
CardToken: "tok-flaky",
ShippingAddress: "1 rue de la Paix, Paris",
})
if err != nil {
logger.Error("workflow failed", "error", err)
os.Exit(1)
}
logger.Info("workflow survived the flaky payment provider",
"status", result.Status,
"took", time.Since(start).Round(time.Second),
)
// -----------------------------------------------------------------------
step(5, "non-retryable failures: how errors reach the caller")
// -----------------------------------------------------------------------
// "tok-declined" makes ChargePayment return an application error of type
// "CardDeclined", which the retry policy lists as non retryable: no
// retries, the workflow fails immediately, and the typed error is
// available to the caller through errors.As.
_, err = orders.ExecuteWorkflowProcessOrderSync(ctx, &examplev1.ProcessOrderRequest{
OrderId: "order-declined",
Items: someItems(),
AmountCents: 999999,
CardToken: "tok-declined",
ShippingAddress: "1 rue de la Paix, Paris",
})
if err == nil {
logger.Error("the declined card should have failed the workflow")
os.Exit(1)
}
var appErr *temporal.ApplicationError
if errors.As(err, &appErr) {
logger.Info("workflow failed as expected",
"error_type", appErr.Type(),
"message", appErr.Message(),
)
} else {
logger.Error("unexpected error shape", "error", err)
os.Exit(1)
}
// -----------------------------------------------------------------------
step(6, "schedules: run a workflow on a cadence")
// -----------------------------------------------------------------------
// Every workflow gets schedule helpers, no proto annotation needed. The
// caller owns the Spec (interval, cron, calendar...); the generator fills
// in the workflow name, arguments and default task queue.
const scheduleID = "daily-sales-report"
_, err = orders.UpsertScheduleDailySalesReport(ctx, scheduleID, &emptypb.Empty{}, client.ScheduleOptions{
Spec: client.ScheduleSpec{
Intervals: []client.ScheduleIntervalSpec{{Every: time.Minute}},
},
})
if err != nil {
logger.Error("could not upsert schedule", "error", err)
os.Exit(1)
}
logger.Info("schedule upserted", "schedule_id", scheduleID, "every", "1m")
// Listing goes through the visibility store, which is eventually
// consistent: a schedule created a millisecond ago may not show up yet,
// so poll briefly instead of trusting a single call.
var schedules []client.ScheduleListEntry
for deadline := time.Now().Add(10 * time.Second); ; {
schedules, err = orders.ListScheduleDailySalesReport(ctx, 10)
if err != nil {
logger.Error("could not list schedules", "error", err)
os.Exit(1)
}
if len(schedules) > 0 || time.Now().After(deadline) {
break
}
time.Sleep(500 * time.Millisecond)
}
logger.Info("schedules targeting DailySalesReport", "count", len(schedules))
// Pause and resume are idempotent: they describe first and no-op if the
// schedule is already in the desired state.
if err := orders.PauseScheduleDailySalesReport(ctx, scheduleID, "walkthrough pause"); err != nil {
logger.Error("could not pause schedule", "error", err)
os.Exit(1)
}
logger.Info("schedule paused")
if err := orders.UnpauseScheduleDailySalesReport(ctx, scheduleID, "walkthrough unpause"); err != nil {
logger.Error("could not unpause schedule", "error", err)
os.Exit(1)
}
logger.Info("schedule unpaused")
// Clean up so re-running the walkthrough starts fresh. Comment this out
// if you want to watch the report fire every minute in the UI.
if err := orders.DeleteScheduleDailySalesReport(ctx, scheduleID); err != nil {
logger.Error("could not delete schedule", "error", err)
os.Exit(1)
}
logger.Info("schedule deleted")
// -----------------------------------------------------------------------
step(7, "blocking updates and continue-as-new: the entity pattern")
// -----------------------------------------------------------------------
// TrackInventory is a long-lived entity workflow: it absorbs Restock
// signals and rolls over with continue-as-new after each one. Reserve is
// a BLOCKING update: with an empty inventory the handler parks inside the
// workflow until stock arrives, and so does our call.
invFuture, err := orders.ExecuteWorkflowTrackInventory(ctx, &examplev1.TrackInventoryRequest{
Sku: "die-d20",
InitialStock: 0,
RestocksBeforeContinueAsNew: 1,
})
if err != nil {
logger.Error("could not start inventory workflow", "error", err)
os.Exit(1)
}
logger.Info("inventory workflow started with zero stock", "workflow_id", invFuture.GetID())
type reserveOutcome struct {
resp *examplev1.ReserveResponse
err error
}
reserveCh := make(chan reserveOutcome, 1)
go func() {
resp, err := orders.UpdateReserve(ctx, invFuture.GetID(), "", &examplev1.ReserveRequest{Quantity: 3})
reserveCh <- reserveOutcome{resp, err}
}()
logger.Info("sent Reserve(3): the update handler is now parked on workflow.Await(stock >= 3), and so are we")
time.Sleep(2 * time.Second)
select {
case <-reserveCh:
logger.Error("the reservation should still be blocked")
os.Exit(1)
default:
logger.Info("still blocked, as expected: no stock yet")
}
// This restock satisfies the parked reservation AND triggers the
// continue-as-new rollover. The workflow drains its update handlers
// before rolling over, so the parked caller is answered first.
if err := orders.SendSignalRestock(ctx, invFuture.GetID(), "", &examplev1.RestockRequest{Quantity: 5}); err != nil {
logger.Error("could not send restock signal", "error", err)
os.Exit(1)
}
logger.Info("sent Restock(5)")
outcome := <-reserveCh
if outcome.err != nil {
logger.Error("reservation failed", "error", outcome.err)
os.Exit(1)
}
logger.Info("blocked update answered",
"remaining_stock", outcome.resp.RemainingStock,
"answered_by_generation", outcome.resp.Generation,
)
// The workflow has since rolled over: same workflow ID, brand-new run,
// carrying only what the old run passed along (the remaining stock).
inventory := orders.GetTrackInventory(ctx, invFuture.GetID(), "")
for {
st, err := inventory.QueryGetStock(ctx, &emptypb.Empty{})
if err == nil && st.Generation >= 2 {
logger.Info("continue-as-new rolled the entity over",
"generation", st.Generation,
"carried_stock", st.Stock,
)
break
}
time.Sleep(200 * time.Millisecond)
}
if err := inventory.Cancel(ctx); err != nil {
logger.Error("could not clean up the inventory workflow", "error", err)
os.Exit(1)
}
logger.Info("inventory workflow cleaned up; see both runs of it in the UI, the update completes in the FIRST one")
fmt.Println()
logger.Info("walkthrough complete, check the executions in the temporal UI", "url", "http://localhost:8080")
}