Versioning & Migrations
Evolving a workflow that is already running.
Versioned workflow evolution
Use this when workflow structure changes across deployments and old histories must still replay.
import { actor, setup } from "rivetkit";
import { type WorkflowStepContextOf, workflow } from "rivetkit/workflow";
export const versionedWorkflowActor = actor({
state: {
runs: 0,
},
run: workflow(async (ctx) => {
await ctx.step("validate-v2", async (step) => {
step.state.runs += 1;
});
await ctx.removed("validate-v1", "step");
await ctx.loop("main-loop-v2", async (loopCtx) => {
await loopCtx.sleep("idle", 500);
await loopCtx.step("heartbeat-v2", async (step) => {
step.state.runs += 1;
});
});
}),
actions: {
getState: (c) => c.state,
},
});
export const registry = setup({ use: { versionedWorkflowActor } });
Version gates with getVersion
Use ctx.getVersion(name, latest) to branch behavior when you change a workflow’s logic while old instances are still in flight. It returns the version this instance is pinned to at that point:
- A fresh instance resolves to
latest. - An instance that already executed past this point under older code resolves to
1(the implicit floor).
The resolved version is recorded in history, so replays are deterministic and each instance stays on the branch it started on. Inside a loop, every iteration resolves independently, so in-flight iterations finish on the old branch while new iterations pick up latest.
import { actor, setup } from "rivetkit";
import { workflow } from "rivetkit/workflow";
export const versionGateActor = actor({
state: {
processed: 0,
},
run: workflow(async (ctx) => {
await ctx.loop("process-loop", async (loopCtx) => {
// Gate the changed code path. Each loop iteration resolves its version
// independently: an iteration that already ran under the old code
// (in-flight across the deploy) resolves to version 1, while a fresh
// iteration resolves to `latest` (2 here). The resolved value is pinned
// in history, so replays stay deterministic.
const version = await loopCtx.getVersion("process-message", 2);
if (version === 1) {
// Preserve the original behavior for in-flight iterations.
await loopCtx.step("process-v1", async (step) => {
step.state.processed += 1;
});
} else {
// New behavior for iterations that begin after this deploy.
await loopCtx.step("process-v2", async (step) => {
step.state.processed += 1;
});
}
await loopCtx.sleep("idle", 1_000);
});
}),
actions: {
getState: (c) => c.state,
},
});
export const registry = setup({ use: { versionGateActor } });
latest must be an integer >= 1, and the gate name must be unique within its scope like any other entry. Once every old instance has drained, retire the gate by replacing the call with ctx.removed(name, "version_check").
Migrations
- Keep workflow entry names stable once deployed.
- If an old entry was removed or renamed, call
ctx.removed(name, originalType). - To change behavior at a point while old instances are still running, gate it with
ctx.getVersion(name, latest)(see Version gates). - This keeps replay compatible across deployments.