|
| 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. |
0 commit comments