AWS Lambda 耐久执行 Go SDK:用 checkpoint 与重放跑最长一年的函数
项目地址:aws/aws-durable-execution-sdk-go
耐久函数是 Lambda 函数,服务在运行时对其进度做 checkpoint。SDK 在操作完成时记录每个操作的结果。一次 invocation 会在 handler 因 timer、外部信号或函数超时而挂起时结束,服务随后再次调用函数。下一次调用中,SDK 重放已记录结果,不重新执行已完成工作,handler 从第一个没有记录结果的操作继续。整体编排最长可运行一年。
SDK 要求 Go 1.24 或更高。
第一个耐久函数
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
main.go:
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) }
构建:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/bootstrap .
带 DurableConfig 的 SAM 模板:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
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
角色需要写 CloudWatch Logs 的权限,以及 SDK 调用的两个 Lambda API action:lambda:CheckpointDurableExecution 和 lambda:GetDurableExecutionState。
SDK 通过一个用默认 AWS config 构建的 Lambda client 调用这些 API。该 client 设置 5 秒连接超时、50 秒响应超时、55 秒总请求超时。它的 AWS 标准 retryer 会重试 server faults、throttling 和连接错误。SDK 自己不额外重试。仍然失败的 checkpoint 调用会结束本次 invocation,服务会再次调用函数;即使 handler 代码捕获了该错误也是如此。
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
aws lambda invoke --function-name first-durable-function --qualifier '$LATEST' --payload '{}' --cli-binary-format raw-in-base64-out response.json
同步 invoke 最长 15 分钟。更长的执行使用 --invocation-type Event 调用。调用立即返回 202 和 DurableExecutionArn,execution 最多运行到它的 ExecutionTimeout。
aws lambda get-durable-execution --durable-execution-arn --query '[Status,Result]' --output text
get-durable-execution-history 列出一次 execution 已记录的事件。
Handler 与 Context
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) 把 handler 注册到 Lambda runtime,每次 invocation 跑一个 execution。SDK 用 encoding/json 解码 event、编码 output。durable.Wrap 返回 raw payload function,不启动 runtime。
handler 返回错误时,execution 进入 FAILED 状态。来自 errors.New 或 fmt.Errorf 的错误,其 ErrorType 是 Error;其他错误的 ErrorType 是它的 Go 类型名。
durable.Context 暴露 ExecutionArn()、RequestID()、InvokedFunctionARN()、Logger()、IsReplaying()。每个操作都把 context 作为第一个参数。
确定性
Replay 按位置把记录的结果与操作配对。因此 handler 每次 invocation 都必须以相同顺序创建相同操作。操作之间的代码只能依赖 event 和 SDK 已经记录的结果。不要在遍历 map 时创建操作,因为 Go 会随机化 map 的迭代顺序;先把 key 排序到 slice 里。
把非确定性工作放进 step body。step body 可以读时钟、生成随机 ID、调用服务;只有它被记录的结果参与 replay。durable.StepContext 是一个带 Logger() 和 Attempt() 的 context.Context。它不暴露 durable 操作,所以 step 不能创建嵌套操作。
Goroutine
durable.Context 归创建它的 goroutine 所有。从另一个 goroutine 对它调用操作会失败,返回 durable.ErrWrongGoroutine。go 语句和 errgroup.Go 回调都算。两个 goroutine 在同一个 context 上 claim 操作时,claim 顺序取决于调度,replay 随后会把记录的结果配给错误操作。该检查在默认构建中运行;用 -tags durablenocheck 构建可移除。
用 durable.Go 并发跑 durable 工作。它在调用 goroutine 上 claim 子操作,保持顺序确定,然后启动一个 goroutine,由该 goroutine 拥有新的 child context。
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 阻塞直到 future settle。等待多个 future 要用组合器,不要用一串 Result 调用;一串调用在第一个错误处返回,会留下其他 future 未 await。
在调用 goroutine 上运行的 body,例如 Step 的 body 或阻塞式 RunInChildContext 的 body,对 enclosing context 调用操作会失败,返回 durable.ErrWrongContext。在自己 goroutine 上运行的 body,例如 Go、RunInChildContextAsync、Map item 或 Parallel branch 的 body,同样调用会失败,返回 durable.ErrWrongGoroutine。
错误
从操作返回的 error 原样返回,除非 handler 把它当作业务结果处理。非 nil error 要么是该操作的终端失败,例如 *durable.StepError,要么是 invocation 正在挂起的信号。终端失败用 errors.As 匹配它的公开类型。挂起信号不匹配任何公开类型。因此,不匹配任何公开类型的 error 必须原样返回。
typed failure 不携带操作 body 返回的 error value。它携带记录的 ErrorType 和 Message,SDK 在首次 invocation 和 replay 时按同样方式重建。因此,匹配 ErrorType,不要用 errors.As 去匹配自己的错误类型。
type CardDeclinedError struct{}
func (CardDeclinedError) Error() string { return "card declined" }
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
}
操作
Step、StepAsync、Wait、WaitAsync、Invoke、InvokeAsync、RunInChildContext、RunInChildContextAsync、Go、WaitForCondition、CreateCallback、WaitForCallback、Map、Parallel、ParallelMixed、All、AllSettled、Any、Race、Join、Select、Retry。
每个操作都把 name 作为第二个参数。name 在 execution history 中标识该操作。传 "" 表示 unnamed 操作。
Step
step 是 checkpoint 的单位。SDK 用 encoding/json 记录它的结果,所以结果类型必须能通过 JSON round-trip。
从 SDK 开始一个 step 到服务记录 outcome 之间,函数可能超时,runtime 可能崩溃。默认的 AtLeastOncePerRetry 语义下,SDK 在 resume 时再次运行 body。因此一次 attempt 中 body 可能运行多次,body 应当幂等。AtMostOncePerRetry 把被中断的 attempt 当作失败,并交给 retry strategy 处理。
WithRetry 设置重试策略。默认是 ExponentialBackoff():总共 6 次尝试,从间隔 5 秒开始,每次翻倍,上限 60 秒,带 full jitter。NoRetry() 在第一次错误时失败。NewRetryStrategy 从 RetryConfig 构建策略,LinearBackoff 构建常量增量的策略。当下一次 attempt 到期时 handler 的其他工作仍在运行,它在同一个 invocation 中运行;当没有其他工作能推进时,invocation 为这段延迟结束。StepContext.Attempt() 是 1 起的 attempt 序号。
strategy, err := durable.NewRetryStrategy(durable.RetryConfig{MaxAttempts: 4, InitialDelay: time.Second, BackoffRate: 2})
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))
策略停止时,Step 返回一个 *durable.StepError,带 attempt 次数以及最后一次 attempt 记录的 ErrorType 和 Message。
WithStepSubType 设置 step 在 checkpoint 中记录的子类型。Replay 会把子类型与 checkpoint 比较,所以改它会破坏 in-flight 的 execution。
Wait
Wait 让 execution 暂停一段 duration。只有当 handler 没有其他工作能推进时 invocation 才结束,duration 到时服务再次调用函数。Replay 时已完成的 wait 立即返回。
duration 至少 1 秒。更短的 duration,Wait 在调用处返回错误,并且不记录操作。1 秒或更长会向上取整到整秒,所以 1.5 秒会等 2 秒。
任何情况下都要返回 Wait 的错误。它从不携带业务结果。
Invoke
Invoke 启动另一个耐久函数,作为它自己的 execution,并返回结果。输出类型参数放在最前面。目标需要 version 或 alias qualifier,例如 :$LATEST。被调用函数失败时,Invoke 返回 *durable.InvokeError。
return durable.Invoke[string](ctx, "charge", "payments-function:$LATEST", orderID)
WaitForCondition
WaitForCondition 反复运行一个 check。check 接收上一次 attempt 的 state,返回新的 state。strategy 接收新 state 和 attempt 序号。它返回 Continue: true 加一个 Delay 表示再次 poll,返回 Continue: false 表示带 state 停止,或返回 Err 让操作失败。
服务按整秒调度每次 check。Delay 为 0 会被提升到 1 秒;正的 Delay 向上取整到整秒;负的 Delay 让 WaitForCondition 失败并返回错误。
Callbacks
CreateCallback 创建回调并立即返回。回调暴露 ID(),这是交给外部系统的标识,以及 Result(ctx),它阻塞到外部系统完成该回调。外部系统用 SendDurableExecutionCallbackSuccess 或 SendDurableExecutionCallbackFailure API 完成回调。结果是 JSON 文档。
aws lambda send-durable-execution-callback-success --callback-id --result '"approved"' --cli-binary-format raw-in-base64-out
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)
默认的 Result 原样返回提交的字节(durable.RawSerdes,支持 string、[]byte、json.RawMessage),所以提交 "approved" 会带着引号返回,提交 42 得到字符串 "42"。要把字节按 JSON 解到结果类型,给操作传 durable.WithCallbackSerdes(durable.JSONSerdes),或在整个 handler 上设 durable.WithCallbackDeserializer。注意 WithSerdes 不作用于回调结果。
WaitForCallback 把两半合成一个操作:创建回调、用 callback ID 跑 submitter、阻塞等结果。submitter 作为 step 运行,可用 WithSubmitterRetry 重试。callback 与 WaitForCallback 都原样记录提交的字节,结果序列化只在 WaitForCallback 操作上跑一次,优先级同 CreateCallback。
两个操作都接受 WithCallbackTimeout 与 WithCallbackHeartbeatTimeout。超时失败为 *durable.CallbackTimeoutError,外部系统发来的失败为 *durable.CallbackExternalError。
RunInChildContext 子上下文
RunInChildContext 用独立 context 跑一个函数,内部操作记录在子上下文下,子上下文整体结果被 checkpoint。重放已完成的子上下文时,SDK 直接返回记录的结果、不跑函数。序列化后超过 256 KiB 的结果不会被存储;这种子上下文重放时会重新跑该函数,并把新结果过一遍 serdes,和首次运行一样,所以重放返回值与首次运行相同。
Retry
Retry 把“含有耐久操作的一段函数”当成一个单位重试。每次尝试跑在自己的子上下文里,所以失败尝试的操作绝不会被重放进下一次尝试。尝试之间的延迟就是一个 Wait,等待期间不消耗计算。策略停止时 Retry 返回 *durable.RetryError,带尝试次数和最后一次的错误。函数拿到 1 起的尝试序号。
durable.Retry(ctx, "quote-and-book", func(c durable.Context, attempt int) (string, error) {...}, durable.ExponentialBackoff())