Roll out a workflow rewrite
Canary a rewritten multi-step workflow on a small share of runs, score both paths, and ramp up safely.
A rewrite rarely changes one step. It changes how several steps work together. group.experiment() can hold a whole workflow path in each variant, so you can send one run in a hundred through the rewrite, score both paths the same way, and ramp up once the results hold.
1. Put each path in a variant
Each callback runs a complete path. Only the selected path runs; the other callback is skipped:
import { experiment } from "inngest";
import { inngest } from "./client";
export default inngest.createFunction(
{ id: "generate-invoice", triggers: { event: "billing/invoice.requested" } },
async ({ event, step, group, runId }) => {
const { result: invoice, experimentRef } = await group.experiment(
"invoice-engine",
{
variants: {
current: async () => {
const invoice = await step.run("generate-current", () =>
generateInvoiceV1(event.data)
);
await step.run("send-current", () => sendInvoice(invoice));
return invoice;
},
rewrite: async () => {
const draft = await step.run("draft-rewrite", () =>
draftInvoiceV2(event.data)
);
const invoice = await step.run("price-rewrite", () =>
applyPricingV2(draft)
);
await step.run("send-rewrite", () => sendInvoice(invoice));
return invoice;
},
},
select: experiment.weighted({ current: 99, rewrite: 1 }),
}
);
// Save what a later outcome needs to find this run and variant.
await step.run("save-invoice", () =>
db.invoices.insert({ id: invoice.id, runId, experimentRef })
);
await inngest.score.experiment({
name: "invoice-valid",
value: validateInvoice(invoice),
experiment: experimentRef,
});
return invoice;
}
);
Weights are relative: 99:1 sends about one new run in a hundred through the rewrite. Put every side effect, such as sending the invoice, inside a step so a replay doesn't repeat it.
2. Score the real outcome later
A valid invoice is useful, but payment or a customer correction shows whether the rewrite worked. When your payment webhook fires, load the saved values and score the original run:
export const scorePayment = inngest.createFunction(
{ id: "score-invoice-paid", triggers: { event: "billing/invoice.paid" } },
async ({ event, step }) => {
const saved = await step.run("load-invoice", () =>
db.invoices.get(event.data.invoiceId)
);
await inngest.score.experiment({
name: "invoice-paid",
value: true,
experiment: saved.experimentRef,
runId: saved.runId,
});
}
);
Passing the original runId places the score under the experiment. Without it, the score attaches to the score-invoice-paid run.
3. Watch, then ramp
While the rewrite runs on a small share, compare both variants on invoice-valid and invoice-paid, and check errors and latency in the traces. A 1% canary proves the new path runs; it may take a while to collect enough outcomes to show which path is better.
When the rewrite holds up, deploy new weights in stages: { current: 90, rewrite: 10 }, then 50:50, then 0:100. Runs that already selected a variant keep it on retries and replays; new runs use the new weights. If results worsen, set the rewrite's weight to 0, or read the split from a flag with experiment.custom() so you can roll back without a deploy.
When the old path is no longer needed, pin the winner with experiment.fixed("rewrite") or remove the experiment and keep only the rewrite's steps.
Next steps
- Experiments covers assignment strategies and replay behavior.
- Read results and roll out explains how much evidence to collect before you ramp.
- Manage eval costs explains the extra selection step and scores.