Skip to main content

Shadowing a Definition Change

Definition versioning stops a pipeline change from corrupting a run that is already in flight. What it does not do is tell you before you deploy that your change is incompatible — it just leaves those runs waiting for a host that no longer exists.

Cadence closes that gap with a Workflow Shadower: sample production runs, fetch their histories, replay them against a candidate build. The step-ledger analogue ships in @bratsos/workflow-engine/testing, so you can run it from your own test suite and fail a build instead of a production run.

The CI sweep​

shadowVersions needs no run ids. It finds every definition version that still has live runs and checks your candidate build against each one:

import {
assertShadowCompatible,
shadowVersions,
} from "@bratsos/workflow-engine/testing";

it("does not strand any run that is still in flight", async () => {
const report = await shadowVersions({
persistence, // your real adapter, pointed at staging
candidates: [invoiceWorkflow, reportWorkflow], // the build you are shipping
});

assertShadowCompatible(report);
});

By default it only looks at versions with runs in PENDING, RUNNING or SUSPENDED — the states in which a run still needs a host. A version whose runs have all finished cannot be stranded, so it is skipped.

assertShadowCompatible throws with one line per problem:

1 of 2 recorded definition version(s) cannot be executed by the candidate definition:
workflow "invoice" version "sha256-3f2a…" (14 live run(s)):
Stage "extract" is no longer in the workflow.
Stage "summarise" moved from position 2 to position 1 in definition order.

Checking specific runs​

shadowRuns takes run ids and checks each one in detail, including the config and output each stage actually recorded:

import { shadowRuns } from "@bratsos/workflow-engine/testing";

const report = await shadowRuns({
persistence,
candidates: [invoiceWorkflowV2],
runIds: recentRunIds, // however you choose them
stepLedger, // optional
blobStore, // optional
});

for (const run of report.incompatible) {
console.log(run.workflowRunId, run.issues.map((i) => i.message));
}

Per run it reports:

IssueMeaning
WORKFLOW_MISSINGthe candidate build defines no workflow with this run's id
STAGE_REMOVEDa stage this run has a record for is gone
EXECUTION_GROUP_CHANGEDa recorded stage moved to a different parallel group
STAGE_ORDER_CHANGEDa recorded stage moved in definition order
STAGE_INSERTED_BEFORE_CURSORthe candidate adds a stage at or before a group this run already passed, so this run would never execute it
CONFIG_REJECTEDthe config this run stored for a stage fails the candidate's configSchema
OUTPUT_REJECTEDa completed stage's recorded output fails the candidate's outputSchema
STEP_LEDGER_ORPHANEDa removed stage still holds durable step records
SNAPSHOT_DRIFTthe pinned and candidate snapshots differ in a way none of the above covered

A stage appended after the point a run has reached is not a finding. That run simply runs the new stage when it gets there, which is the whole point of adding one.

Passing stepLedger also fills in recordedSteps — the durable step ids each stage actually wrote — which makes a failure diagnosable rather than just red.

Passing blobStore lets output validation see outputs that were externalised to the blob store rather than stored inline. Without it, only inline outputs are checked; a blob that has aged out is skipped rather than reported as a failure.

What shadowing cannot tell you​

Shadowing verifies the structural contract and the recorded configs and outputs. It cannot verify that a stage body will emit the same ctx.step keys, because step keys are produced by running the code — no static check can know them. Renaming ctx.step.run("fetch", …) to ctx.step.run("download", …) does not change the definition version and shadowing will not catch it; a resumed run simply re-executes that step.

Treat shadowing as the guard against a pipeline change that strands runs, not as a proof that a stage body is unchanged.