From 8af9807401c88acf8e936830180c795819a5633f Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 6 Apr 2025 15:23:02 -0400 Subject: [PATCH 1/3] init rate rollouts --- .../rules/__tests__/rate-rollout-rule.test.ts | 160 ++++++++++++++++++ packages/rule-engine/src/rules/index.ts | 1 + .../src/rules/rate-rollout-rule.ts | 113 +++++++++++++ packages/rule-engine/src/types.ts | 1 + 4 files changed, 275 insertions(+) create mode 100644 packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts create mode 100644 packages/rule-engine/src/rules/rate-rollout-rule.ts diff --git a/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts b/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts new file mode 100644 index 000000000..3239ee233 --- /dev/null +++ b/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { 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"); + + // Create a spy on Date constructor + const dateSpy = vi.spyOn(global, "Date"); + + beforeEach(() => { + // Reset the mock before each test + vi.resetAllMocks(); + // Mock the Date constructor to return a fixed date + dateSpy.mockImplementation(() => baseDate); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createMockReleases = (releaseTimes: number[]): Releases => { + const releases = releaseTimes.map((minutesAgo, index) => { + const createdAt = new Date(baseDate.getTime() - minutesAgo * 60 * 1000); + return { + id: `release-${index}`, + createdAt, + version: { + id: `version-${index}`, + tag: `v0.${index}.0`, + config: {}, + metadata: {}, + createdAt, + }, + variables: {}, + } as ResolvedRelease; + }); + + return new Releases(releases); + }; + + it("should allow all releases if their rollout period is complete", () => { + // Create releases that were created a long time ago + const mockReleases = createMockReleases([1000, 5000, 7000]); + + // Create rule with a short 10-minute rollout period + const rule = new RateRolloutRule({ rolloutDurationSeconds: 600 }); + + // Override getHashValue to return values that will be included + vi.spyOn(rule as any, "getHashValue").mockImplementation((id: string) => { + // Return values that will be <= 100% rollout percentage + return parseInt(id.split('-')[1]) * 25; // 0, 25, 50 + }); + + const result = rule.filter(mockDeploymentContext, mockReleases); + + // All releases should be allowed since they were created long ago + expect(result.allowedReleases.getAll().length).toBe(mockReleases.length); + expect(result.rejectionReasons).toBeUndefined(); + }); + + it("should partially roll out releases based on elapsed time", () => { + // Mock the Date constructor to return a fixed "now" time + const now = new Date("2025-01-01T01:00:00Z"); // 1 hour from base + dateSpy.mockImplementation(() => now); + + // Create a rule with a 2-hour rollout period + const rule = new RateRolloutRule({ + rolloutDurationSeconds: 7200, // 2 hours + }); + + // Create test release instance with getCurrentTime spy + const getCurrentTimeSpy = vi.spyOn(rule as any, "getCurrentTime"); + + // Create releases at different times + const releases = createMockReleases([ + 30, // 30 minutes ago - 25% through rollout + 60, // 60 minutes ago - 50% through rollout + 90, // 90 minutes ago - 75% through rollout + 120, // 120 minutes ago - 100% through rollout + ]); + + // Mock hash values to make testing deterministic + vi.spyOn(rule as any, "getHashValue").mockImplementation((id: string) => { + const idNum = parseInt(id.split('-')[1]); + // release-0: 30, release-1: 40, release-2: 70, release-3: 90 + return (idNum + 1) * 30 - 10; + }); + + const result = rule.filter(mockDeploymentContext, releases); + + // Verify getCurrentTime was called + expect(getCurrentTimeSpy).toHaveBeenCalled(); + + // release-3 (120 mins ago) should be allowed (100% rollout with hash 90) + expect(result.allowedReleases.find(r => r.id === "release-3")).toBeDefined(); + + // release-2 (90 mins ago) should be allowed (75% rollout with hash 70) + expect(result.allowedReleases.find(r => r.id === "release-2")).toBeDefined(); + + // release-1 (60 mins ago) should be allowed (50% rollout with hash 40) + expect(result.allowedReleases.find(r => r.id === "release-1")).toBeDefined(); + + // release-0 (30 mins ago) should be rejected (25% rollout with hash 30) + expect(result.allowedReleases.find(r => r.id === "release-0")).toBeUndefined(); + + // Verify rejection reasons exist for denied releases + expect(result.rejectionReasons).toBeDefined(); + expect(result.rejectionReasons?.get("release-0")).toBeDefined(); + }); + + it("should include remaining time in rejection reason", () => { + // Mock the Date constructor to return a fixed "now" time + const now = new Date("2025-01-01T00:30:00Z"); // 30 minutes from base + dateSpy.mockImplementation(() => now); + + // Create a rule with a 2-hour rollout period + const rule = new RateRolloutRule({ + rolloutDurationSeconds: 7200, // 2 hours + }); + + // Create a very recent release (10 minutes ago - only ~8% through rollout) + const releases = createMockReleases([10]); + + // Force the release to be denied by making getHashValue return 100 + vi.spyOn(rule as any, "getHashValue").mockReturnValue(100); + + const result = rule.filter(mockDeploymentContext, releases); + + // The release should be denied + expect(result.allowedReleases.length).toBe(0); + + // Check that the rejection reason includes the remaining time + const rejectionReason = result.rejectionReasons?.get("release-0"); + expect(rejectionReason).toBeDefined(); + + // Should mention the percentage and remaining time + expect(rejectionReason).toContain("8% complete"); + expect(rejectionReason).toMatch(/eligible in ~1h \d+m/); + }); +}); \ No newline at end of file diff --git a/packages/rule-engine/src/rules/index.ts b/packages/rule-engine/src/rules/index.ts index 992f6e43e..9c950ce7c 100644 --- a/packages/rule-engine/src/rules/index.ts +++ b/packages/rule-engine/src/rules/index.ts @@ -1 +1,2 @@ export * from "./deployment-deny-rule.js"; +export * from "./rate-rollout-rule.js"; diff --git a/packages/rule-engine/src/rules/rate-rollout-rule.ts b/packages/rule-engine/src/rules/rate-rollout-rule.ts new file mode 100644 index 000000000..c2a3b1d55 --- /dev/null +++ b/packages/rule-engine/src/rules/rate-rollout-rule.ts @@ -0,0 +1,113 @@ +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(); + const allowedReleases = releases.filter((release) => { + // Calculate how much time has passed since the release was created + const releaseCreatedAt = this.getVersionCreatedAt(release); + const releaseAge = differenceInSeconds(now, releaseCreatedAt); + + // Calculate what percentage of the rollout period has elapsed + const rolloutPercentage = Math.min( + (releaseAge / 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 - releaseAge, + ); + 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); + } +} diff --git a/packages/rule-engine/src/types.ts b/packages/rule-engine/src/types.ts index b6268f34c..24389466b 100644 --- a/packages/rule-engine/src/types.ts +++ b/packages/rule-engine/src/types.ts @@ -12,6 +12,7 @@ export type ResolvedRelease = { tag: string; config: Record; metadata: Record; + createdAt: Date; }; variables: Record; }; From 3ca84c62c270fa205f6c1069411d75fc44006b06 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 6 Apr 2025 18:31:33 -0400 Subject: [PATCH 2/3] fix lint --- .../__tests__/deployment-deny-rule.test.ts | 2 + .../rules/__tests__/rate-rollout-rule.test.ts | 91 +++++++++++-------- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts b/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts index 3882db26d..31cac1bc9 100644 --- a/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts +++ b/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts @@ -24,6 +24,7 @@ describe("DeploymentDenyRule", () => { tag: "v1.0.0", config: {}, metadata: {}, + createdAt: new Date("2023-01-01T12:00:00Z"), }, variables: {}, }, @@ -35,6 +36,7 @@ describe("DeploymentDenyRule", () => { tag: "v1.1.0", config: {}, metadata: {}, + createdAt: new Date("2023-01-02T12:00:00Z"), }, variables: {}, }, diff --git a/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts b/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts index 3239ee233..b97e2216d 100644 --- a/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts +++ b/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ResolvedRelease } from "../../types.js"; +import type { ResolvedRelease } from "../../types.js"; import { Releases } from "../../releases.js"; import { RateRolloutRule } from "../rate-rollout-rule.js"; @@ -23,7 +23,7 @@ describe("RateRolloutRule", () => { // Set a fixed base date for testing const baseDate = new Date("2025-01-01T00:00:00Z"); - + // Create a spy on Date constructor const dateSpy = vi.spyOn(global, "Date"); @@ -61,18 +61,23 @@ describe("RateRolloutRule", () => { it("should allow all releases if their rollout period is complete", () => { // Create releases that were created a long time ago const mockReleases = createMockReleases([1000, 5000, 7000]); - + // Create rule with a short 10-minute rollout period const rule = new RateRolloutRule({ rolloutDurationSeconds: 600 }); - + // Override getHashValue to return values that will be included - vi.spyOn(rule as any, "getHashValue").mockImplementation((id: string) => { + vi.spyOn(rule as any, "getHashValue").mockImplementation(function ( + this: unknown, + ...args: unknown[] + ) { + const id = args[0] as string; + const idNum = parseInt(id.split("-")[1] ?? "0"); // Return values that will be <= 100% rollout percentage - return parseInt(id.split('-')[1]) * 25; // 0, 25, 50 + return idNum * 25; // 0, 25, 50 }); - + const result = rule.filter(mockDeploymentContext, mockReleases); - + // All releases should be allowed since they were created long ago expect(result.allowedReleases.getAll().length).toBe(mockReleases.length); expect(result.rejectionReasons).toBeUndefined(); @@ -82,47 +87,59 @@ describe("RateRolloutRule", () => { // Mock the Date constructor to return a fixed "now" time const now = new Date("2025-01-01T01:00:00Z"); // 1 hour from base dateSpy.mockImplementation(() => now); - + // Create a rule with a 2-hour rollout period const rule = new RateRolloutRule({ rolloutDurationSeconds: 7200, // 2 hours }); - + // Create test release instance with getCurrentTime spy const getCurrentTimeSpy = vi.spyOn(rule as any, "getCurrentTime"); - + // Create releases at different times const releases = createMockReleases([ - 30, // 30 minutes ago - 25% through rollout - 60, // 60 minutes ago - 50% through rollout - 90, // 90 minutes ago - 75% through rollout - 120, // 120 minutes ago - 100% through rollout + 30, // 30 minutes ago - 25% through rollout + 60, // 60 minutes ago - 50% through rollout + 90, // 90 minutes ago - 75% through rollout + 120, // 120 minutes ago - 100% through rollout ]); - + // Mock hash values to make testing deterministic - vi.spyOn(rule as any, "getHashValue").mockImplementation((id: string) => { - const idNum = parseInt(id.split('-')[1]); + vi.spyOn(rule as any, "getHashValue").mockImplementation(function ( + this: unknown, + ...args: unknown[] + ) { + const id = args[0] as string; + const idNum = parseInt(id.split("-")[1] ?? "0"); // release-0: 30, release-1: 40, release-2: 70, release-3: 90 return (idNum + 1) * 30 - 10; }); - + const result = rule.filter(mockDeploymentContext, releases); - + // Verify getCurrentTime was called expect(getCurrentTimeSpy).toHaveBeenCalled(); - + // release-3 (120 mins ago) should be allowed (100% rollout with hash 90) - expect(result.allowedReleases.find(r => r.id === "release-3")).toBeDefined(); - + expect( + result.allowedReleases.find((r) => r.id === "release-3"), + ).toBeDefined(); + // release-2 (90 mins ago) should be allowed (75% rollout with hash 70) - expect(result.allowedReleases.find(r => r.id === "release-2")).toBeDefined(); - + expect( + result.allowedReleases.find((r) => r.id === "release-2"), + ).toBeDefined(); + // release-1 (60 mins ago) should be allowed (50% rollout with hash 40) - expect(result.allowedReleases.find(r => r.id === "release-1")).toBeDefined(); - + expect( + result.allowedReleases.find((r) => r.id === "release-1"), + ).toBeDefined(); + // release-0 (30 mins ago) should be rejected (25% rollout with hash 30) - expect(result.allowedReleases.find(r => r.id === "release-0")).toBeUndefined(); - + expect( + result.allowedReleases.find((r) => r.id === "release-0"), + ).toBeUndefined(); + // Verify rejection reasons exist for denied releases expect(result.rejectionReasons).toBeDefined(); expect(result.rejectionReasons?.get("release-0")).toBeDefined(); @@ -132,29 +149,29 @@ describe("RateRolloutRule", () => { // Mock the Date constructor to return a fixed "now" time const now = new Date("2025-01-01T00:30:00Z"); // 30 minutes from base dateSpy.mockImplementation(() => now); - + // Create a rule with a 2-hour rollout period const rule = new RateRolloutRule({ rolloutDurationSeconds: 7200, // 2 hours }); - + // Create a very recent release (10 minutes ago - only ~8% through rollout) const releases = createMockReleases([10]); - + // Force the release to be denied by making getHashValue return 100 vi.spyOn(rule as any, "getHashValue").mockReturnValue(100); - + const result = rule.filter(mockDeploymentContext, releases); - + // The release should be denied expect(result.allowedReleases.length).toBe(0); - + // Check that the rejection reason includes the remaining time const rejectionReason = result.rejectionReasons?.get("release-0"); expect(rejectionReason).toBeDefined(); - + // Should mention the percentage and remaining time expect(rejectionReason).toContain("8% complete"); expect(rejectionReason).toMatch(/eligible in ~1h \d+m/); }); -}); \ No newline at end of file +}); From 3839294cce043c420a24f02aa4ed77e293ff1036 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Sun, 6 Apr 2025 23:17:27 -0700 Subject: [PATCH 3/3] rate rollout tests --- .../rules/__tests__/rate-rollout-rule.test.ts | 190 ++++++------------ .../src/rules/rate-rollout-rule.ts | 9 +- 2 files changed, 61 insertions(+), 138 deletions(-) diff --git a/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts b/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts index b97e2216d..5d085065b 100644 --- a/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts +++ b/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts @@ -1,3 +1,4 @@ +import { addMinutes, addSeconds } from "date-fns"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ResolvedRelease } from "../../types.js"; @@ -24,154 +25,77 @@ describe("RateRolloutRule", () => { // Set a fixed base date for testing const baseDate = new Date("2025-01-01T00:00:00Z"); - // Create a spy on Date constructor - const dateSpy = vi.spyOn(global, "Date"); - - beforeEach(() => { - // Reset the mock before each test - vi.resetAllMocks(); - // Mock the Date constructor to return a fixed date - dateSpy.mockImplementation(() => baseDate); - }); + beforeEach(() => vi.resetAllMocks()); afterEach(() => { vi.restoreAllMocks(); }); - const createMockReleases = (releaseTimes: number[]): Releases => { - const releases = releaseTimes.map((minutesAgo, index) => { - const createdAt = new Date(baseDate.getTime() - minutesAgo * 60 * 1000); - return { - id: `release-${index}`, - createdAt, - version: { - id: `version-${index}`, - tag: `v0.${index}.0`, - config: {}, - metadata: {}, - createdAt, - }, - variables: {}, - } as ResolvedRelease; - }); - - return new Releases(releases); + const mockRelease: ResolvedRelease = { + id: "1", + createdAt: baseDate, + version: { + id: "1", + tag: "1", + config: {}, + metadata: {}, + createdAt: baseDate, + }, + variables: {}, }; - it("should allow all releases if their rollout period is complete", () => { - // Create releases that were created a long time ago - const mockReleases = createMockReleases([1000, 5000, 7000]); - - // Create rule with a short 10-minute rollout period + 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); - // Override getHashValue to return values that will be included - vi.spyOn(rule as any, "getHashValue").mockImplementation(function ( - this: unknown, - ...args: unknown[] - ) { - const id = args[0] as string; - const idNum = parseInt(id.split("-")[1] ?? "0"); - // Return values that will be <= 100% rollout percentage - return idNum * 25; // 0, 25, 50 - }); - - const result = rule.filter(mockDeploymentContext, mockReleases); - - // All releases should be allowed since they were created long ago - expect(result.allowedReleases.getAll().length).toBe(mockReleases.length); - expect(result.rejectionReasons).toBeUndefined(); - }); - - it("should partially roll out releases based on elapsed time", () => { - // Mock the Date constructor to return a fixed "now" time - const now = new Date("2025-01-01T01:00:00Z"); // 1 hour from base - dateSpy.mockImplementation(() => now); - - // Create a rule with a 2-hour rollout period - const rule = new RateRolloutRule({ - rolloutDurationSeconds: 7200, // 2 hours - }); - - // Create test release instance with getCurrentTime spy - const getCurrentTimeSpy = vi.spyOn(rule as any, "getCurrentTime"); - - // Create releases at different times - const releases = createMockReleases([ - 30, // 30 minutes ago - 25% through rollout - 60, // 60 minutes ago - 50% through rollout - 90, // 90 minutes ago - 75% through rollout - 120, // 120 minutes ago - 100% through rollout - ]); - - // Mock hash values to make testing deterministic - vi.spyOn(rule as any, "getHashValue").mockImplementation(function ( - this: unknown, - ...args: unknown[] - ) { - const id = args[0] as string; - const idNum = parseInt(id.split("-")[1] ?? "0"); - // release-0: 30, release-1: 40, release-2: 70, release-3: 90 - return (idNum + 1) * 30 - 10; - }); - - const result = rule.filter(mockDeploymentContext, releases); - - // Verify getCurrentTime was called - expect(getCurrentTimeSpy).toHaveBeenCalled(); - - // release-3 (120 mins ago) should be allowed (100% rollout with hash 90) - expect( - result.allowedReleases.find((r) => r.id === "release-3"), - ).toBeDefined(); - - // release-2 (90 mins ago) should be allowed (75% rollout with hash 70) - expect( - result.allowedReleases.find((r) => r.id === "release-2"), - ).toBeDefined(); - - // release-1 (60 mins ago) should be allowed (50% rollout with hash 40) - expect( - result.allowedReleases.find((r) => r.id === "release-1"), - ).toBeDefined(); - - // release-0 (30 mins ago) should be rejected (25% rollout with hash 30) - expect( - result.allowedReleases.find((r) => r.id === "release-0"), - ).toBeUndefined(); - - // Verify rejection reasons exist for denied releases - expect(result.rejectionReasons).toBeDefined(); - expect(result.rejectionReasons?.get("release-0")).toBeDefined(); - }); - - it("should include remaining time in rejection reason", () => { - // Mock the Date constructor to return a fixed "now" time - const now = new Date("2025-01-01T00:30:00Z"); // 30 minutes from base - dateSpy.mockImplementation(() => now); + vi.spyOn(rule as any, "getHashValue").mockReturnValue(100); - // Create a rule with a 2-hour rollout period - const rule = new RateRolloutRule({ - rolloutDurationSeconds: 7200, // 2 hours - }); + const result = rule.filter( + mockDeploymentContext, + new Releases([mockRelease]), + ); - // Create a very recent release (10 minutes ago - only ~8% through rollout) - const releases = createMockReleases([10]); + expect(result.allowedReleases.getAll().length).toBe(1); + expect(result.rejectionReasons).toEqual(new Map()); + }); - // Force the release to be denied by making getHashValue return 100 - vi.spyOn(rule as any, "getHashValue").mockReturnValue(100); + 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); - const result = rule.filter(mockDeploymentContext, releases); + vi.spyOn(rule as any, "getHashValue").mockReturnValue(50); - // The release should be denied - expect(result.allowedReleases.length).toBe(0); + const result = rule.filter( + mockDeploymentContext, + new Releases([mockRelease]), + ); - // Check that the rejection reason includes the remaining time - const rejectionReason = result.rejectionReasons?.get("release-0"); - expect(rejectionReason).toBeDefined(); + expect(result.allowedReleases.getAll().length).toBe(1); + expect(result.rejectionReasons).toEqual(new Map()); + }); - // Should mention the percentage and remaining time - expect(rejectionReason).toContain("8% complete"); - expect(rejectionReason).toMatch(/eligible in ~1h \d+m/); + 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]]), + ); }); }); diff --git a/packages/rule-engine/src/rules/rate-rollout-rule.ts b/packages/rule-engine/src/rules/rate-rollout-rule.ts index c2a3b1d55..b4df998e7 100644 --- a/packages/rule-engine/src/rules/rate-rollout-rule.ts +++ b/packages/rule-engine/src/rules/rate-rollout-rule.ts @@ -47,19 +47,18 @@ export class RateRolloutRule implements DeploymentResourceRule { const rejectionReasons = new Map(); const allowedReleases = releases.filter((release) => { // Calculate how much time has passed since the release was created - const releaseCreatedAt = this.getVersionCreatedAt(release); - const releaseAge = differenceInSeconds(now, releaseCreatedAt); + const versionCreatedAt = this.getVersionCreatedAt(release); + const versionAge = differenceInSeconds(now, versionCreatedAt); // Calculate what percentage of the rollout period has elapsed const rolloutPercentage = Math.min( - (releaseAge / this.rolloutDurationSeconds) * 100, + (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) { @@ -68,7 +67,7 @@ export class RateRolloutRule implements DeploymentResourceRule { // Otherwise, it's rejected with a reason const remainingTimeSeconds = Math.max( 0, - this.rolloutDurationSeconds - releaseAge, + this.rolloutDurationSeconds - versionAge, ); const remainingTimeHours = Math.floor(remainingTimeSeconds / 3600); const remainingTimeMinutes = Math.floor(