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.
The SDK requires Go 1.24 or later.
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-gogo get records the latest release. Versioning states
what each release guarantees.
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: 300The 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.
The SDK calls these APIs through a Lambda client built from the default AWS config. The client sets a 5 second connect timeout, a 50 second response timeout, and a 55 second total request timeout. Its AWS standard retryer retries server faults, throttling, and connection errors. The SDK adds no retry of its own. A checkpoint call that still fails ends the invocation, and the service invokes the function again. This holds even when handler code catches the error.
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-roleInvoking 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.jsonA 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 textget-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.
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.
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.
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.
The SDK enforces this. A body that runs on the calling goroutine, the body
of a Step or of a blocking RunInChildContext, fails an operation on an
enclosing context with durable.ErrWrongContext. A body that runs on its
own goroutine, the body of Go, RunInChildContextAsync, a Map item, or
a Parallel branch, fails the same call with durable.ErrWrongGoroutine,
because that goroutine does not own the enclosing context. An operation on
a child context kept from a body that already returned, such as a
sibling's context, fails with durable.ErrWrongContext when it runs on
that context's goroutine. A step body must not call an operation on a
captured enclosing context either. Its own argument exposes no durable
operations, so such a call fails with durable.ErrWrongContext. Either
way the rejected operation claims no operation ID and records nothing.
-tags durablenocheck removes both checks.
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(ctx)
}Future.Result blocks until the future settles. It returns the suspension
signal only when the invocation suspends, which happens once every branch
is blocked. Await several futures with a combinator, not with a sequence of
Result calls. A sequence that returns on the first error leaves the other
futures unawaited. The combinators await every future and record the
combined outcome as one operation. The combinators section
shows them.
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
}
}| 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. |
ParallelMixed |
Run named branches of different result types 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.
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.
Under AtLeastOncePerRetry the body runs without waiting for the step's
START to be recorded. The step still waits for its SUCCEED or FAIL to be
recorded before it returns. Under AtMostOncePerRetry the SDK waits for
the START to be recorded before the body runs.
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. When the next attempt
becomes due while other work of the handler is still running, it runs in
the same invocation. When nothing else can make progress, the invocation
ends for the delay, 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.
WithStepSubType sets the subtype a step records in its checkpoints and
reports to plugins, in place of Step, so the history can tell one kind
of step from another. WithCallbackSubType does the same for a callback.
Replay compares the subtype with the checkpoint, so changing it breaks the
executions in flight.
Wait pauses the execution for a duration. The invocation ends only when
no other work of the handler can make progress, and the service invokes the
function again when the duration elapses. A wait that elapses while other
work runs returns in the same invocation. On replay a completed wait
returns at once.
The duration must be at least one second. For a shorter duration, Wait
returns an error at the call and records no operation, and WaitAsync
returns a future that fails with that error. A duration of one second or
more is rounded up to whole seconds, so 1.5 seconds waits 2 seconds.
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 starts another durable function as its own execution and returns
its result. When the invoked function completes while other work of the
calling handler is still running, Invoke returns in the same invocation.
When nothing else can make progress, the calling invocation ends and the
execution resumes when the invoked function 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 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 waits for the delay the wait strategy
returns. When the next check becomes due while other work of the handler is
still running, it runs in the same invocation. When nothing else can make
progress, the invocation ends for the delay. 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}
},
})
}The service schedules each check in whole seconds. A Delay of 0 is
raised to one second. A positive Delay is rounded up to whole seconds,
so a positive Delay under one second waits one second. A negative
Delay fails WaitForCondition with an error.
NewWaitStrategy builds a strategy from a WaitConfig with a
ShouldContinue predicate, an attempt cap, and exponential backoff.
WaitStrategy is required. When it is nil, WaitForCondition returns an
error at the call, runs no check, and records no operation.
CreateCallback creates a callback and returns it at once. The callback
exposes ID(), the identifier to hand to an external system, and
Result(ctx), 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
JSON 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-outfunc 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(ctx)
}By default Result returns the submitted bytes unchanged, as the other
durable execution SDKs do. The default is durable.RawSerdes, which
supports a result type of string, []byte, or json.RawMessage. So the
handler above returns "approved" with its quotes, and a submitted 42
returns the string 42. To decode the submitted bytes as JSON into the
result type, pass durable.WithCallbackSerdes(durable.JSONSerdes) to the
operation, or set durable.WithCallbackDeserializer for the whole
handler. WithSerdes does not apply to callback results.
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. The callback and
the WaitForCallback operation both record the submitted bytes unchanged.
The result serializer runs once, at the WaitForCallback operation, with
the same precedence as for CreateCallback.
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.
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. A result whose serialized form exceeds
256 KiB is not stored. Replay runs the function again and passes the new
result through the serdes, as the first run did, so replay returns the
same value as the first run.
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 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 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. The SDK calls it once
before the first item starts, with every count at 0, and again after each
item finishes. A ShouldComplete that completes the batch at that first
call runs no item and returns an empty result. Items still running when
the batch completes early are reported with the status BatchItemStarted.
Map and Parallel return an error only when the batch fails as a
unit. That is a breached tolerance, with the reason
FAILURE_TOLERANCE_EXCEEDED, or a ShouldComplete that completes the
batch as failed, with the reason CUSTOM_COMPLETION_FAILED. The error is
a *durable.BatchError carrying the per-item errors when an item failed.
A ShouldComplete that fails the batch with no failed item returns a
*durable.BatchCompletionError instead, recorded with the ErrorType
BatchCompletionError. A failure
within a tolerance returns the populated result and a nil error, so
if err != nil { return err } keeps the tolerance. Read the failed items
from Failed() and Errors(). MIN_SUCCESSFUL_REACHED means the
threshold completed the batch with items still unstarted or in flight.
When every item ran, the reason is ALL_COMPLETED.
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.
A batch records its result in the same format as the other durable
execution SDKs. A failed item's error is recorded as its type, message,
and the chain of errors it wraps. The SDK rebuilds each item error from
that record, on the first run and on replay alike. An SDK error type in
the chain, such as *durable.StepError, is rebuilt as that type, so
errors.As finds it. Its detail fields that the record does not hold,
such as StepError.Attempts and CallbackError.CallbackID, are zero.
Match an error of your own type on the recorded ErrorType.
Each item has a name. A Map item takes its name from WithItemNamer,
and a Parallel branch from Branch.Name. A Map item with no name,
or whose namer returns "", is named map-item-<index>, for example
map-item-0. A Parallel branch with an empty Name is named
parallel-branch-<index>. The name is the name of the item's recorded
child context and of the operationName log field inside it. It is also
the key that BatchResult.Item and BatchResult.Result look up, so
results.Result("map-item-1") returns the value of the second item.
ParallelMixed runs branches of different result types concurrently.
NewTypedBranch builds a *TypedBranch[T] from a name and a body that
returns a T. ParallelMixed takes the branches as a []durable.AnyBranch
and returns a BatchResult[json.RawMessage] and an error.
TypedBranch.Result reads the branch value from that result as a T.
type Profile struct {
Name string `json:"name"`
}
func handler(ctx durable.Context, _ any) (string, error) {
profile := durable.NewTypedBranch("profile", func(c durable.Context) (Profile, error) {
return durable.Step(c, "load-profile", func(_ durable.StepContext) (Profile, error) {
return Profile{Name: "Ana"}, nil
})
})
orders := durable.NewTypedBranch("orders", func(c durable.Context) (int, error) {
return durable.Step(c, "count-orders", func(_ durable.StepContext) (int, error) {
return 3, nil
})
})
res, err := durable.ParallelMixed(ctx, "load", []durable.AnyBranch{profile, orders},
durable.WithMaxConcurrency(2))
if err != nil {
return "", err
}
p, err := profile.Result(res)
if err != nil {
return "", err
}
n, err := orders.Result(res)
if err != nil {
return "", err
}
return fmt.Sprintf("%s has %d orders", p.Name, n), nil
}Every BatchOption applies as in Parallel, and completion and failure
follow the Parallel rules above. A ParallelMixed call records the same
operations and stores the same payloads as a Parallel over the branch
values. Each branch value is marshaled once, by the serdes set with
WithTypedBranchSerdes or else by the batch item serdes, and the SDK
stores those bytes unchanged. Each item of the result holds its branch's
stored bytes in Result, and TypedBranch.Result decodes them into the
branch type.
TypedBranch.Result returns the value of a succeeded branch and the item
error of a failed branch. For a branch that never started, or that was
abandoned when the batch completed early, it returns a
*durable.BranchNotCompletedError. It returns an error when the result
came from a different ParallelMixed call, or when the branch was never
passed to one. WithBatchSummary under ParallelMixed receives
BatchResult[json.RawMessage], so its function has the type
func(durable.BatchResult[json.RawMessage]) string.
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, and fails when
every future fails. Race returns the outcome of the first future to
settle, success or failure. A failure of All, Any, Race, Join, or
Select is a *durable.ChildContextError whose ErrorType is
"PromiseCombinatorError" and whose cause matches
*durable.CombinatorError. Its message is the first error's message, or
All promises were rejected for Any. AllSettled does not fail for a
failing future. The future's error is in Settled[i].Err. Race and
Any with no futures return an error at once.
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(ctx)
reserved, _ := reserve.Result(ctx)
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
}The SDK stores operation results as JSON by default. The stored JSON keeps
<, > and & literal. 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.
A serdes failure is permanent by default. The operation fails with a
*durable.SerdesError, which names the operation and the Direction,
"marshal" or "unmarshal". A step fails with a *durable.StepError
whose ErrorType is "SerdesError". The step retry strategy is not
consulted, so the step body runs once. The caller may catch the error.
When the handler returns it, the execution settles FAILED. A handler
event that does not decode, or a handler result that does not encode,
fails the execution the same way, with a SerdesError whose Operation
is "execution". To mark a failure transient, return
durable.RetryableSerdesError(err) from Marshal or Unmarshal. The SDK
then records no outcome for the operation and ends only the current
invocation, even when the handler catches the error, and the service
invokes the execution again from its last checkpoint.
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))
}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.
The runner checks each checkpoint against the service's limits and
rejects the same updates the service rejects, with the service's error
code and message. Examples are a step result over 262144 bytes and an
Invoke input over 1048576 bytes. It also fails an execution whose handler answers
PENDING four times in a row with no pending operation, as the service
does. It reports an operation's completion during an invocation the same
way the service does. A wait or step retry that becomes due while other
work runs, a callback resolved while the handler runs, and an invoke of a
registered function are reported in a checkpoint response, so the handler
continues in the same invocation.
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, err := runner.RunUntilComplete(41)
if err != nil {
t.Fatal(err)
}
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)
}
}The runner methods return an error only when the runner itself fails, for
example when the event does not marshal to JSON. The handler's outcome is
in the result. A handler that returns an error produces a nil error and a
result with Status set to Failed and the recorded error in Error. A
run blocked on a callback or an invoke has Status set to Pending, and
a run that reaches the invocation cap has CapReached set. ResultAs
returns an error for any result that did not succeed, and for a failed
handler its message includes the recorded error type and message.
The runners take no test handle, so the same calls work in a plain main
package, for example a program you start with go run during local
development.
func main() {
handler := func(ctx durable.Context, name string) (string, error) {
return durable.Step(ctx, "greet", func(_ durable.StepContext) (string, error) {
return "hello, " + name, nil
})
}
result, err := durabletest.NewLocalRunner(handler).RunUntilComplete("world")
if err != nil {
log.Fatal(err) // the runner failed
}
out, err := durabletest.ResultAs[string](result)
if err != nil {
log.Fatal(err) // the handler failed, is pending, or the result did not decode
}
fmt.Println(out)
}TestResult also lists every recorded operation in Operations, so a test
can assert that a step ran once across replays. The operation records
also carry the scheduled end and duration of a wait, the time of a step's
next retry, and the error data and stack trace of a failure.
TestResult.Error carries the error data and stack trace of a failed
execution. 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. Its Run and RunWithArn methods
take a context.Context, which bounds the invoke and every poll. The
assertion helpers, such as AssertGoldenSignature, take a testing.TB,
so benchmarks and fuzz tests can call them.
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
}Every line is logged exactly once, in the invocation that first runs it.
Code that ran in an earlier invocation is replaying, and its log records
are dropped. Code that runs for the first time is live, and its records
are written. Context.IsReplaying reports which of the two the code is
in.
A context starts an invocation replaying when the checkpoint log holds operations for it. It becomes live, and stays live, at the first of these points.
- It starts an operation that has no checkpoint.
- Its code receives the outcome of an operation that the previous
invocation did not have. That is an operation that changed while the
execution was suspended, or one that completed during this invocation.
The code receives an outcome when a blocking operation returns, when
Future.ResultorCallback.Resultreturns, or when a combinator,Map,Parallel, orWaitForCallbackreturns.
An invocation suspends only while an operation the code awaits is still
pending. That operation completes while the execution is suspended. So in
the next invocation, the code before the await replays, and the code after
it is live. Starting an asynchronous operation such as CreateCallback or
Go does not make a context live, and neither does the suspension
signal.
Result takes the context of the code that reads the outcome. Inside a
Go branch, pass the branch's context, even for a future the parent
created, so that the branch's own lines are logged.
One case logs a line twice. When an invocation fails, for example by
crashing or timing out, the SDK cannot know which lines it wrote. The next
invocation logs again the lines the failed invocation ran after it
received an outcome. A step body can also run more than once under
AtLeastOncePerRetry, and the lines of its StepContext.Logger repeat
with the body.
Replay is decided per context. 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, with replay set to true, 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)).
A checkpoint response can withdraw the checkpoint token, which means the
service accepts no further checkpoints from the current invocation. When
that suspends the invocation, the SDK writes one WARN record through the
handler's log handler, with the requestId and executionArn fields. Its
message is Checkpoint response contained no CheckpointToken: the service will accept no further checkpoints from this invocation. Suspending; the execution continues on the next invocation. The JavaScript SDK writes the
same message, so one CloudWatch query finds it for both. Replay suppression
never drops this record, because it reports the state of the invocation.
The execution continues on the next invocation. When the withdrawn token
answers the checkpoint of the execution's final result, the execution has
finished, and the SDK writes no record.
The SDK also traces its own work at slog.LevelDebug. A handler enabled
at Debug turns the trace on, whether you pass it to WithLogHandler or to
ConfigureLogging. No environment variable is involved. Each record
carries requestId, executionArn, and tenantId when the invocation
has one.
| Message | When | Fields |
|---|---|---|
operation claimed |
The SDK claims an operation ID. | operationId, operationSubtype, operationName when named. |
replay complete; executing live |
A context leaves replay. | operationId and operationName of the child operation, inside a child context. |
checkpoint enqueued |
The SDK queues a checkpoint request. | updateCount, the number of operation updates. |
checkpoint flushed |
The service accepts a checkpoint call. | updateCount. |
invocation suspending |
The invocation ends because an operation is pending. | reason: wait, callback, invoke, retry, condition, or combinator. |
operation completed |
An operation reaches a terminal state. | operationId, operationSubtype, operationName when named, status (SUCCEEDED or FAILED). |
operationSubtype is the subtype the operation records, such as Step,
Wait, Callback, ChainedInvoke, RunInChildContext,
WaitForCallback, WaitForCondition, Map, MapIteration, Parallel,
or ParallelBranch. A trace record written while its context replays
follows the replay log mode. Under ReplayLogModeSuppress it is dropped,
and under ReplayLogModeEmit it carries replay set to true. An
operation completed record is replayed when an earlier invocation
already observed the outcome, and live when this invocation is the first
to observe it, whatever the replay state of its context. The
invocation suspending record, and a checkpoint record for a request the
invocation makes itself, report the state of the invocation and are never
dropped.
The plugin instrumentation API is experimental. Any part of it may change or be removed in a minor release, as Versioning describes. 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.
The behaviors below differ from the JavaScript SDK on purpose. Keep them in mind when you port a handler.
- The zero value of
MaxAttemptsselects the default: 3 forRetryConfig, 6 forLinearRetryConfig, and 60 forWaitConfig. It does not mean one attempt. A JavaScriptmaxAttempts: 0ormaxAttempts: 1ports todurable.NoRetry()for a retry strategy, and toMaxAttempts: 1for aWaitConfig. LinearRetryConfig.Jitterdefaults toJitterNone, so the default linear delays are exactly 1 s, 2 s, 3 s, 4 s, and 5 s.RetryConfigandWaitConfigdefault toJitterFull. Both defaults match the JavaScript SDK's presets.MapandParallelreturn a plainerrorfor an invalid option: a non-positiveWithMaxConcurrency, an invalidWithCompletion, or a failing summary function. The JavaScript SDK ends the execution for the same input.StartandWrappanic on an invalid handler option, because handler options are read once at program start. The JavaScript SDK does not validate handler options at construction.- A
WaitForConditioncheck-function failure returns a*durable.WaitForConditionErrorin Go, and aStepErrorin the JavaScript SDK. Python returns aWaitForConditionError, as Go does. Allreturns the values in input order, andRacereturns the outcome of the first future to settle without saying which future won. Both matchPromise.allandPromise.race. UseSelectwhen the caller needs the winner's name.Retryreturns anerrorfor an invalid argument: a nil function, a nil strategy, or aContextthe SDK did not create. The JavaScript SDK throws aTypeErrorfor the same input.- A child context waits for its SUCCEED or FAIL to be recorded before it
returns. The JavaScript SDK does not wait. The outcome of a
combinator,
Select,Map, orParallelcan depend on timing, so it must be recorded before later code acts on it.
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.
The repository publishes three modules, each with its own version tags.
- The SDK,
github.com/aws/aws-durable-execution-sdk-go, holds thedurableanddurable/durabletestpackages. Its tags arevX.Y.Z. - The insight plugin,
github.com/aws/aws-durable-execution-sdk-go/insight, has tagsinsight/vX.Y.Z. - The durablelint analyzer,
github.com/aws/aws-durable-execution-sdk-go/analysis, has tagsanalysis/vX.Y.Z.
The SDK follows semantic versioning. Code written against a v1 release keeps compiling and working as documented with every later v1 release. A minor release may add fields to exported structs, so construct structs with keyed fields, as the Go 1 compatibility promise advises.
The plugin API is the one exception. It is experimental,
and any part of it may change or be removed in a minor release. It
comprises durable.Plugin, durable.WithPlugins,
durable.WithPluginChildOperationsDepth, the hook info types, and the
plugin status and outcome types and constants. Each of these carries an
EXPERIMENTAL marker in its documentation.
The insight and analysis modules stay below v1. So any release of either module may make incompatible changes, and for insight that includes its record schema. Each insight release requires the SDK version it was released with. A later SDK release may change the plugin API in a way that an earlier insight release does not support, so upgrade insight together with the SDK.
docs/release-notes.md records the changes in each release, including every change to the plugin API.
The SDK supports the Go releases that the Go project supports. The most recent Go release to leave that support stays supported for six more months. The AWS SDK for Go v2 follows the same policy. A minor release may raise the minimum Go version.
The go line of each module's go.mod names the oldest Go release that
builds the module. CI tests that release and the two newest Go releases.
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.
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.
See CONTRIBUTING for information about reporting security issues.
Apache-2.0. See LICENSE.