Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/old-sites-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vercel/flags-core": minor
---

Add progressive rollout outcome
232 changes: 231 additions & 1 deletion packages/vercel-flags-core/src/evaluate.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { evaluate } from './evaluate';
import {
Comparator,
Expand Down Expand Up @@ -2212,4 +2212,234 @@ describe('evaluate', () => {
expect(getTotals([1000, 1000, 1000, 1000], 9)).toEqual(expectedTotals);
});
});

describe('rollouts', () => {
const HOUR = 60 * 60 * 1000;
const startTimestamp = 1_000_000_000_000;

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

const makeRollout = (
overrides: Partial<Packed.RolloutOutcome> = {},
): Packed.RolloutOutcome => ({
type: 'rollout',
base: ['user', 'id'],
startTimestamp,
rollFromVariant: 0,
rollToVariant: 1,
defaultVariant: 0,
slots: [
[0, 6 * HOUR], // 0% for 6h
[50_000, 6 * HOUR], // 50% for 6h
],
...overrides,
});

it('serves rollFromVariant before startTimestamp', () => {
vi.setSystemTime(startTimestamp - 1);
expect(
evaluate({
definition: {
environments: { production: { fallthrough: makeRollout() } },
seed: 7,
variants: [false, true],
},
environment: 'production',
entities: { user: { id: 'uid1' } },
}),
).toEqual({
value: false,
reason: ResolutionReason.FALLTHROUGH,
outcomeType: OutcomeType.ROLLOUT,
});
});

it('serves rollFromVariant at 0% (initial slot)', () => {
vi.setSystemTime(startTimestamp);
expect(
evaluate({
definition: {
environments: { production: { fallthrough: makeRollout() } },
seed: 7,
variants: [false, true],
},
environment: 'production',
entities: { user: { id: 'uid1' } },
}),
).toEqual({
value: false,
reason: ResolutionReason.FALLTHROUGH,
outcomeType: OutcomeType.ROLLOUT,
});
});

it('serves rollToVariant at 100% (final slot)', () => {
vi.setSystemTime(startTimestamp + 12 * HOUR);
expect(
evaluate({
definition: {
environments: { production: { fallthrough: makeRollout() } },
seed: 7,
variants: [false, true],
},
environment: 'production',
entities: { user: { id: 'uid1' } },
}),
).toEqual({
value: true,
reason: ResolutionReason.FALLTHROUGH,
outcomeType: OutcomeType.ROLLOUT,
});
});

it('serves defaultVariant when entity attribute is missing', () => {
vi.setSystemTime(startTimestamp + 12 * HOUR);
expect(
evaluate({
definition: {
environments: { production: { fallthrough: makeRollout() } },
seed: 7,
variants: [false, true],
},
environment: 'production',
entities: {},
}),
).toEqual({
value: false,
reason: ResolutionReason.FALLTHROUGH,
outcomeType: OutcomeType.ROLLOUT,
});
});

it('transitions correctly between slots based on elapsed time', () => {
vi.setSystemTime(startTimestamp + 6 * HOUR);
const rollout = makeRollout();

let trueCount = 0;
for (let i = 0; i < 1000; i++) {
const result = evaluate({
definition: {
environments: { production: { fallthrough: rollout } },
seed: 7,
variants: [false, true],
},
environment: 'production',
entities: { user: { id: `uid${i}` } },
});
if (result.value === true) trueCount++;
}

// At 50%, roughly half should get true (allow some variance)
expect(trueCount).toBeGreaterThan(400);
expect(trueCount).toBeLessThan(600);
});

it('distributes monotonically (users who got rollTo at lower % keep it at higher %)', () => {
const rollout = makeRollout({
slots: [
[10_000, 1 * HOUR], // 10% for 1h
[50_000, 1 * HOUR], // 50% for 1h
],
});

const usersAtTime = (time: number) => {
vi.setSystemTime(time);
const result = new Set<string>();
for (let i = 0; i < 1000; i++) {
const uid = `uid${i}`;
const evalResult = evaluate({
definition: {
environments: { production: { fallthrough: rollout } },
seed: 7,
variants: [false, true],
},
environment: 'production',
entities: { user: { id: uid } },
});
if (evalResult.value === true) result.add(uid);
}
return result;
};

// 10% slot is active from t=0 to t=1h
const at10 = usersAtTime(startTimestamp);
// 50% slot is active from t=1h to t=2h
const at50 = usersAtTime(startTimestamp + 1 * HOUR);
// 100% slot is active from t=2h onwards
const at100 = usersAtTime(startTimestamp + 2 * HOUR);

// Every user in the 10% group must also be in the 50% group
for (const uid of at10) {
expect(at50.has(uid)).toBe(true);
}
// Every user in the 50% group must also be in the 100% group
for (const uid of at50) {
expect(at100.has(uid)).toBe(true);
}
// 100% should include all users
expect(at100.size).toBe(1000);
});

it('goes to 100% after all slots are exhausted', () => {
vi.setSystemTime(startTimestamp + 100 * HOUR);
const rollout = makeRollout({
slots: [
[0, 1 * HOUR], // 0% for 1h
[50_000, 1 * HOUR], // 50% for 1h
],
});

let trueCount = 0;
for (let i = 0; i < 1000; i++) {
const result = evaluate({
definition: {
environments: { production: { fallthrough: rollout } },
seed: 7,
variants: [false, true],
},
environment: 'production',
entities: { user: { id: `uid${i}` } },
});
if (result.value === true) trueCount++;
}

// All users should get rollToVariant
expect(trueCount).toBe(1000);
});

it('works as a rule outcome', () => {
vi.setSystemTime(startTimestamp + 12 * HOUR);
expect(
evaluate({
definition: {
environments: {
production: {
rules: [
{
conditions: [[['user', 'plan'], Comparator.EQ, 'pro']],
outcome: makeRollout(),
},
],
fallthrough: 0,
},
},
seed: 7,
variants: [false, true],
},
environment: 'production',
entities: { user: { id: 'uid1', plan: 'pro' } },
}),
).toEqual({
value: true,
reason: ResolutionReason.RULE_MATCH,
outcomeType: OutcomeType.ROLLOUT,
});
});
});
});
80 changes: 80 additions & 0 deletions packages/vercel-flags-core/src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,86 @@ function handleOutcome<T>(
outcomeType: OutcomeType.SPLIT,
};
}
case 'rollout': {
const lhs = access(outcome.base, params);
const defaultOutcome = getVariant<T>(
params.definition.variants,
outcome.defaultVariant,
);

// serve the default variant if the lhs is not a string
if (typeof lhs !== 'string') {
return { value: defaultOutcome, outcomeType: OutcomeType.ROLLOUT };
}

// Determine active slot based on elapsed time
const now = Date.now();
const elapsed = now - outcome.startTimestamp;

// Before rollout starts or no slots, serve rollFromVariant
if (elapsed < 0 || outcome.slots.length === 0) {
return {
value: getVariant<T>(
params.definition.variants,
outcome.rollFromVariant,
),
outcomeType: OutcomeType.ROLLOUT,
};
}

// Walk slots to find current promille.
// Each slot's durationMs is how long that slot is served before
// moving to the next one. Once all slots are exhausted the
// rollout is complete (100% to rollToVariant).
let cumulativeDuration = 0;
let currentPromille = 0;
let exhausted = true;
for (const [promille, durationMs] of outcome.slots) {
currentPromille = promille;
cumulativeDuration += durationMs;
if (cumulativeDuration > elapsed) {
exhausted = false;
break;
}
}
if (exhausted) currentPromille = 100_000;

// short-circuit common edges
if (currentPromille <= 0) {
return {
value: getVariant<T>(
params.definition.variants,
outcome.rollFromVariant,
),
outcomeType: OutcomeType.ROLLOUT,
};
}
if (currentPromille >= 100_000) {
return {
value: getVariant<T>(
params.definition.variants,
outcome.rollToVariant,
),
outcomeType: OutcomeType.ROLLOUT,
};
}

/** 2^32-1 */
const maxValue = 4_294_967_295;
const value = hashInput(lhs, params.definition.seed);
const threshold = (currentPromille / 100_000) * maxValue;

return {
value:
value < threshold
? getVariant<T>(params.definition.variants, outcome.rollToVariant)
: getVariant<T>(
params.definition.variants,
outcome.rollFromVariant,
),
outcomeType: OutcomeType.ROLLOUT,
};
}
default: {
const { type } = outcome;
exhaustivenessCheck(type);
Expand Down
Loading
Loading