README
¶
AWS Durable Execution SDK for Go
A durable function is a Lambda function whose progress the service checkpoints as it runs. The SDK records the result of each operation when the operation completes. An invocation ends when the handler suspends on a timer, on an external signal, or on the function timeout. The service then invokes the function again. On that invocation the SDK replays the recorded results instead of running the completed work again, and the handler continues from the first operation that has no recorded result. The overall orchestration can run for up to a year, because the durable execution spans multiple Lambda invocations and consumes no compute while it waits.
[!WARNING] This is an experimental preview, not intended for production use. The API may change without notice, and the final version may look different from what you see today.
The SDK requires Go 1.24 or later.
Your first durable function
Create a module and add the SDK to it.
mkdir first-durable-function && cd first-durable-function
go mod init example.com/first-durable-function
go get github.com/aws/aws-durable-execution-sdk-go
The repository has no release tags, because this is an experimental
pre-release. The first release will be tagged v1.0.0. Until then go get
records a pseudo-version of the latest commit on main, such as
v0.0.0-20260920051622-7f3b62f70004. Run
go get github.com/aws/aws-durable-execution-sdk-go@latest to move to a
newer commit.
Put this in main.go. The handler runs a step, suspends on a two second
timer, then runs a second step that reads the first result.
package main
import (
"fmt"
"time"
"github.com/aws/aws-durable-execution-sdk-go/durable"
)
func handler(ctx durable.Context, _ any) (string, error) {
name, err := durable.Step(ctx, "fetch-name", func(_ durable.StepContext) (string, error) {
return "world", nil
})
if err != nil {
return "", err
}
if err := durable.Wait(ctx, "cooldown", 2*time.Second); err != nil {
return "", err
}
return durable.Step(ctx, "format", func(_ durable.StepContext) (string, error) {
return fmt.Sprintf("hello, %s", name), nil
})
}
func main() {
durable.Start(handler)
}
Build a static Linux binary named bootstrap. That is the file the
provided.al2023 runtime executes.
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/bootstrap .
Point a SAM template at the dist directory and give the function a
DurableConfig. The DurableConfig is what makes the service checkpoint
the function.
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: First durable function
Parameters:
ExecutionRoleArn:
Type: String
Description: IAM role the function assumes
Resources:
FirstDurableFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: first-durable-function
CodeUri: dist/
Handler: bootstrap
Runtime: provided.al2023
Architectures: [x86_64]
Timeout: 60
MemorySize: 128
Role: !Ref ExecutionRoleArn
DurableConfig:
RetentionPeriodInDays: 7
ExecutionTimeout: 300
The template takes an existing execution role. The role needs permission to
write CloudWatch Logs, and it needs the two Lambda API actions the SDK
calls, lambda:CheckpointDurableExecution and
lambda:GetDurableExecutionState.
sam deploy --template-file template.yaml \
--stack-name first-durable-function \
--resolve-s3 --region us-west-2 \
--parameter-overrides ExecutionRoleArn=arn:aws:iam::111122223333:role/durable-lambda-role
Invoking a durable function starts an execution. A synchronous invoke
waits for the execution to finish and returns its result, so the call below
returns after about three seconds with "hello, world" in response.json.
The invocation metadata on stdout includes the DurableExecutionArn.
aws lambda invoke --function-name first-durable-function \
--qualifier '$LATEST' --payload '{}' \
--cli-binary-format raw-in-base64-out response.json
A synchronous invoke is limited to 15 minutes. For a longer execution,
invoke with --invocation-type Event. The call then returns at once with
status code 202 and the DurableExecutionArn, and the execution runs for
up to its ExecutionTimeout. Poll it with the ARN. It reports RUNNING
while the timer is pending and SUCCEEDED with the result afterwards.
aws lambda get-durable-execution \
--durable-execution-arn <DurableExecutionArn> \
--query '[Status,Result]' --output text
get-durable-execution-history lists the recorded events of an
execution. For the handler above it shows the first step, the wait, the
end of the first invocation, the completed wait, the second step, and the
end of the second invocation.
The handler and the context
A durable handler is a function of two arguments that returns a value and an error.
type Order struct {
ID string `json:"id"`
}
type Receipt struct {
ChargeID string `json:"chargeId"`
}
func handler(ctx durable.Context, order Order) (Receipt, error) {
return Receipt{ChargeID: "ch_" + order.ID}, nil
}
durable.Start(handler) registers the handler with the Lambda runtime and
runs one execution per invocation. The event type and the output type are
yours to choose. The SDK decodes the event and encodes the output with
encoding/json. durable.Wrap returns a raw payload function instead of
starting the runtime, for a program that composes its own Lambda entry
point.
When the handler returns an error, the execution moves to the FAILED
status. The service records the error's type and message, and
get-durable-execution returns them in its Error field. A synchronous
caller receives the same error object as the payload, with FunctionError
set to Unhandled in the invocation metadata. The ErrorType of an error
from errors.New or fmt.Errorf is Error. The ErrorType of any other
error is the name of its Go type.
durable.Context is the handle to the execution. It exposes ExecutionArn(),
RequestID(), InvokedFunctionARN(), Logger(), and IsReplaying().
IsReplaying reports whether the current invocation is replaying recorded
results. Every operation takes the context as its first argument.
Determinism
Replay pairs recorded results with operations by position. So the handler must create the same operations in the same order on every invocation. Code between operations must depend only on the event and on results the SDK already recorded. Do not create operations while iterating a map, because Go randomizes map iteration order. Sort the keys into a slice first.
Put nondeterministic work inside a step body. A step body may read the
clock, generate a random ID, or call a service. Only its recorded result
takes part in replay. durable.StepContext, the argument a step body
receives, is a context.Context with Logger() and Attempt(). It exposes
no durable operations, so a step cannot create nested operations.
Goroutines
A durable.Context is owned by the goroutine that created it. Calling an
operation on it from another goroutine fails with
durable.ErrWrongGoroutine. That covers a go statement and an
errgroup.Go callback alike. Two goroutines claiming operations on one
context would claim them in a scheduling-dependent order, and replay would
then pair recorded results with the wrong operations. The check runs in
every default build. Build with -tags durablenocheck to remove it.
Use durable.Go to run durable work concurrently. It claims the child
operation on the calling goroutine, which keeps the order deterministic,
and then starts a goroutine that owns a fresh child context. Use the child
context inside the function, never the parent.
func handler(ctx durable.Context, _ any) (string, error) {
fut := durable.Go(ctx, "work", func(child durable.Context) (string, error) {
return durable.Step(child, "step", func(_ durable.StepContext) (string, error) {
return "done", nil
})
})
return fut.Result()
}
Future.Result blocks until the future settles. Await several futures with
a combinator, not with a sequence of Result calls. When the first future
suspends, Result returns the suspension signal and the handler returns
before the other branches reach a checkpoint. The combinators await every
future first and propagate the suspension afterwards. The
combinators section shows them.
Errors
Return an error from an operation unchanged unless the handler treats it
as a business outcome. A non-nil error is one of two things. Either it is a
terminal failure of that operation, such as *durable.StepError, or it is
the signal that the invocation is suspending. A terminal failure matches its public type with errors.As.
The suspension signal matches no public type. So an error that matches no
public type must be returned as it is.
A typed failure does not carry the error value the operation body
returned. It carries the recorded ErrorType and Message, and the SDK
rebuilds it the same way on the first invocation and on replay. So match on
ErrorType, not with errors.As against your own error types.
type CardDeclinedError struct{}
func (CardDeclinedError) Error() string { return "card declined" }
func handler(ctx durable.Context, _ any) (string, error) {
receipt, err := durable.Step(ctx, "charge", func(_ durable.StepContext) (string, error) {
return "", CardDeclinedError{}
}, durable.WithRetry(durable.NoRetry()))
var stepErr *durable.StepError
switch {
case err == nil:
return receipt, nil
case errors.As(err, &stepErr) && stepErr.ErrorType == "CardDeclinedError":
return "declined", nil
default:
return "", err
}
}
Operations
| Operation | Description |
|---|---|
Step |
Run a function with automatic checkpointing and configurable retry. |
StepAsync |
Asynchronous Step returning a *Future. |
Wait |
Pause execution for a duration without blocking Lambda. |
WaitAsync |
Asynchronous Wait returning a *Future. |
Invoke |
Call another durable function and wait for its result. |
InvokeAsync |
Asynchronous Invoke returning a *Future. |
RunInChildContext |
Execute a subflow in an isolated child context. |
RunInChildContextAsync |
Asynchronous RunInChildContext returning a *Future. |
Go |
Launch a replay-safe concurrent subflow (shorthand for RunInChildContextAsync). |
WaitForCondition |
Poll a check function until a condition is met or a strategy stops. |
CreateCallback |
Register a callback and wait for external resolution. |
WaitForCallback |
Create a callback, invoke a submitter with the callback ID, and wait. |
Map |
Fan out a function over items with configurable concurrency and completion. |
Parallel |
Run named branches concurrently with configurable completion. |
All |
Wait for all futures to succeed. Returns results or the first error. |
AllSettled |
Wait for all futures to settle. Returns all outcomes. |
Any |
Return the first future to succeed. Errors if all fail. |
Race |
Return the result of the first future to settle. |
Join |
Wait for futures of different result types to settle. Returns the first error in argument order. |
Select |
Run named branches concurrently. Return the first to settle along with its name. |
Retry |
Retry a function containing durable operations as a unit, suspending between attempts. |
Every operation takes a name as its second argument. The name identifies
the operation in the execution history. Pass "" for an unnamed operation.
Step
A step is the unit of checkpointing. The SDK records its result with
encoding/json, so the result type must round-trip through JSON. Once the
service records the result, replay returns it without running the body
again.
func handler(ctx durable.Context, _ any) (string, error) {
return durable.Step(ctx, "fetch-name", func(sc durable.StepContext) (string, error) {
// Nondeterministic work belongs here. sc is a context.Context, so
// pass it to AWS SDK calls made inside the step.
return "world", nil
})
}
Between the moment the SDK starts a step and the moment the service records
the outcome, the function can time out or the runtime can crash. Under the
default AtLeastOncePerRetry semantics the SDK runs the body again on
resume. So the body may run more than once for one attempt, and it should
be idempotent. AtMostOncePerRetry treats the interrupted attempt as a
failure instead and consults the retry strategy.
WithRetry sets the retry strategy. The default is ExponentialBackoff(),
which makes 6 attempts in total, starting 5 seconds apart, doubling each
time, capped at 60 seconds, with full jitter. NoRetry() fails on the first
error. NewRetryStrategy builds a strategy from a RetryConfig, and
LinearBackoff builds one with a constant increment. The execution suspends
for the delay between attempts, so a retrying step does not hold the
invocation open. StepContext.Attempt() is the 1-based attempt number.
func handler(ctx durable.Context, _ any) (string, error) {
strategy, err := durable.NewRetryStrategy(durable.RetryConfig{
MaxAttempts: 4,
InitialDelay: time.Second,
BackoffRate: 2,
})
if err != nil {
return "", err
}
return durable.Step(ctx, "flaky-call", func(sc durable.StepContext) (string, error) {
if sc.Attempt() < 3 {
return "", fmt.Errorf("transient failure on attempt %d", sc.Attempt())
}
return fmt.Sprintf("succeeded on attempt %d", sc.Attempt()), nil
},
durable.WithRetry(strategy),
durable.WithSemantics(durable.AtMostOncePerRetry),
)
}
When the strategy stops, Step returns a *durable.StepError with the
attempt count and the recorded ErrorType and Message of the last
attempt.
Wait
Wait suspends the execution for a duration. The invocation ends, and the
service invokes the function again when the duration elapses. On replay a
completed wait returns at once.
func handler(ctx durable.Context, _ any) (string, error) {
if err := durable.Wait(ctx, "cooldown", 60*time.Second); err != nil {
return "", err
}
return "waited", nil
}
Return the error of Wait in every case. It never carries a business
outcome.
Invoke
Invoke starts another durable function as its own execution and returns
its result. The calling execution suspends after starting it and resumes
when it completes. The output type parameter comes first, so it can be
written out while the input type is inferred. The target needs a version or
alias qualifier, such as :$LATEST. If the invoked function fails, Invoke
returns a *durable.InvokeError.
func handler(ctx durable.Context, orderID string) (string, error) {
return durable.Invoke[string](ctx, "charge", "payments-function:$LATEST", orderID)
}
WaitForCondition
WaitForCondition runs a check repeatedly. The check receives the state
from the previous attempt and returns the new state. The SDK checkpoints
the state between attempts and suspends for the delay the wait strategy
returns. The strategy receives the new state and the attempt number. It
returns Continue: true with a Delay to poll again, Continue: false to
stop with the state, or an Err to fail the operation.
func handler(ctx durable.Context, _ any) (int, error) {
return durable.WaitForCondition(ctx, "poll-until-ready",
func(_ durable.StepContext, state int) (int, error) {
return state + 1, nil
},
durable.ConditionConfig[int]{
InitialState: 0,
WaitStrategy: func(state int, attempt int) durable.WaitDecision {
if state >= 3 {
return durable.WaitDecision{Continue: false}
}
return durable.WaitDecision{Continue: true, Delay: time.Second}
},
})
}
NewWaitStrategy builds a strategy from a WaitConfig with a
ShouldContinue predicate, an attempt cap, and exponential backoff. When
WaitStrategy is nil, the SDK polls with a 5 second initial delay
multiplied by 1.5 after each attempt, capped at 5 minutes, and fails once
60 attempts have been made. That default never reports the condition met,
so set WaitStrategy.
Callbacks
CreateCallback creates a callback and returns it at once. The callback
exposes ID(), the identifier to hand to an external system, and
Result(), which blocks until that system completes it. The external
system completes a callback with the SendDurableExecutionCallbackSuccess
or SendDurableExecutionCallbackFailure API. The result is a JSON
document. From the CLI, the command below completes the callback with the
string "approved". The callback ID also appears in the
CallbackStarted event of the execution history.
aws lambda send-durable-execution-callback-success \
--callback-id <ID> --result '"approved"' \
--cli-binary-format raw-in-base64-out
func handler(ctx durable.Context, _ any) (string, error) {
cb, err := durable.CreateCallback[string](ctx, "approval",
durable.WithCallbackTimeout(time.Hour))
if err != nil {
return "", err
}
ctx.Logger().Info("awaiting approval", "callbackId", cb.ID())
return cb.Result()
}
WaitForCallback combines the two halves into one operation. It creates
the callback, runs the submitter with the callback ID, and blocks until the
result arrives. The submitter runs as a step, so it may call a service, and
WithSubmitterRetry retries it with a RetryStrategy.
func handler(ctx durable.Context, _ any) (string, error) {
return durable.WaitForCallback[string](ctx, "approval",
func(sc durable.StepContext, callbackID string) error {
// Hand callbackID to the approver here, for example by
// publishing it to a queue.
sc.Logger().Info("approval requested", "callbackId", callbackID)
return nil
},
durable.WithCallbackTimeout(time.Hour))
}
Both operations take WithCallbackTimeout and
WithCallbackHeartbeatTimeout. A timeout fails the operation with a
*durable.CallbackTimeoutError. A failure sent by the external system
fails it with a *durable.CallbackExternalError.
Child contexts
RunInChildContext runs a function with its own context. The operations
inside it are recorded under the child, and the child's overall result is
checkpointed. On replay of a completed child, the SDK returns the recorded
result without running the function.
func handler(ctx durable.Context, _ any) (string, error) {
return durable.RunInChildContext(ctx, "greet", func(child durable.Context) (string, error) {
name, err := durable.Step(child, "fetch-name", func(_ durable.StepContext) (string, error) {
return "world", nil
})
if err != nil {
return "", err
}
return "hello, " + name, nil
})
}
Retry
Retry retries a function that contains durable operations, as one unit.
Each attempt runs in its own child context, so a failed attempt's
operations are never replayed into the next attempt. The delay between
attempts is a Wait, so no compute is consumed while waiting. When the
strategy stops, Retry returns a *durable.RetryError with the attempt
count and the last attempt's error. The function receives the 1-based
attempt number.
func handler(ctx durable.Context, _ any) (string, error) {
return durable.Retry(ctx, "quote-and-book", func(c durable.Context, attempt int) (string, error) {
quote, err := durable.Step(c, "fetch-quote", func(_ durable.StepContext) (float64, error) {
if attempt < 2 {
return 0, errors.New("quote service unavailable")
}
return 104.50, nil
}, durable.WithRetry(durable.NoRetry()))
if err != nil {
return "", err
}
return durable.Step(c, "book", func(_ durable.StepContext) (string, error) {
return fmt.Sprintf("booked at %.2f", quote), nil
})
}, durable.ExponentialBackoff())
}
Map and Parallel
Map applies one function to every item. Each item runs in its own child
context and receives its index. Parallel runs a fixed set of named
branches that share an output type. Both return a BatchResult and accept
WithMaxConcurrency and WithCompletion.
func handler(ctx durable.Context, items []string) ([]string, error) {
results, err := durable.Map(ctx, "process-all", items,
func(c durable.Context, item string, index int) (string, error) {
return durable.Step(c, "process", func(_ durable.StepContext) (string, error) {
return fmt.Sprintf("item-%d:%s", index, item), nil
})
},
durable.WithMaxConcurrency(4))
if err != nil {
return nil, err
}
return results.Results(), nil
}
func handler(ctx durable.Context, _ any) ([]int, error) {
results, err := durable.Parallel(ctx, "fan-out", []durable.Branch[int]{
{Name: "double", Func: func(c durable.Context) (int, error) {
return durable.Step(c, "compute", func(_ durable.StepContext) (int, error) {
return 42, nil
})
}},
{Name: "wait-then-value", Func: func(c durable.Context) (int, error) {
if err := durable.Wait(c, "cooldown", time.Second); err != nil {
return 0, err
}
return 100, nil
}},
})
if err != nil {
return nil, err
}
return results.Results(), nil
}
The default completion policy is fail-fast. The first item failure
completes the batch, and Map or Parallel returns a
*durable.BatchError carrying the per-item errors. A CompletionConfig
changes that. MinSuccessful completes the batch once that many items
succeed. ToleratedFailureCount and ToleratedFailurePercentage let the
batch continue past failures up to a limit. ShouldComplete decides
programmatically from a BatchProgress snapshot. Items still running when
the batch completes early are reported with the status BatchItemStarted.
BatchResult reports each item's status through Items, Succeeded(),
Failed(), and Started(), the values through Results(), the per-item
errors through Errors(), and why the batch ended through Reason.
Combinators
StepAsync, WaitAsync, InvokeAsync, RunInChildContextAsync, and Go
start an operation and return a *Future. The combinators take futures and
record the combined outcome as one operation.
All waits for every future to succeed and returns the values in input
order. It fails on the first error.
func handler(ctx durable.Context, _ any) ([]int, error) {
a := durable.StepAsync(ctx, "a", func(_ durable.StepContext) (int, error) { return 1, nil })
b := durable.StepAsync(ctx, "b", func(_ durable.StepContext) (int, error) { return 2, nil })
c := durable.StepAsync(ctx, "c", func(_ durable.StepContext) (int, error) { return 3, nil })
return durable.All(ctx, "gather", []*durable.Future[int]{a, b, c})
}
AllSettled waits for every future and returns a Settled per future,
with either a Value or an Err. It never fails fast.
func handler(ctx durable.Context, _ any) (int, error) {
good := durable.StepAsync(ctx, "good", func(_ durable.StepContext) (int, error) { return 1, nil })
bad := durable.StepAsync(ctx, "bad", func(_ durable.StepContext) (int, error) {
return 0, errors.New("boom")
}, durable.WithRetry(durable.NoRetry()))
settled, err := durable.AllSettled(ctx, "collect", []*durable.Future[int]{good, bad})
if err != nil {
return 0, err
}
failures := 0
for _, s := range settled {
if s.Err != nil {
failures++
}
}
return failures, nil
}
Any returns the value of the first future to succeed. It fails with a
*durable.CombinatorError when every future fails. Race returns the
outcome of the first future to settle, success or failure.
func handler(ctx durable.Context, _ any) (int, error) {
primary := durable.StepAsync(ctx, "primary", func(_ durable.StepContext) (int, error) {
return 0, errors.New("unavailable")
}, durable.WithRetry(durable.NoRetry()))
fallback := durable.StepAsync(ctx, "fallback", func(_ durable.StepContext) (int, error) { return 42, nil })
return durable.Any(ctx, "first-ok", []*durable.Future[int]{primary, fallback})
}
Join takes futures of different result types through the Awaitable
interface. It waits for all of them and returns the first error in argument
order. Read the values afterwards with Result, which returns at once after
Join returned nil.
func handler(ctx durable.Context, _ any) (string, error) {
charge := durable.StepAsync(ctx, "charge", func(_ durable.StepContext) (string, error) {
return "ch_123", nil
})
reserve := durable.Go(ctx, "reserve", func(c durable.Context) (int, error) {
return durable.Step(c, "reserve-items", func(_ durable.StepContext) (int, error) {
return 3, nil
})
})
if err := durable.Join(ctx, "settle", []durable.Awaitable{charge, reserve}); err != nil {
return "", err
}
receipt, _ := charge.Result()
reserved, _ := reserve.Result()
return fmt.Sprintf("%s reserved %d items", receipt, reserved), nil
}
Select runs named branches and returns the name and value of the first
branch to settle. Use it in place of Race when the caller must know
which branch won.
func handler(ctx durable.Context, _ any) (string, error) {
winner, quote, err := durable.Select(ctx, "quote", []durable.Branch[float64]{
{Name: "primary", Func: func(c durable.Context) (float64, error) {
return durable.Step(c, "primary-quote", func(_ durable.StepContext) (float64, error) {
time.Sleep(2 * time.Second)
return 100.00, nil
})
}},
{Name: "fallback", Func: func(c durable.Context) (float64, error) {
return durable.Step(c, "fallback-quote", func(_ durable.StepContext) (float64, error) {
return 104.50, nil
})
}},
})
if err != nil {
return "", err
}
return fmt.Sprintf("%s quoted %.2f", winner, quote), nil
}
Serialization
The SDK stores operation results as JSON by default. WithSerdes sets a
different durable.Serdes for the whole handler at construction time, and
ConfigureSerdes changes it from inside the handler. Per-operation options
such as WithStepSerdes, WithChildSerdes, and WithBatchSerdes override
it for one operation. The Serdes interface is untyped. Marshal receives
the value as any and Unmarshal fills a pointer passed as any, so one
value can serve every result type in a handler. SerdesOf builds a
Serdes for one result type from typed marshal and unmarshal functions.
NewFileSystemSerdes stores payloads as files under a base path and
records a reference in the checkpoint. In the default
FileSystemSerdesModeAlways every value goes to a file. In
FileSystemSerdesModeOverflow a value goes to a file only when it exceeds
255 KB. Point the base path at a
durable mount such as Amazon EFS, not at the ephemeral /tmp of the
function.
func handler(ctx durable.Context, _ any) (string, error) {
upper := durable.SerdesOf(
func(_ context.Context, _ durable.SerdesContext, v string) ([]byte, error) {
return []byte(strings.ToUpper(v)), nil
},
func(_ context.Context, _ durable.SerdesContext, data []byte) (string, error) {
return strings.ToLower(string(data)), nil
},
)
return durable.Step(ctx, "shout", func(_ durable.StepContext) (string, error) {
return "hello", nil
}, durable.WithStepSerdes(upper))
}
Testing a handler locally
The durable/durabletest package runs a handler in process, without
network access or AWS credentials. RunUntilComplete invokes the handler
as many times as the execution needs. Between invocations it completes
pending timers and step retries, and it returns when the execution
finishes or blocks on external action.
func TestHandler(t *testing.T) {
handler := func(ctx durable.Context, n int) (int, error) {
if err := durable.Wait(ctx, "cooldown", time.Hour); err != nil {
return 0, err
}
return durable.Step(ctx, "add", func(_ durable.StepContext) (int, error) {
return n + 1, nil
})
}
runner := durabletest.NewLocalRunner(handler)
result := runner.RunUntilComplete(t, 41)
if result.Status != durabletest.Succeeded {
t.Fatalf("status = %s, want SUCCEEDED", result.Status)
}
got, err := durabletest.ResultAs[int](result)
if err != nil {
t.Fatal(err)
}
if got != 42 {
t.Fatalf("result = %d, want 42", got)
}
}
TestResult also lists every recorded operation in Operations, so a test
can assert that a step ran once across replays. SendCallbackSuccess,
SendCallbackFailure, and SendCallbackHeartbeat resolve a callback the
handler is blocked on. CompleteChainedInvoke and FailChainedInvoke
resolve an Invoke. RegisterFunction registers a handler for a function
name so that Invoke runs it in process. NewCloudRunner runs the same
assertions against a deployed function.
Logging
Context.Logger() and StepContext.Logger() return a *slog.Logger. By
default it writes one JSON object per record to stderr with the field names
the other durable execution SDKs use, so one CloudWatch query works across
languages.
| Field | Present |
|---|---|
timestamp, level, message |
Always. |
requestId, executionArn |
Always. |
tenantId |
When the invocation has one. |
operationId, operationName |
Inside a child context (RunInChildContext, Go, Map, Parallel, WaitForCallback) or an operation body. operationName only when the operation is named. |
attempt |
Inside a step body, condition check, or callback submitter. |
replay |
Only on a record emitted while its context replays, and only under ReplayLogModeEmit (see below). Always true when present. |
A field that does not apply in a scope is omitted, never emitted empty. Each scope carries its own identifiers only. A step inside a child context reports the step, not the child.
To use your own logging library, pass its slog.Handler to
WithLogHandler. The SDK attaches the fields above through the handler's
WithAttrs method as structured attributes, wraps the handler with replay
suppression, and adds the fields a plugin returns from
Plugin.EnrichLogContext as record attributes. When two sources use the
same key, the SDK's own fields win, then the attributes you attach with the
record or with Logger.With, then plugin fields. When several plugins
return the same key, the plugin registered last wins. Keys are compared by
qualified path, so a plugin field under an open slog group collides only
with your attributes at that same path.
To choose the handler from inside the handler body, for example from the
event payload, call ConfigureLogging. It replaces the handler for the
rest of the invocation, on the calling context and on every child context
and branch derived from it after the call. The SDK attaches the same fields
to the new handler. The change lasts for the current invocation only and
does not affect checkpoints or operation ordering, so it is safe to call
conditionally.
type Event struct {
Debug bool `json:"debug"`
}
func handler(ctx durable.Context, event Event) (string, error) {
if event.Debug {
if err := durable.ConfigureLogging(ctx, durable.LogConfig{
Handler: slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}),
}); err != nil {
return "", err
}
}
return "configured", nil
}
Replayed log records
While a context replays checkpointed operations, its log records are
dropped, so replayed code does not duplicate the lines it wrote when it
first ran. Suppression is decided per branch. A Go branch that is still
replaying stays quiet while a sibling that has reached live execution logs
normally.
To see the records of the replayed portion when diagnosing a replay
problem, select ReplayLogModeEmit with WithReplayLogMode at
construction or with ConfigureLogging inside the handler. Replayed
records are then emitted with the field replay set to true. Live
records carry no replay field. Expect duplicate lines. Every line written
before a suspension appears again on each later invocation that replays
it. ReplayLogModeSuppress is the default. The top-level replay key
belongs to the SDK in every mode. A value you attach under that name with
Logger.With or pass with a record is dropped, while the same name inside a
group opened with WithGroup is kept. At construction the option is
durable.Start(handler, durable.WithReplayLogMode(durable.ReplayLogModeEmit)).
Plugin API
The plugin instrumentation API is experimental. Its hooks, info types, constants, and dispatch behavior may change or be removed without notice. docs/release-notes.md records each change.
The plugin instrumentation API gives observability and tracing integrations
hooks into the lifecycle of an execution. A durable.Plugin is a struct of
optional hook functions. Set the ones you need and register the plugin with
durable.WithPlugins.
func handler(ctx durable.Context, _ any) (string, error) {
return durable.Step(ctx, "work", func(_ durable.StepContext) (string, error) {
return "done", nil
})
}
func main() {
tracer := durable.Plugin{
OnOperationStart: func(_ context.Context, info durable.OperationHookInfo) {
log.Printf("start %s %s replay=%v", info.Type, info.Name, info.IsReplay)
},
WrapOperationAttemptFn: func(ctx context.Context, info durable.AttemptHookInfo, fn func(context.Context) (any, error)) (any, error) {
start := time.Now()
defer func() { log.Printf("%s took %s", info.Name, time.Since(start)) }()
return fn(ctx)
},
}
durable.Start(handler, durable.WithPlugins(tracer))
}
The hooks cover the invocation (OnInvocationStart, OnInvocationEnd,
OnOperationChange, WrapInvocation), each operation (OnOperationStart,
OnOperationEnd), each attempt of a retryable operation
(OnOperationAttemptStart, OnOperationAttemptEnd,
WrapOperationAttemptFn), child contexts (WrapChildContextFn), and log
records (EnrichLogContext). The wrap hooks receive the wrapped work as a
function and may pass it a derived context, which becomes the parent of the
context the user code observes. WithPluginChildOperationsDepth bounds how
deep in the operation tree hooks are reported.
Each hook has a defined firing time, replay behavior, order relative to
the other hooks, and dispatch goroutine. Run
go doc github.com/aws/aws-durable-execution-sdk-go/durable Plugin to
read them per hook. With one plugin registered a notification hook runs on
the goroutine that dispatches the event. With several, each plugin's hook runs on a goroutine
the dispatch joins, so the plugins' hooks for one event run in parallel.
Hooks of concurrent operations run in parallel too, so a plugin must be
safe for concurrent use. Construct Plugin and the hook info types with
keyed fields, because fields may be added, and tolerate status values you
do not know. The insight module is a complete plugin
built on this API.
Examples
examples/ holds one deployable function per pattern.
examples/README.md lists them by operation with the
terminal status each one reaches. Each is a Lambda function on
provided.al2023. examples/build.sh builds them and the SAM template in
that directory deploys them.
Feedback & Support
Contributing
We welcome contributions and feedback. Please open an issue before submitting a pull request so we can discuss the change. See CONTRIBUTING.md for guidelines.
Acknowledgments
Big thanks to @embano1, whose insightful advice on the early experimental versions caught the bugs before they became bugs, and whose eagle-eyed reviews caught the subtle (and not-so-subtle!) ones before they could bite anyone.
License
Apache-2.0. See LICENSE.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package durable provides the AWS Lambda Durable Functions programming model for Go.
|
Package durable provides the AWS Lambda Durable Functions programming model for Go. |
|
durabletest
Package durabletest provides an in-memory local testing runner for durable handler functions.
|
Package durabletest provides an in-memory local testing runner for durable handler functions. |
|
internal/wire
Package wire defines the JSON shapes exchanged between a durable function invocation and the durable execution service: the invocation input with its embedded operation log page, and the invocation response.
|
Package wire defines the JSON shapes exchanged between a durable function invocation and the durable execution service: the invocation input with its embedded operation log page, and the invocation response. |