Appearance
Renaming and Versioning Steps
How to evolve step definitions over time without breaking in-flight runs.
Step Kind as a Durable Identifier
The name argument to NewStep and NewAction becomes the kind. Cyclone stores that kind in cyclone_steps.kind. When a worker claims a step, it dispatches to the handler registered for that kind. Cyclone keeps the kind for the life of the run. A change to the kind breaks every in-flight run that uses the old name.
sql
-- The kind is stored in the database permanently
SELECT run_id, node, kind, state FROM cyclone_steps WHERE kind = 'send_email';Why Renaming Is Unsafe
If you change a step's name from "send_email" to "send_notification":
- In-flight runs enqueued before the deploy still have
kind = 'send_email'incyclone_steps. - Your new binary registers
"send_notification". It does not register"send_email". - Workers find no handler for
kind = 'send_email'. The step fails. - Cyclone discards the step when the attempt limit is reached. The run fails.
Do not rename a step kind that can still have in-flight runs.
WithVersion(n): Safe Kind Evolution
Use WithVersion to create a new kind that coexists with the old one:
go
// Original step — still registered for in-flight runs
var SendEmail = cyclone.NewAction("send_email", sendEmailFn)
// New version — new runs use this kind ("send_email@v2")
var SendEmailV2 = cyclone.NewAction("send_email", sendEmailV2Fn, cyclone.WithVersion(2))The stored kind for V2 is "send_email@v2". New runs use V2. Old in-flight runs continue with the original "send_email".
Register both handlers:
go
mux.Register(SendEmail) // "send_email" — for in-flight V1 runs
mux.Register(SendEmailV2) // "send_email@v2" — for new runsSafe Rollout Procedure
- Add the V2 handler with the new logic and
WithVersion(2). Deploy the V1 handler and the V2 handler. - Update enqueue call sites to use V2. V2 is the new step variable. New runs use
"send_email@v2". - Wait for V1 runs to drain. Check:sql
SELECT count(*) FROM cyclone_steps WHERE kind = 'send_email' AND state NOT IN ('completed', 'discarded', 'cancelled'); -- Should return 0 - Remove the V1 handler from the mux in the next deploy.
- You can reclaim the name later. See below.
Handling Old Queued Work
During the transition, an old run that uses the original kind must still find a handler. Keep the old handler registered until all old runs are terminal:
go
// Both registered simultaneously during transition period
mux.Register(SendEmail) // V1: old kind "send_email"
mux.Register(SendEmailV2) // V2: new kind "send_email@v2"Keeping Old Handlers Temporarily
You can simplify an old handler to a no-op. Do that when both of these are true. The old logic is no longer safe to execute. You still want a clean drain:
go
// Simplified no-op for draining old in-flight runs
var SendEmailLegacy = cyclone.NewAction("send_email", func(ctx cyclone.Context, in LegacyInput) error {
slog.Warn("legacy send_email step claimed; skipping", "run_id", ctx.RunID())
return nil // complete without effect
})The no-op makes the old runs complete. Cyclone does not discard them. The new handler is in production at the same time.
Reclaiming Names
When every run that uses the old kind is terminal, you can:
- Drop the old handler from the mux.
- You can register a new V3 handler under the original name, with no version suffix:go
var SendEmail = cyclone.NewAction("send_email", latestSendEmailFn)
Check the database before you reclaim the name:
sql
SELECT kind, state, count(*)
FROM cyclone_steps
WHERE kind = 'send_email'
GROUP BY kind, state;
-- All rows should be in terminal statesWorkflow Node IDs vs Step Kinds
Node IDs and step kinds are different:
- Kind: the handler's registered name (
"send_email","send_email@v2"). Cyclone uses the kind for dispatch. - Node ID: the step's identifier within a specific run (
"notify","send_email-1"). Cyclone uses the node ID for retry targeting, output reading, and test assertions.
Cyclone fixes node IDs at submit time. They never change. Cyclone uses step kinds for routing. You can version a step kind. A node with ID "notify" always executes the handler for its kind. The kind can be "send_email@v2". The node ID stays "notify".
go
cyclone.Run(b, SendEmailV2, cyclone.From(orderFuture), cyclone.ID("notify"))
// Node ID: "notify" Kind: "send_email@v2"