Skip to content

Commit 146d350

Browse files
authored
Merge pull request #19 from wavezync/feat/v1.2-correctness
Timeouts that stop running steps, on_timeout: :fail fix, versioning phase 1
2 parents fe5af15 + 89e609c commit 146d350

16 files changed

Lines changed: 573 additions & 170 deletions

File tree

‎agents/ROADMAP.md‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Durable — Roadmap
22

33
**Last Updated:** 2026-10-23
4-
**Current release:** `durable` 1.0.0 and `durable_dashboard` 1.0.0 on Hex (2026-10-21)
4+
**Current release:** `durable` 1.1.0 (2026-10-23) and `durable_dashboard` 1.0.0 (2026-10-21) on Hex
55
**Reference:** See `arch.md` for technical architecture, `WORKPLAN_ARCHIVED.md` for historical detail
66

77
---
@@ -50,20 +50,17 @@
5050
| [M05](milestones/M05-message-bus.md) | Message Bus | Partial | `Durable.PubSub` lifecycle events shipped. Adapter behaviour / pg_notify not built → backlog |
5151
| [M06](milestones/M06-phoenix-dashboard.md) | Phoenix Dashboard | Done | Shipped as its own package |
5252
| [M07](milestones/M07-scalability-adapters.md) | Scalability Adapters | Backlog | No demand for Redis yet |
53+
| [M08](milestones/M08-workflow-versioning.md) | Workflow Versioning | In Progress | Phase 1 (fail on a removed step) done; phase 2 (`version:` pinning) waits on the doc's open questions |
5354

5455
---
5556

5657
## Next Up
5758

5859
In priority order:
5960

60-
1. **Correctness follow-ups from v1:**
61-
- The workflow deadline can't interrupt a step that's still running; it's only checked between steps.
62-
- `wait_for_input` with `on_timeout: :fail` ends the run as `cancelled`, not `failed`. Fixing it changes behaviour, so ship it in a minor release with a changelog note.
63-
2. **Workflow versioning.** Needs a milestone doc. Changing a deployed workflow while runs are still in flight is the main open problem for production users.
64-
3. **DSL gaps:** waits inside `parallel` blocks, an `each`/foreach primitive (each iteration needs its own context, see `Durable.DSL.Step` docs), decisions inside `parallel`.
65-
4. **Testing follow-up:** wait timeouts can't be exercised by `Durable.Testing.drain/2` (the timeout worker only runs with the queue on). A helper that fires a run's pending timeouts would close it.
66-
5. **Formatter follow-up to #10:** the repo's own `durable/.formatter.exs` only *exports* `locals_without_parens`, so internal code and tests still get parens. Also add `:durable` to `import_deps` in `durable_dashboard` and `examples/phoenix_demo`.
61+
1. **Workflow versioning, phase 2** ([M08](milestones/M08-workflow-versioning.md)). Pin each run to the `version:` it started on. Needs the doc's three open questions answered first. Adds a migration.
62+
2. **DSL gaps:** waits inside `parallel` blocks, an `each`/foreach primitive (each iteration needs its own context, see `Durable.DSL.Step` docs), decisions inside `parallel`.
63+
3. **Testing follow-up:** wait timeouts can't be exercised by `Durable.Testing.drain/2` (the timeout worker only runs with the queue on). A helper that fires a run's pending timeouts would close it.
6764

6865
---
6966

@@ -86,9 +83,10 @@ In priority order:
8683
2. Decisions are not supported inside parallel blocks
8784
3. No backward jumps in decision steps (forward-only by design)
8885
4. Context is single-level atomized (top-level keys only)
89-
5. No workflow versioning
86+
5. No workflow version pinning (M08)
9087
6. No foreach/loop DSL primitive (use Elixir's `Enum` functions)
9188
7. A resumed wait re-runs the step body from the top; side effects before the wait must be idempotent
9289
8. Only one `sleep/1` per step body
93-
9. The workflow deadline is checked between steps and can't interrupt a running step
94-
10. `Durable.Testing.drain/2` doesn't fire wait timeouts
90+
9. `Durable.Testing.drain/2` doesn't fire wait timeouts
91+
10. A step with a `timeout:`, or in a workflow with one, runs in its own process (`self()` isn't the worker)
92+
11. In-flight runs resume on the deployed definition; a removed current step fails the run (versioning: M08)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# M08: Workflow Versioning
2+
3+
**Status:** In Progress (phase 1 done, phase 2 needs the open questions answered)
4+
**Priority:** High
5+
**Effort:** 1 week
6+
**Dependencies:** None
7+
8+
## Motivation
9+
10+
A deploy changes the workflow code under runs that are still in flight. Today a
11+
run resumes against whatever definition is deployed, matched to its position by
12+
step name. Whether that's safe depends on the change:
13+
14+
| Change | In-flight run today |
15+
|--------|---------------------|
16+
| Edit a step body | Runs the new body. Usually what you want (bug fixes). |
17+
| Add a step after the run's position | Runs it. |
18+
| Add a step before the run's position | Skips it. |
19+
| Remove a later step | Skips it. |
20+
| Rename or remove the step the run stopped at | **Was:** marked `completed`, every step after skipped, no error. **Now (phase 1):** fails with `definition_changed`. |
21+
| Change a `parallel`/`branch` block's steps | The block's id is a hash of its shape, so its step names change: same as a rename. |
22+
| A step expects context an earlier *new* step writes | Crashes at runtime, retried, fails. |
23+
24+
The silent `completed` was a data-loss bug and is fixed. The rest needs a way to
25+
keep old runs on the old definition until they finish, which is what this
26+
milestone adds.
27+
28+
## Scope
29+
30+
### In Scope
31+
32+
- Phase 1 (done): a run whose current step no longer exists fails with
33+
`%{type: "definition_changed"}` and no compensations, so it stays retryable
34+
once the step is restored.
35+
- Phase 2: explicit versions. Several definitions of one workflow side by side;
36+
each run pinned to the version it started on.
37+
- A way to see which versions still have runs in flight, so old code can be
38+
deleted safely.
39+
40+
### Out of Scope
41+
42+
- Replay-based patching (Temporal's `patched?`). Durable checkpoints steps
43+
rather than replaying history, so there's nothing to keep deterministic.
44+
- Automatic versions from a structural hash. Every change would make a new
45+
version, including the safe ones above, and old definitions still have to
46+
stay in the code to be run.
47+
- Migrating a run from one version to another (moving its position by hand).
48+
49+
## Architecture & Design
50+
51+
### API
52+
53+
```elixir
54+
defmodule MyApp.OrderWorkflow do
55+
use Durable
56+
57+
# Runs started before v2 shipped finish on this. Delete it once
58+
# `mix durable.status` shows none left.
59+
workflow "order", version: 1 do
60+
step :reserve, ...
61+
step :approve, ...
62+
step :ship, ...
63+
end
64+
65+
workflow "order", version: 2 do
66+
step :reserve, ...
67+
step :review, ...
68+
step :ship, ...
69+
end
70+
end
71+
```
72+
73+
- `version:` is a positive integer workflow option, default `1`. Every workflow
74+
written so far is version 1, which matches every existing run.
75+
- Two `workflow` blocks with the same name and version fail the compile.
76+
- `Durable.start/3`, `call_workflow`, and schedules start the highest version.
77+
`Durable.start(mod, input, version: 1)` picks one explicitly (for tests of the
78+
old path).
79+
- `workflow_version/0` (in `Durable.Context`) returns the run's version, so one
80+
definition can branch on it for small differences instead of being copied.
81+
82+
### Storage
83+
84+
- New column `workflow_executions.workflow_version integer NOT NULL DEFAULT 1`
85+
(a new migration, shipped through `mix durable.gen.upgrade`). The default
86+
covers existing rows.
87+
- Index `(workflow_module, workflow_name, workflow_version) WHERE status IN
88+
(pending, running, waiting)` for the in-flight report.
89+
90+
### Resolution
91+
92+
- `__workflow_definition__(name)` keeps returning the latest version (the
93+
dashboard and `Durable.Scheduler` rely on it);
94+
`__workflow_definition__(name, version)` returns one version, and
95+
`__workflow_versions__(name)` lists them.
96+
- The executor resolves `(module, name, version)`. A version that's gone fails
97+
the run with `definition_changed`, like a missing step.
98+
- Parallel children copy the parent's version. A `call_workflow` child is a new
99+
run and starts the callee's latest version.
100+
101+
### Visibility
102+
103+
- `Durable.Query.list_executions(version: 1)`.
104+
- `mix durable.status` shows in-flight runs per version.
105+
- Dashboard: the version on the run page; per-version in-flight counts on the
106+
workflow page.
107+
- Telemetry: `:workflow_version` in run metadata (adding keys is allowed by the
108+
telemetry contract).
109+
110+
## Implementation Plan
111+
112+
1. ~~Phase 1: fail on a missing current step~~ (done in this branch).
113+
2. DSL: `version:` option, per-version definition functions, duplicate check,
114+
`__workflow_versions__/1`.
115+
3. Migration, schema, executor resolution, start/call/schedule pick latest,
116+
parallel children inherit.
117+
4. `workflow_version/0`, query filter, `mix durable.status`, telemetry.
118+
5. Dashboard: version on the run page and in-flight counts per version.
119+
6. Guide: "Changing workflows" — which changes are safe in place, when to add a
120+
version, how to retire one.
121+
122+
## Testing Strategy
123+
124+
- Two versions in one module: new runs start v2, a v1 run resumes and finishes
125+
on v1.
126+
- A run whose version was deleted fails with `definition_changed`; restoring it
127+
and `Durable.retry/2` finishes the run.
128+
- Parallel children run the parent's version.
129+
- Existing rows (no version set) resolve as version 1.
130+
- Duplicate `name`/`version` fails the compile.
131+
132+
## Acceptance Criteria
133+
134+
- [x] A deploy that removes a run's current step fails the run loudly and
135+
recoverably instead of completing it.
136+
- [ ] A run always finishes on the version it started on, while that version is
137+
in the code.
138+
- [ ] Operators can see which versions still have runs before deleting one.
139+
- [ ] Upgrading from 1.x needs only `mix durable.gen.upgrade` and a migrate.
140+
141+
## Open Questions
142+
143+
1. **API shape:** several `workflow "order", version: N` blocks in one module
144+
(proposed), or one module per version (`MyApp.Order.V1`) tied together by a
145+
name?
146+
2. **Release:** the new column means users must run a migration. Ship as 1.2.0
147+
with an upgrade note, as the migration tooling was built for, or hold for
148+
2.0?
149+
3. **Missing version:** fail the run (proposed), or fall back to the latest
150+
version when the run's current step exists there too?
151+
152+
## References
153+
154+
- AWS Step Functions versions and aliases: executions are pinned to the version
155+
they started on.
156+
- Restate deployments: old deployments keep serving in-flight invocations until
157+
they drain.
158+
- `test/durable/definition_change_test.exs`: the phase 1 behaviour.

‎durable/.formatter.exs‎

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
# Used by "mix format"
2+
locals_without_parens = [
3+
# Workflow DSL
4+
workflow: 2,
5+
workflow: 3,
6+
# Step DSL
7+
branch: 2,
8+
compensate: 2,
9+
compensate: 3,
10+
decision: 2,
11+
decision: 3,
12+
parallel: 1,
13+
parallel: 2,
14+
step: 2,
15+
step: 3
16+
]
17+
218
[
319
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
4-
export: [
5-
locals_without_parens: [
6-
# Workflow DSL
7-
workflow: 2,
8-
workflow: 3,
9-
# Step DSL
10-
branch: 2,
11-
compensate: 2,
12-
compensate: 3,
13-
decision: 2,
14-
decision: 3,
15-
parallel: 1,
16-
parallel: 2,
17-
step: 2,
18-
step: 3
19-
]
20-
]
20+
locals_without_parens: locals_without_parens,
21+
export: [locals_without_parens: locals_without_parens]
2122
]

‎durable/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -497,9 +497,9 @@ workflow "process_order", timeout: hours(2), queue: :orders do
497497
```
498498

499499
- `:timeout` — how long a run may take from when it's due to start, waits and
500-
sleeps included. A run past it fails with a `"timeout"` error before its next
501-
step and runs its compensations; a waiting run is woken to do so (within the
502-
timeout worker's one-minute sweep). It can't interrupt a step that's running.
500+
sleeps included. A run past it fails with a `"timeout"` error and runs its
501+
compensations: a step still running is stopped, and a waiting run is woken
502+
to fail (within the timeout worker's one-minute sweep).
503503
- `:queue` — the queue runs start on unless `Durable.start/3` names one.
504504

505505
Unknown options fail the compile.

‎durable/guides/waiting.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ end
252252
| `:metadata` | Additional data for UI |
253253
| `:timeout` | Max wait time |
254254
| `:timeout_value` | Value on timeout |
255+
| `:on_timeout` | `:resume` (default) returns `:timeout_value`; `:fail` fails the run and runs its compensations |
255256
| `:fields` | Field definitions for forms |
256257
| `:choices` | Options for choice inputs |
257258

‎durable/lib/durable/dsl/step.ex‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,13 @@ defmodule Durable.DSL.Step do
3737
- `:retry` - Retry configuration
3838
- `:max_attempts` - Maximum retry attempts (default: 1)
3939
- `:backoff` - Backoff strategy: `:exponential`, `:linear`, `:constant`
40-
- `:base` - Base for backoff calculation (default: 2)
41-
- `:max_backoff` - Maximum backoff in ms (default: 3600000)
42-
- `:timeout` - Step timeout in milliseconds
40+
- `:base` - Base delay in ms (default: 1000)
41+
- `:max_backoff` - Maximum backoff in ms (default: 30000)
42+
- `:timeout` - How long one attempt may run, in milliseconds. A slower
43+
attempt is stopped and fails with a `"timeout"` error, which is retried
44+
like any error. A step with a timeout, or in a workflow with one, runs in
45+
a separate process: its body sees the same context and captured logs,
46+
but `self()` is not the worker.
4347
- `:compensate` - Name of compensation function for saga pattern
4448
- `:queue` - Override queue for this step
4549
@@ -89,7 +93,7 @@ defmodule Durable.DSL.Step do
8993
type: :step,
9094
module: __MODULE__,
9195
body_fn: &(__MODULE__.unquote(func_name) / 1),
92-
opts: unquote(Macro.escape(normalized_opts))
96+
opts: unquote(quote_opts(normalized_opts))
9397
}
9498
end
9599
end
@@ -113,6 +117,14 @@ defmodule Durable.DSL.Step do
113117

114118
defp normalize_retry_opts(opts), do: opts
115119

120+
# Option values are code: splice them in rather than escape them, so
121+
# `timeout: seconds(30)` is a number in the workflow module, not the quoted
122+
# call.
123+
defp quote_opts(opts) when is_map(opts),
124+
do: {:%{}, [], Enum.map(opts, fn {key, value} -> {key, quote_opts(value)} end)}
125+
126+
defp quote_opts(value), do: value
127+
116128
@doc """
117129
Defines a decision step for conditional branching.
118130
@@ -165,7 +177,7 @@ defmodule Durable.DSL.Step do
165177
type: :decision,
166178
module: __MODULE__,
167179
body_fn: &(__MODULE__.unquote(func_name) / 1),
168-
opts: unquote(Macro.escape(normalized_opts))
180+
opts: unquote(quote_opts(normalized_opts))
169181
}
170182
end
171183
end
@@ -227,7 +239,7 @@ defmodule Durable.DSL.Step do
227239
name: unquote(name),
228240
module: __MODULE__,
229241
body_fn: &(__MODULE__.unquote(func_name) / 1),
230-
opts: unquote(Macro.escape(normalized_opts))
242+
opts: unquote(quote_opts(normalized_opts))
231243
}
232244
end
233245
end
@@ -299,7 +311,7 @@ defmodule Durable.DSL.Step do
299311
type: :step,
300312
module: __MODULE__,
301313
body_fn: &(__MODULE__.unquote(func_name) / 1),
302-
opts: unquote(Macro.escape(step_opts))
314+
opts: unquote(quote_opts(step_opts))
303315
}
304316
end
305317
end)
@@ -515,7 +527,7 @@ defmodule Durable.DSL.Step do
515527
type: :step,
516528
module: __MODULE__,
517529
body_fn: &(__MODULE__.unquote(func_name) / 1),
518-
opts: unquote(Macro.escape(step_opts))
530+
opts: unquote(quote_opts(step_opts))
519531
}
520532
end
521533
end)

‎durable/lib/durable/dsl/workflow.ex‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ defmodule Durable.DSL.Workflow do
2222
2323
- `:timeout` - How long a run may take, in milliseconds, from when it is due
2424
to start — waits and sleeps included. A run past it fails with a
25-
`"timeout"` error before its next step, running compensations like any
26-
failure; a waiting run is woken to do so. The deadline is checked between
27-
steps, so it can't interrupt a step that is still running.
25+
`"timeout"` error, running compensations like any failure: a step still
26+
running is stopped (and not retried), and a waiting run is woken to fail.
27+
Steps in a run with a timeout run in their own process; see the step
28+
`:timeout` option.
2829
- `:queue` - The queue runs start on unless `Durable.start/3` names one
2930
3031
"""

0 commit comments

Comments
 (0)