Skip to content

init rate rollouts #459

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe("DeploymentDenyRule", () => {
tag: "v1.0.0",
config: {},
metadata: {},
createdAt: new Date("2023-01-01T12:00:00Z"),
},
variables: {},
},
Expand All @@ -35,6 +36,7 @@ describe("DeploymentDenyRule", () => {
tag: "v1.1.0",
config: {},
metadata: {},
createdAt: new Date("2023-01-02T12:00:00Z"),
},
variables: {},
},
Expand Down
101 changes: 101 additions & 0 deletions packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { addMinutes, addSeconds } from "date-fns";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { ResolvedRelease } from "../../types.js";
import { Releases } from "../../releases.js";
import { RateRolloutRule } from "../rate-rollout-rule.js";

describe("RateRolloutRule", () => {
const mockDeploymentContext = {
desiredReleaseId: null,
deployment: {
id: "deployment-1",
name: "Test Deployment",
},
environment: {
id: "env-1",
name: "Test Environment",
},
resource: {
id: "resource-1",
name: "Test Resource",
},
};

// Set a fixed base date for testing
const baseDate = new Date("2025-01-01T00:00:00Z");

beforeEach(() => vi.resetAllMocks());

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

const mockRelease: ResolvedRelease = {
id: "1",
createdAt: baseDate,
version: {
id: "1",
tag: "1",
config: {},
metadata: {},
createdAt: baseDate,
},
variables: {},
};

it("should allow a release if their rollout period is complete", () => {
const rule = new RateRolloutRule({ rolloutDurationSeconds: 600 });
const now = addMinutes(baseDate, 10);
vi.spyOn(rule as any, "getCurrentTime").mockReturnValue(now);

vi.spyOn(rule as any, "getHashValue").mockReturnValue(100);

const result = rule.filter(
mockDeploymentContext,
new Releases([mockRelease]),
);

expect(result.allowedReleases.getAll().length).toBe(1);
expect(result.rejectionReasons).toEqual(new Map());
});

it("should allow a release if the hash is less than or equal to the rollout percentage", () => {
const rule = new RateRolloutRule({ rolloutDurationSeconds: 600 });
const now = addSeconds(baseDate, 300);
vi.spyOn(rule as any, "getCurrentTime").mockReturnValue(now);

vi.spyOn(rule as any, "getHashValue").mockReturnValue(50);

const result = rule.filter(
mockDeploymentContext,
new Releases([mockRelease]),
);

expect(result.allowedReleases.getAll().length).toBe(1);
expect(result.rejectionReasons).toEqual(new Map());
});

it("should reject a release if the hash is greater than the rollout percentage", () => {
const rolloutDurationSeconds = 600;
const nowSecondsAfterBase = 300;
const rule = new RateRolloutRule({ rolloutDurationSeconds });
const now = addSeconds(baseDate, nowSecondsAfterBase);
vi.spyOn(rule as any, "getCurrentTime").mockReturnValue(now);

vi.spyOn(rule as any, "getHashValue").mockReturnValue(51);

const result = rule.filter(
mockDeploymentContext,
new Releases([mockRelease]),
);

expect(result.allowedReleases.getAll().length).toBe(0);
const expectedRejectionReason = `Release denied due to rate-based rollout restrictions (${Math.round(
(nowSecondsAfterBase / rolloutDurationSeconds) * 100,
)}% complete, eligible in ~5m)`;
expect(result.rejectionReasons).toEqual(
new Map([[mockRelease.id, expectedRejectionReason]]),
);
});
});
1 change: 1 addition & 0 deletions packages/rule-engine/src/rules/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./deployment-deny-rule.js";
export * from "./rate-rollout-rule.js";
112 changes: 112 additions & 0 deletions packages/rule-engine/src/rules/rate-rollout-rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { differenceInSeconds } from "date-fns";

import type { Releases } from "../releases.js";
import type {
DeploymentResourceContext,
DeploymentResourceRule,
DeploymentResourceRuleResult,
ResolvedRelease,
} from "../types.js";

export interface RateRolloutRuleOptions {
/**
* Duration in seconds over which the rollout should occur
*/
rolloutDurationSeconds: number;

/**
* Custom reason to return when a release is denied due to rate limits
*/
denyReason?: string;
}

export class RateRolloutRule implements DeploymentResourceRule {
public readonly name = "RateRolloutRule";
private rolloutDurationSeconds: number;
private denyReason: string;

constructor({
rolloutDurationSeconds,
denyReason = "Release denied due to rate-based rollout restrictions",
}: RateRolloutRuleOptions) {
this.rolloutDurationSeconds = rolloutDurationSeconds;
this.denyReason = denyReason;
}

// For testing: allow injecting a custom "now" timestamp
protected getCurrentTime(): Date {
return new Date();
}

filter(
_: DeploymentResourceContext,
releases: Releases,
): DeploymentResourceRuleResult {
const now = this.getCurrentTime();

const rejectionReasons = new Map<string, string>();
const allowedReleases = releases.filter((release) => {
// Calculate how much time has passed since the release was created
const versionCreatedAt = this.getVersionCreatedAt(release);
const versionAge = differenceInSeconds(now, versionCreatedAt);

// Calculate what percentage of the rollout period has elapsed
const rolloutPercentage = Math.min(
(versionAge / this.rolloutDurationSeconds) * 100,
100,
);

// Generate a deterministic value based on the release ID
// Using a simple hash function to get a value between 0-100
const releaseValue = this.getHashValue(release.version.id);
// If the release's hash value is less than the rollout percentage,
// it's allowed to be deployed
if (releaseValue <= rolloutPercentage) {
return true;
}
// Otherwise, it's rejected with a reason
const remainingTimeSeconds = Math.max(
0,
this.rolloutDurationSeconds - versionAge,
);
const remainingTimeHours = Math.floor(remainingTimeSeconds / 3600);
const remainingTimeMinutes = Math.floor(
(remainingTimeSeconds % 3600) / 60,
);

const timeDisplay =
remainingTimeHours > 0
? `~${remainingTimeHours}h ${remainingTimeMinutes}m`
: `~${remainingTimeMinutes}m`;

rejectionReasons.set(
release.id,
`${this.denyReason} (${Math.floor(rolloutPercentage)}% complete, eligible in ${timeDisplay})`,
);
return false;
});

return { allowedReleases, rejectionReasons };
}

/**
* Get the creation date of a release, preferring version.createdAt if available
*/
private getVersionCreatedAt(release: ResolvedRelease): Date {
return release.version.createdAt;
}

/**
* Generate a deterministic value between 0-100 based on the release ID
* This ensures releases are consistently evaluated
*/
private getHashValue(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash << 5) - hash + id.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
// Normalize to 0-100 range
return Math.abs(hash % 101);
}
}
1 change: 1 addition & 0 deletions packages/rule-engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type ResolvedRelease = {
tag: string;
config: Record<string, any>;
metadata: Record<string, string>;
createdAt: Date;
};
variables: Record<string, unknown>;
};
Expand Down
Loading