This repository holds the durable execution engine behind Dapr Workflow and the Go workflow SDK.
- The engine. The Dapr sidecar embeds it to run workflows. It stores workflow state through a pluggable backend, replays workflow code after crashes and restarts, and serves the Durable Task gRPC protocol that every Dapr SDK uses to run workflows and activities.
- The Go workflow SDK. The
workflowpackage is the API for writing workflows and activities in Go. The Dapr Go SDK builds on it, so Dapr Go applications import it directly.
The engine started as a Go port of the .NET Durable Task Framework and also draws on Go Workflows. It is designed to run as a sidecar next to the application.
A workflow is an ordinary Go function that calls activities and awaits their results. The engine records each step, so a workflow that is interrupted resumes where it left off.
import (
"context"
"fmt"
"log"
"github.com/dapr/durabletask-go/workflow"
)
// GreetingWorkflow calls the SayHello activity once per city, in sequence,
// and returns the greetings.
func GreetingWorkflow(ctx *workflow.WorkflowContext) (any, error) {
var greetings []string
for _, city := range []string{"Tokyo", "London", "Seattle"} {
var greeting string
if err := ctx.CallActivity(SayHello, workflow.WithActivityInput(city)).Await(&greeting); err != nil {
return nil, err
}
greetings = append(greetings, greeting)
}
return greetings, nil
}
// SayHello is an activity that returns a greeting for its input.
func SayHello(ctx workflow.ActivityContext) (any, error) {
var name string
if err := ctx.GetInput(&name); err != nil {
return nil, err
}
return fmt.Sprintf("Hello, %s!", name), nil
}
func run(ctx context.Context, client *workflow.Client) {
r := workflow.NewRegistry()
r.AddWorkflow(GreetingWorkflow)
r.AddActivity(SayHello)
// Connect to the engine and start processing workflows and activities.
if err := client.StartWorker(ctx, r); err != nil {
log.Fatal(err)
}
id, err := client.ScheduleWorkflow(ctx, "GreetingWorkflow")
if err != nil {
log.Fatal(err)
}
metadata, err := client.WaitForWorkflowCompletion(ctx, id)
if err != nil {
log.Fatal(err)
}
fmt.Println(metadata.Output.GetValue()) // ["Hello, Tokyo!","Hello, London!","Hello, Seattle!"]
}In a Dapr application, dapr.NewWorkflowClient from the Dapr Go SDK returns a *workflow.Client that is connected to the Dapr sidecar. Outside Dapr, workflow.NewClient takes any gRPC connection to a Durable Task engine. The package example runs this workflow end to end against an in-process engine.
The workflow package also covers child workflows, durable timers, external events, Select for racing tasks, retry policies and ContinueAsNew. To learn the concepts, see the Dapr Workflow documentation, including how to author a workflow and workflow patterns.
Each Dapr SDK speaks this engine's gRPC protocol, which is defined in dapr/durabletask-protobuf.
| Language | Package | Source |
|---|---|---|
| Go | github.com/dapr/go-sdk, with workflows written using github.com/dapr/durabletask-go/workflow |
dapr/go-sdk |
| Python | dapr-ext-workflow, built on durabletask-dapr |
dapr/python-sdk, dapr/durabletask-python |
| .NET | Dapr.Workflow |
dapr/dotnet-sdk |
| Java | io.dapr:dapr-sdk-workflows |
dapr/java-sdk |
| JavaScript | @dapr/dapr |
dapr/js-sdk |
The Dapr Workflow documentation has quickstarts and examples for each language.
The rest of this README covers running the engine yourself, outside Dapr. This is useful for tests and local experiments, and for building a different host around it.
The engine persists workflow state through the backend.Backend interface. This repository includes sqlite and PostgreSQL implementations.
// Persists state to a file named test.sqlite3. Use "" for in-memory storage.
options := sqlite.NewSqliteOptions("test.sqlite3")
be := sqlite.NewSqliteBackend(options, backend.DefaultLogger())You can add other storage providers by implementing backend.Backend.
main.go shows how to build a standalone gRPC sidecar that embeds the engine. It needs a Backend for storage and an Executor that runs user code, and it hosts both in a TaskHubWorker.
The following code creates a task hub worker with a sqlite Backend and a gRPC Executor:
// Use the default logger or provide your own
logger := backend.DefaultLogger()
// Configure the sqlite backend that will store the runtime state
sqliteOptions := sqlite.NewSqliteOptions(sqliteFilePath)
be := sqlite.NewSqliteBackend(sqliteOptions, logger)
// Create a gRPC server that the language SDKs will connect to
grpcServer := grpc.NewServer()
executor, registerFn := backend.NewGrpcExecutor(be, logger)
registerFn(grpcServer)
// Construct and start the task hub worker, which polls the backend for new work
workflowWorker := backend.NewWorkflowWorker(backend.WorkflowWorkerOptions{
Backend: be,
Executor: executor,
Logger: logger,
AppID: "example",
})
activityWorker := backend.NewActivityTaskWorker(be, executor, logger)
taskHubWorker := backend.NewTaskHubWorker(be, workflowWorker, activityWorker, logger)
if err := taskHubWorker.Start(context.Background()); err != nil {
log.Fatalf("failed to start worker: %v", err)
}
// Start listening
lis, err := net.Listen("tcp", "localhost:4001")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
fmt.Printf("server listening at %v\n", lis.Addr())
grpcServer.Serve(lis)The gRPC service serves one application at a time, as in any sidecar architecture. To scale out, add replicas that each contain the application and its sidecar, all connected to a shared database.
Run go run main.go to start a local Durable Task gRPC server that listens on port 4001:
go run main.go --port 4001 --db ./test.sqlite3The output looks like this:
INFO: 2026/10/28 16:57:49 worker started with backend sqlite::./test.sqlite3
server listening at 127.0.0.1:4001
Any of the language SDKs can now connect to port 4001 from a separate process to run workflows.
You can also run workflows in the same process as the engine, without gRPC, by using the lower-level task package with task.NewTaskExecutor. The samples directory uses this approach. To run a sample, go to its folder and run go run ..
An activity sequence is the simplest and most common workflow pattern.
// ActivitySequenceWorkflow makes three activity calls in sequence and returns the results
// as an array.
func ActivitySequenceWorkflow(ctx *task.WorkflowContext) (any, error) {
var helloTokyo string
if err := ctx.CallActivity(SayHelloActivity, task.WithActivityInput("Tokyo")).Await(&helloTokyo); err != nil {
return nil, err
}
var helloLondon string
if err := ctx.CallActivity(SayHelloActivity, task.WithActivityInput("London")).Await(&helloLondon); err != nil {
return nil, err
}
var helloSeattle string
if err := ctx.CallActivity(SayHelloActivity, task.WithActivityInput("Seattle")).Await(&helloSeattle); err != nil {
return nil, err
}
return []string{helloTokyo, helloLondon, helloSeattle}, nil
}
// SayHelloActivity can be called by a workflow function and will return a friendly greeting.
func SayHelloActivity(ctx task.ActivityContext) (any, error) {
var input string
if err := ctx.GetInput(&input); err != nil {
return "", err
}
return fmt.Sprintf("Hello, %s!", input), nil
}The full sample is in samples/sequence.
In the fan-out / fan-in pattern, a workflow runs several activities in parallel and then waits for all of them. The snippet below leaves out the GetDevicesToUpdate and UpdateDevice activity definitions.
// UpdateDevicesWorkflow is a workflow that runs activities in parallel
func UpdateDevicesWorkflow(ctx *task.WorkflowContext) (any, error) {
// Get a dynamic list of devices to perform updates on
var devices []string
if err := ctx.CallActivity(GetDevicesToUpdate).Await(&devices); err != nil {
return nil, err
}
// Start a dynamic number of tasks in parallel, not waiting for any to complete (yet)
tasks := make([]task.Task, len(devices))
for i, id := range devices {
tasks[i] = ctx.CallActivity(UpdateDevice, task.WithActivityInput(id))
}
// Now that all are started, wait for them to complete and then return the success rate
successCount := 0
for _, t := range tasks {
var succeeded bool
if err := t.Await(&succeeded); err == nil && succeeded {
successCount++
}
}
return float32(successCount) / float32(len(devices)), nil
}The full sample is in samples/parallel.
Sometimes a workflow needs input from an external system. For example, an approval workflow may wait for a person to approve a request, or a workflow may pause until an operator sends a command. WaitForSingleEvent pauses the workflow until a named event arrives or a timeout expires. Pass -1 as the timeout to wait indefinitely.
// ExternalEventWorkflow is a workflow function that blocks for 30 seconds or
// until a "Name" event is sent to it.
func ExternalEventWorkflow(ctx *task.WorkflowContext) (any, error) {
var nameInput string
if err := ctx.WaitForSingleEvent("Name", 30*time.Second).Await(&nameInput); err != nil {
// Timeout expired
return nil, err
}
return fmt.Sprintf("Hello, %s!", nameInput), nil
}To send an event to a workflow, call the task hub client's RaiseEvent method. The workflow state buffers the event durably. The workflow consumes it when it calls WaitForSingleEvent with a matching event name.
id, _ := client.ScheduleNewWorkflow(ctx, "ExternalEventWorkflow")
// Prompt the user for their name and send that to the workflow
go func() {
fmt.Println("Enter your first name: ")
var nameInput string
fmt.Scanln(&nameInput)
client.RaiseEvent(ctx, id, "Name", api.WithEventPayload(nameInput))
}()The full sample is in samples/externalevents.
Register workflow and activity functions with a TaskRegistry. The TaskHubClient starts, queries, terminates, suspends, resumes and waits for workflows.
The following snippet registers ActivitySequenceWorkflow, starts an instance and waits for it to complete. It leaves out how the client and worker are created. Each sample contains an Init function that does this.
r := task.NewTaskRegistry()
r.AddWorkflow(ActivitySequenceWorkflow)
r.AddActivity(SayHelloActivity)
ctx := context.Background()
client, worker, err := Init(ctx, r)
if err != nil {
panic(err)
}
defer worker.Shutdown(ctx)
id, err := client.ScheduleNewWorkflow(ctx, ActivitySequenceWorkflow)
if err != nil {
panic(err)
}
metadata, err := client.WaitForWorkflowCompletion(ctx, id)
if err != nil {
panic(err)
}
fmt.Printf("workflow completed: %v\n", metadata)The engine publishes distributed traces to any configured OpenTelemetry exporter. Register a global TracerProvider at application startup with otel.SetTracerProvider(tp), and the task hub worker uses it to emit OTLP trace spans.
The following example sends traces to Zipkin running locally:
func ConfigureZipkinTracing() (*trace.TracerProvider, error) {
// Inspired by this sample: https://github.com/open-telemetry/opentelemetry-go/blob/main/example/zipkin/main.go
exp, err := zipkin.New("http://localhost:9411/api/v2/spans")
if err != nil {
return nil, err
}
// NOTE: The simple span processor is not recommended for production.
// Instead, the batch span processor should be used for production.
processor := trace.NewSimpleSpanProcessor(exp)
// processor := trace.NewBatchSpanProcessor(exp)
tp := trace.NewTracerProvider(
trace.WithSpanProcessor(processor),
trace.WithSampler(trace.AlwaysSample()),
trace.WithResource(resource.NewWithAttributes(
"durabletask.io",
attribute.KeyValue{Key: "service.name", Value: attribute.StringValue("sample-app")},
)),
)
otel.SetTracerProvider(tp)
return tp, nil
}This code is in the distributedtracing sample. The screenshot below shows the trace for the sample's workflow. The workflow calls an activity, creates a 2-second durable timer, and then calls another activity that makes an HTTP request:
Each workflow appears as a single span, with its activities, timers and child workflows as child spans. Span attributes include the workflow instance ID, task name and task ID.
Contributions are welcome. CONTRIBUTING.md explains how to clone the repository with its submodules, generate the protobuf code, and run the tests.
As in other Dapr projects, contributions to this repository use the Developer Certificate of Origin (DCO) instead of a Contributor License Agreement. Sign off each commit to certify that you wrote it or otherwise have the right to submit it. Use git commit -s to add a Signed-off-by line with your name and email:
Signed-off-by: Random J Developer <random@developer.example.org>
A DCO check runs on every pull request. A pull request cannot be merged until all of its commits are signed off.
Please refer to the Dapr Community Code of Conduct.
This project is licensed under the Apache License 2.0.
