chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
# Durable Execution: Flow Steps & Checkpoint
|
||||
|
||||
**Status:** Design proposal — not yet implemented.
|
||||
|
||||
This note sketches two related changes:
|
||||
|
||||
1. Give **flow** a real step model — a flow is a *task* made of *ordered
|
||||
steps* — so it becomes the deterministic-workflow engine it has always
|
||||
claimed to be (today it runs a single LLM step per event).
|
||||
2. Introduce **`Checkpoint`**, a pluggable durability primitive that
|
||||
persists run progress and resumes after a crash. Store-backed by
|
||||
default; both flow and agent use it.
|
||||
|
||||
The two are designed together because a step boundary is the natural
|
||||
place to checkpoint.
|
||||
|
||||
---
|
||||
|
||||
## Motivation
|
||||
|
||||
A flow or agent run is long, expensive, and has side effects partway
|
||||
through (it sent an email at step 2, charged via x402 at step 4). Today
|
||||
all in-flight state lives in process memory: a crash loses the run, and
|
||||
re-running from the top repeats the side effects.
|
||||
|
||||
Durable execution means the run survives a crash and **continues from
|
||||
where it stopped**, without re-doing completed steps.
|
||||
|
||||
This is squarely a distributed-systems concern — checkpoint state, replay
|
||||
on restart, pluggable backend — i.e. go-micro's kind of problem, built on
|
||||
primitives it already has (`store`, `WrapTool`, `call.ID`).
|
||||
|
||||
---
|
||||
|
||||
## What flow is today (for contrast)
|
||||
|
||||
`flow` is a concrete `*Flow` struct. Per broker event, `Execute` runs
|
||||
**one** augmented-LLM turn (a single `Generate` with services as tools)
|
||||
or dispatches the event to an agent, records one `Result`, and returns.
|
||||
There is no notion of a task with ordered steps, no carried state, no
|
||||
checkpoint. The step model below generalizes today's behavior: a flow
|
||||
with one step == current flow.
|
||||
|
||||
---
|
||||
|
||||
## Core concepts
|
||||
|
||||
### State
|
||||
|
||||
What carries across steps. **A struct, not a map** — a typed `Data`
|
||||
plus a `Stage` marker so you can always tell where a run is.
|
||||
|
||||
```go
|
||||
type State struct {
|
||||
Stage string // name of the step the run is at — where it is
|
||||
Data []byte // carried data, serialized; use Set / Scan
|
||||
}
|
||||
|
||||
// Set replaces the data with the JSON encoding of v.
|
||||
func (s *State) Set(v any) error
|
||||
// Scan decodes the data into v (a pointer to the caller's struct).
|
||||
func (s State) Scan(v any) error
|
||||
```
|
||||
|
||||
The developer defines their own data struct and threads it through
|
||||
with `Set`/`Scan` — type-safe at the edges, serializable in the middle
|
||||
(which is what makes checkpointing possible). `Stage` is the readable
|
||||
"where am I"; the engine also uses it as the resume point.
|
||||
|
||||
The trigger event seeds the first `State`.
|
||||
|
||||
### Step
|
||||
|
||||
The unit of a flow. **One kind** — a struct with a name, the action to
|
||||
run, and an optional retry override. No per-kind constructors.
|
||||
|
||||
```go
|
||||
type StepFunc func(ctx context.Context, in State) (State, error)
|
||||
|
||||
type Step struct {
|
||||
Name string
|
||||
Run StepFunc
|
||||
Retry int // optional per-step override of the flow's retry (0 = use flow default)
|
||||
}
|
||||
```
|
||||
|
||||
Common actions are **helpers that return a `StepFunc`**, dropped into
|
||||
`Step.Run` — so there is still one `Step` type, and the actions compose:
|
||||
|
||||
```go
|
||||
flow.Call(service, endpoint) StepFunc // one RPC to a service
|
||||
flow.LLM(opts...) StepFunc // one augmented-LLM turn
|
||||
flow.Agent(name) StepFunc // dispatch to a registered agent
|
||||
// …or write your own StepFunc.
|
||||
```
|
||||
|
||||
Steps are **authored by the developer** and run in order. That ordering
|
||||
is the defining difference from an agent, where the *model* chooses the
|
||||
steps.
|
||||
|
||||
### Run
|
||||
|
||||
The persisted record of one execution — what `Checkpoint` saves and
|
||||
loads. Retained for success and failure (see retention below).
|
||||
|
||||
```go
|
||||
type Run struct {
|
||||
ID string // durable run id (idempotency root)
|
||||
Flow string // flow name
|
||||
State State // carried data + Stage (where it is)
|
||||
Steps []StepRecord // per-step status + outcome (history/audit)
|
||||
Status string // running | done | failed
|
||||
Started time.Time
|
||||
Updated time.Time
|
||||
}
|
||||
|
||||
type StepRecord struct {
|
||||
Name string
|
||||
Status string // pending | in_progress | done | failed
|
||||
Attempts int // how many tries this step took
|
||||
Result string // short serialized outcome / summary
|
||||
Error string
|
||||
}
|
||||
```
|
||||
|
||||
The resume point is `State.Stage` — there is no separate numeric cursor,
|
||||
so there is one source of truth for "where it is."
|
||||
|
||||
### Checkpoint
|
||||
|
||||
The pluggable durability primitive. Persists and restores a `Run`.
|
||||
|
||||
```go
|
||||
type Checkpoint interface {
|
||||
Save(ctx context.Context, run Run) error
|
||||
Load(ctx context.Context, runID string) (Run, bool, error)
|
||||
Delete(ctx context.Context, runID string) error
|
||||
}
|
||||
```
|
||||
|
||||
The built-in implementation is **store-backed** and on by default, keyed
|
||||
in the store:
|
||||
|
||||
```
|
||||
database "flow", table "{name}", key {runID} → JSON(Run)
|
||||
```
|
||||
|
||||
Runs are confined to their own **store table** — database `flow`, one
|
||||
table per flow name — via `store.Scope`, not a single shared table keyed
|
||||
by prefix. `StoreCheckpoint(s, scope)` takes that scope; the flow passes
|
||||
its name by default. `store.Scope` injects the database/table per
|
||||
operation, so it doesn't mutate or race on the shared store (the way
|
||||
`Init(Table(...))` would). Because it rides on `store.Store`, the storage
|
||||
is also pluggable (Postgres, NATS KV, file) with no extra interface.
|
||||
|
||||
**Retention:** completed runs (success *and* failure) are **kept** by
|
||||
default, so you have a durable history of what ran. `Delete` is only
|
||||
called when the flow opts in with `flow.DeleteOnSuccess()` (failures are
|
||||
always kept).
|
||||
|
||||
---
|
||||
|
||||
## The run loop
|
||||
|
||||
```
|
||||
run := load(runID) or new Run{State: {Stage: steps[0].Name, ...}}
|
||||
|
||||
start := index of step named run.State.Stage
|
||||
for i := start; i < len(steps); i++ {
|
||||
step := steps[i]
|
||||
run.Steps[i].Status = "in_progress"; checkpoint.Save(run)
|
||||
|
||||
out, err := runWithRetry(ctx, step, run.State, retriesFor(step))
|
||||
run.Steps[i].Attempts = attemptsTaken
|
||||
if err != nil {
|
||||
run.Steps[i].Status = "failed"; run.Steps[i].Error = err
|
||||
run.Status = "failed"; checkpoint.Save(run) // kept for audit
|
||||
return err // resumable: retry resumes here
|
||||
}
|
||||
|
||||
run.State = out
|
||||
run.Steps[i].Status = "done"
|
||||
if i+1 < len(steps) {
|
||||
run.State.Stage = steps[i+1].Name // <-- checkpoint boundary
|
||||
} else {
|
||||
run.State.Stage = "" // finished
|
||||
}
|
||||
checkpoint.Save(run)
|
||||
}
|
||||
|
||||
run.Status = "done"; checkpoint.Save(run)
|
||||
// Delete only if flow.DeleteOnSuccess() was set.
|
||||
```
|
||||
|
||||
On restart, `Load` returns the `Run`; the loop resumes at the step named
|
||||
`run.State.Stage`, so completed steps are skipped — their effects already
|
||||
happened and their output is already in `run.State.Data`.
|
||||
|
||||
### Retry
|
||||
|
||||
Flow-level by default, per-step override when needed (e.g. a tool that
|
||||
times out):
|
||||
|
||||
```go
|
||||
flow.Retry(2) // flow-level default for every step
|
||||
flow.Step{Name: "charge", Run: …, Retry: 0} // override: never retry this one
|
||||
```
|
||||
|
||||
`retriesFor(step)` uses `step.Retry` if set, else the flow default.
|
||||
|
||||
### Idempotency (the honest part)
|
||||
|
||||
True exactly-once is impossible if a crash lands *inside* a step. What we
|
||||
provide is at-least-once + a stable **idempotency key** per step:
|
||||
`runID + stepName`. That key is passed to the tool as `call.ID`, so a
|
||||
replayed call is recognized downstream and de-duplicated. Side-effecting
|
||||
steps must cooperate (honor the key). The framework makes this
|
||||
consistent; it cannot make it free.
|
||||
|
||||
Retry uses the same key, so a retried step is de-duplicated the same way.
|
||||
This is where the existing `WrapTool` seam pays off: a durable wrapper
|
||||
checks the checkpoint — if this `call.ID` already has a recorded result,
|
||||
return it without re-calling.
|
||||
|
||||
---
|
||||
|
||||
## Agent reuse
|
||||
|
||||
The agent loop is the **self-directed** analogue and uses the same
|
||||
`Checkpoint`. The difference is who authors the steps:
|
||||
|
||||
| | Steps authored by | Steps known | Durability |
|
||||
|---|---|---|---|
|
||||
| **flow** | developer | up front (ordered list) | checkpoint between steps |
|
||||
| **agent** | the model | discovered at runtime | checkpoint each LLM turn + its tool calls |
|
||||
|
||||
For the agent, `Run.Steps` grows as the model acts, instead of being
|
||||
predefined. One requirement: the agent must own its loop (today the
|
||||
provider drives it), so it can `Save` between turns. That is the one
|
||||
structural change on the agent side.
|
||||
|
||||
---
|
||||
|
||||
## Pluggability — two levels
|
||||
|
||||
1. **Storage (free today).** Built-in `Checkpoint` over `store.Store`;
|
||||
swap the store backend. Covers "checkpoint to my DB instead."
|
||||
2. **Engine (future).** Because steps are now explicit and named, a flow
|
||||
can be mapped onto an external durable-execution engine — each `Step`
|
||||
becomes a Temporal activity / Restate handler — by providing an
|
||||
alternative runner. Most users only need level 1; level 2 exists so
|
||||
teams already running Temporal aren't forced off it.
|
||||
|
||||
The explicit step model is what makes level 2 possible later; we don't
|
||||
build it now.
|
||||
|
||||
---
|
||||
|
||||
## Proposed API
|
||||
|
||||
```go
|
||||
type Onboarding struct {
|
||||
Email string `json:"email"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
f := flow.New("onboard-user",
|
||||
flow.Trigger("events.user.created"),
|
||||
flow.Retry(2), // flow-level retry default
|
||||
flow.Steps(
|
||||
flow.Step{Name: "plan", Run: flow.LLM(flow.Prompt("Plan onboarding for {{.Email}}"))},
|
||||
flow.Step{Name: "workspace", Run: flow.Call("workspace", "Workspace.Create")},
|
||||
flow.Step{Name: "welcome", Run: flow.Agent("comms")},
|
||||
),
|
||||
// Durable by default (store-backed); runs are retained for audit.
|
||||
flow.WithCheckpoint(flow.StoreCheckpoint(service.Options().Store, "onboard-user")),
|
||||
)
|
||||
f.Register(reg, broker, client)
|
||||
```
|
||||
|
||||
A single-step flow keeps today's behavior, so this is additive.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (resolved)
|
||||
|
||||
- **State is a struct, not a map** — typed `Data` + `Stage`. The
|
||||
developer defines the data struct; `Stage` doubles as the resume
|
||||
point, so there is one source of truth for position.
|
||||
- **One `Step` kind** — a struct with `Name`, `Run`, and an optional
|
||||
`Retry`. Common actions are `StepFunc` helpers (`Call`, `LLM`,
|
||||
`Agent`), not separate step constructors.
|
||||
- **Runs are retained** for success and failure by default;
|
||||
`flow.DeleteOnSuccess()` opts into cleanup (failures always kept).
|
||||
- **Retry is a flow-level option** (`flow.Retry(n)`), with a per-step
|
||||
`Retry` field as a fine-grained override.
|
||||
|
||||
---
|
||||
|
||||
## Scope & phasing
|
||||
|
||||
1. **Step model in flow** (no durability yet): `State`, `Step`, ordered
|
||||
`Steps`, the run loop, retry. Single-step flows unchanged.
|
||||
2. **`Checkpoint` + store-backed default**: persist/resume flow runs,
|
||||
retention.
|
||||
3. **Agent durability**: move the agent loop in-package, reuse
|
||||
`Checkpoint`. Opt-in (`AgentDurable()`), default off — overkill for
|
||||
short interactive chats, essential for long unattended runs.
|
||||
4. **Engine-level pluggability** (Temporal/Restate): only if demand.
|
||||
|
||||
Each phase is independently useful and shippable.
|
||||
Reference in New Issue
Block a user