Skip to main content
Concepts

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.