Wordflow Temporal Course · Optional exercises
Extra · Approval
Goal: players submit new puzzles. Every word is checked against the dictionary at once, then a person approves or rejects the puzzle before it’s published.
Concepts
Parallel Execution. Start all the Activities first, then wait for each result. Eight lookups take about as long as one.
Approval. A Workflow waits for a person’s decision, as a Signal, with a deadline. Waiting a day costs nothing: no Worker is busy, and the Timer lives in the Temporal Service.
Status from a Query. The submitter or reviewer can check progress at any time without changing anything.
Patterns
What you’ll build
PuzzleSubmissionWorkflow, with Workflow IDsubmission-<puzzleId>:- Check the puzzle’s shape with the provided rules.
- Look up every word in parallel, and note any the dictionary doesn’t know.
- Wait for a
reviewSignal ({approved, reviewer, note}) or 24 hours. - If approved, save the puzzle with a
SavePuzzleActivity and announce it.
- A
submissionQuery that returns the status, the unknown words, and the review. - A command that submits a puzzle file.
Build it
The provided code includes game.ValidatePuzzle and Store.Save.
1. Names
Add to app/shared/extras.go, and import "wordflow/internal/game":
// Extra: Approval.
const (
SignalReview = "review"
QuerySubmission = "submission"
)
func SubmissionWorkflowID(puzzleID string) string {
return "submission-" + puzzleID
}
type SubmissionInput struct {
Puzzle game.Puzzle `json:"puzzle"`
}
type ReviewInput struct {
Approved bool `json:"approved"`
Reviewer string `json:"reviewer"`
Note string `json:"note,omitempty"`
}
type SubmissionView struct {
Status string `json:"status"`
UnknownWords []string `json:"unknownWords,omitempty"`
Review *ReviewInput `json:"review,omitempty"`
}
2. Save Activity
In app/activities/puzzles.go:
// SavePuzzle publishes a puzzle so it can be played.
func (a *PuzzleActivities) SavePuzzle(ctx context.Context, puzzle game.Puzzle) error {
return a.Store.Save(puzzle)
}
3. The Workflow
Create app/workflows/submission.go:
package workflows
import (
"time"
"go.temporal.io/sdk/workflow"
"wordflow/app/activities"
"wordflow/app/shared"
"wordflow/internal/game"
)
const reviewTimeout = 24 * time.Hour
// PuzzleSubmissionWorkflow checks a submitted puzzle, waits for a person to
// approve it, then publishes it.
func PuzzleSubmissionWorkflow(ctx workflow.Context, input shared.SubmissionInput) (shared.SubmissionView, error) {
puzzle := input.Puzzle
view := shared.SubmissionView{Status: "checking"}
err := workflow.SetQueryHandler(ctx, shared.QuerySubmission, func() (shared.SubmissionView, error) {
return view, nil
})
if err != nil {
return view, err
}
if err := game.ValidatePuzzle(puzzle); err != nil {
view.Status = "invalid"
return view, err
}
// Parallel Execution: start every lookup, then collect the results.
lookupCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: 2 * time.Second,
})
var a *activities.DictionaryActivities
lookups := make([]workflow.Future, len(puzzle.Words))
for i, word := range puzzle.Words {
lookups[i] = workflow.ExecuteActivity(lookupCtx, a.LookupWord, word)
}
for i, lookup := range lookups {
var valid bool
if err := lookup.Get(ctx, &valid); err != nil {
return view, err
}
if !valid {
view.UnknownWords = append(view.UnknownWords, puzzle.Words[i])
}
}
// Approval: wait for a review Signal, or give up after reviewTimeout.
view.Status = "awaiting_review"
var review shared.ReviewInput
reviewed := false
timerCtx, cancelTimer := workflow.WithCancel(ctx)
timer := workflow.NewTimerWithOptions(timerCtx, reviewTimeout, workflow.TimerOptions{Summary: "review deadline"})
selector := workflow.NewSelector(ctx)
selector.AddReceive(workflow.GetSignalChannel(ctx, shared.SignalReview), func(c workflow.ReceiveChannel, more bool) {
c.Receive(ctx, &review)
reviewed = true
})
selector.AddFuture(timer, func(workflow.Future) {})
selector.Select(ctx)
cancelTimer()
if !reviewed {
view.Status = "expired"
return view, nil
}
view.Review = &review
if !review.Approved {
view.Status = "rejected"
return view, nil
}
saveCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Second,
})
var p *activities.PuzzleActivities
if err := workflow.ExecuteActivity(saveCtx, p.SavePuzzle, puzzle).Get(ctx, nil); err != nil {
return view, err
}
view.Status = "published"
announce(ctx, "New puzzle: "+puzzle.Title+", approved by "+review.Reviewer)
return view, nil
}
ValidatePuzzleis pure, deterministic code, so it runs in the Workflow. No Activity is needed.- All the
ExecuteActivitycalls happen before the firstGet, so all the lookups are scheduled together. AddReceiveandAddFutureon one Selector means “whichever comes first”: the Signal or the Timer.
Register it in the Worker and the replayer:
w.RegisterWorkflow(workflows.PuzzleSubmissionWorkflow)
4. A client
Create app/cmd/submit/main.go:
// Command submit sends a new puzzle for review.
//
// go run ./app/cmd/submit submissions/bread.json
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"go.temporal.io/sdk/client"
"wordflow/app/shared"
"wordflow/app/workflows"
"wordflow/internal/game"
)
func main() {
if len(os.Args) != 2 {
log.Fatalln("usage: submit PUZZLE.json")
}
data, err := os.ReadFile(os.Args[1])
if err != nil {
log.Fatalln(err)
}
var puzzle game.Puzzle
if err := json.Unmarshal(data, &puzzle); err != nil {
log.Fatalln("bad puzzle file:", err)
}
c, err := client.Dial(client.Options{})
if err != nil {
log.Fatalln("unable to connect to Temporal:", err)
}
defer c.Close()
id := shared.SubmissionWorkflowID(puzzle.ID)
_, err = c.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{
ID: id,
TaskQueue: shared.TaskQueue,
}, workflows.PuzzleSubmissionWorkflow, shared.SubmissionInput{Puzzle: puzzle})
if err != nil {
log.Fatalln("unable to submit:", err)
}
fmt.Println("submitted", id)
fmt.Printf("check it: temporal workflow query --workflow-id %s --name %s\n", id, shared.QuerySubmission)
fmt.Printf("approve: temporal workflow signal --workflow-id %s --name %s --input '{\"approved\":true,\"reviewer\":\"you\"}'\n", id, shared.SignalReview)
}
5. Run it
Restart the Worker, then:
go run ./app/cmd/submit submissions/bread.json
temporal workflow query --workflow-id submission-bread --name submission
temporal workflow signal --workflow-id submission-bread --name review \
--input '{"approved":true,"reviewer":"you"}'
temporal workflow result --workflow-id submission-bread
The reviewer here is the Temporal CLI. In a real app, it would be an admin page calling SignalWorkflow.
Check it
-
Submit
submissions/bread.json. ThesubmissionQuery returnsawaiting_review. -
In the history, eight
ActivityTaskScheduledevents come before anyActivityTaskCompleted, then aTimerStartedlabelled review deadline.
-
Send the
reviewSignal with{"approved": true, "reviewer": "you"}. The Timer is cancelled, the puzzle is saved, the feed shows New puzzle: Bread, approved by you, and Bread appears in the puzzle list.
-
Copy the file, change its
idtobread-2, and add the wordDRAEB. Submit it. The Query showsDRAEBunderunknownWords. Reject it, and the Workflow completes with statusrejected.
Delete puzzles/bread.json afterwards if you want the original puzzle list back.