diff --git a/packages/api/src/router/release-deploy.ts b/packages/api/src/router/release-deploy.ts index 9be9199f3..d2da4019d 100644 --- a/packages/api/src/router/release-deploy.ts +++ b/packages/api/src/router/release-deploy.ts @@ -16,7 +16,7 @@ import { createJobApprovals, createReleaseJobTriggers, dispatchReleaseJobTriggers, - isPassingAllPolicies, + isPassingAllPoliciesExceptNewerThanLastActive, isPassingLockingPolicy, isPassingReleaseStringCheckPolicy, } from "@ctrlplane/job-dispatch"; @@ -91,7 +91,9 @@ export const releaseDeployRouter = createTRPCRouter({ await dispatchReleaseJobTriggers(ctx.db) .releaseTriggers(releaseJobTriggers) .filter( - input.isForcedRelease ? isPassingLockingPolicy : isPassingAllPolicies, + input.isForcedRelease + ? isPassingLockingPolicy + : isPassingAllPoliciesExceptNewerThanLastActive, ) .then(cancelOldReleaseJobTriggersOnJobDispatch) .dispatch(); @@ -160,7 +162,9 @@ export const releaseDeployRouter = createTRPCRouter({ await dispatchReleaseJobTriggers(ctx.db) .releaseTriggers(releaseJobTriggers) .filter( - input.isForcedRelease ? isPassingLockingPolicy : isPassingAllPolicies, + input.isForcedRelease + ? isPassingLockingPolicy + : isPassingAllPoliciesExceptNewerThanLastActive, ) .then(cancelOldReleaseJobTriggersOnJobDispatch) .dispatch(); diff --git a/packages/job-dispatch/src/policies/release-sequencing.ts b/packages/job-dispatch/src/policies/release-sequencing.ts index 4ad9e0e3b..bbfaa31c2 100644 --- a/packages/job-dispatch/src/policies/release-sequencing.ts +++ b/packages/job-dispatch/src/policies/release-sequencing.ts @@ -1,8 +1,11 @@ +import type { Tx } from "@ctrlplane/db"; +import { isAfter } from "date-fns"; import _ from "lodash"; +import { isPresent } from "ts-is-present"; -import { and, eq, inArray, notExists, sql } from "@ctrlplane/db"; +import { and, eq, inArray, ne, notExists, sql } from "@ctrlplane/db"; import * as schema from "@ctrlplane/db/schema"; -import { activeStatus } from "@ctrlplane/validators/jobs"; +import { activeStatus, JobStatus } from "@ctrlplane/validators/jobs"; import type { ReleaseIdPolicyChecker } from "./utils.js"; @@ -65,3 +68,92 @@ export const isPassingNoActiveJobsPolicy: ReleaseIdPolicyChecker = async ( .map((rjt) => rjt.release_job_trigger) .value(); }; + +const latestActiveReleaseSubQuery = (db: Tx) => + db + .select({ + id: schema.release.id, + deploymentId: schema.release.deploymentId, + version: schema.release.version, + createdAt: schema.release.createdAt, + name: schema.release.name, + config: schema.release.config, + environmentId: schema.releaseJobTrigger.environmentId, + rank: sql`ROW_NUMBER() OVER (PARTITION BY ${schema.release.deploymentId}, ${schema.releaseJobTrigger.environmentId} ORDER BY ${schema.release.createdAt} DESC)`.as( + "rank", + ), + }) + .from(schema.release) + .innerJoin( + schema.releaseJobTrigger, + eq(schema.releaseJobTrigger.releaseId, schema.release.id), + ) + .innerJoin(schema.job, eq(schema.releaseJobTrigger.jobId, schema.job.id)) + .where(ne(schema.job.status, JobStatus.Pending)) + .as("active_releases"); + +/** + * This policy checks if the release is newer than the last release that was deployed for a deployment/environment. + * i.e. you can only dispatch a release if every release later than it is pending. + * @param db + * @param releaseJobTriggers + */ +export const isPassingNewerThanLastActiveReleasePolicy: ReleaseIdPolicyChecker = + async (db, releaseJobTriggers) => { + if (releaseJobTriggers.length === 0) return []; + + const activeRelease = latestActiveReleaseSubQuery(db); + + const releaseIds = releaseJobTriggers.map((rjt) => rjt.releaseId); + const releases = await db + .select() + .from(schema.release) + .where(inArray(schema.release.id, releaseIds)); + + const deploymentIds = _.uniq(releases.map((r) => r.deploymentId)); + const deployments = await db + .select() + .from(schema.deployment) + .leftJoin( + activeRelease, + and( + eq(activeRelease.deploymentId, schema.deployment.id), + eq(activeRelease.rank, 1), + ), + ) + .where(inArray(schema.deployment.id, deploymentIds)) + .then((rows) => + _.chain(rows) + .groupBy((r) => r.deployment.id) + .map((r) => ({ + ...r[0]!.deployment, + activeReleases: r.map((r) => r.active_releases).filter(isPresent), + })) + .value(), + ); + + return _.chain(releaseJobTriggers) + .groupBy((rjt) => { + const release = releases.find((r) => r.id === rjt.releaseId); + if (!release) return null; + return [release.deploymentId, rjt.environmentId]; + }) + .filter(isPresent) + .map((triggers) => _.maxBy(triggers, (t) => t.createdAt)!) + .map((t) => { + const release = releases.find((r) => r.id === t.releaseId); + if (!release) return null; + const deployment = deployments.find( + (d) => d.id === release.deploymentId, + ); + if (!deployment) return null; + const activeRelease = deployment.activeReleases.find( + (r) => r.environmentId === t.environmentId, + ); + if (!activeRelease) return t; + if (release.id === activeRelease.id) return t; + return isAfter(release.createdAt, activeRelease.createdAt) ? t : null; + }) + .filter(isPresent) + .value(); + }; diff --git a/packages/job-dispatch/src/policy-checker.ts b/packages/job-dispatch/src/policy-checker.ts index b9e252f0f..a0558f85d 100644 --- a/packages/job-dispatch/src/policy-checker.ts +++ b/packages/job-dispatch/src/policy-checker.ts @@ -8,13 +8,39 @@ import { isPassingConcurrencyPolicy } from "./policies/concurrency-policy.js"; import { isPassingJobRolloutPolicy } from "./policies/gradual-rollout.js"; import { isPassingApprovalPolicy } from "./policies/manual-approval.js"; import { isPassingReleaseDependencyPolicy } from "./policies/release-dependency.js"; -import { isPassingNoActiveJobsPolicy } from "./policies/release-sequencing.js"; +import { + isPassingNewerThanLastActiveReleasePolicy, + isPassingNoActiveJobsPolicy, +} from "./policies/release-sequencing.js"; import { isPassingReleaseWindowPolicy } from "./policies/release-window.js"; import { isPassingCriteriaPolicy } from "./policies/success-rate-criteria-passing.js"; export const isPassingAllPolicies = async ( db: Tx, releaseJobTriggers: schema.ReleaseJobTrigger[], +) => { + if (releaseJobTriggers.length === 0) return []; + const checks: ReleaseIdPolicyChecker[] = [ + isPassingLockingPolicy, + isPassingApprovalPolicy, + isPassingCriteriaPolicy, + isPassingConcurrencyPolicy, + isPassingReleaseDependencyPolicy, + isPassingJobRolloutPolicy, + isPassingNoActiveJobsPolicy, + isPassingNewerThanLastActiveReleasePolicy, + isPassingReleaseWindowPolicy, + ]; + + let passingJobs = releaseJobTriggers; + for (const check of checks) passingJobs = await check(db, passingJobs); + + return passingJobs; +}; + +export const isPassingAllPoliciesExceptNewerThanLastActive = async ( + db: Tx, + releaseJobTriggers: schema.ReleaseJobTrigger[], ) => { if (releaseJobTriggers.length === 0) return []; const checks: ReleaseIdPolicyChecker[] = [ diff --git a/packages/job-dispatch/src/release-sequencing.ts b/packages/job-dispatch/src/release-sequencing.ts index aac742e57..bc321bf99 100644 --- a/packages/job-dispatch/src/release-sequencing.ts +++ b/packages/job-dispatch/src/release-sequencing.ts @@ -1,6 +1,6 @@ import type { Tx } from "@ctrlplane/db"; -import { inArray, sql } from "@ctrlplane/db"; +import { inArray, isNull, sql } from "@ctrlplane/db"; import * as schema from "@ctrlplane/db/schema"; import { JobStatus } from "@ctrlplane/validators/jobs"; @@ -40,7 +40,7 @@ export const cancelOldReleaseJobTriggersOnJobDispatch = async ( inner join ${schema.release} on ${schema.releaseJobTrigger.releaseId} = ${schema.release.id} inner join ${schema.deployment} on ${schema.release.deploymentId} = ${schema.deployment.id} inner join ${schema.environment} on ${schema.releaseJobTrigger.environmentId} = ${schema.environment.id} - inner join ${schema.environmentPolicy} on ${schema.environment.policyId} = ${schema.environmentPolicy.id} + left join ${schema.environmentPolicy} on ${schema.environment.policyId} = ${schema.environmentPolicy.id} inner join (${triggersSubquery}) as triggers on ${schema.deployment.id} = triggers.cancelDeploymentId and ${schema.releaseJobTrigger.environmentId} = triggers.cancelEnvironmentId @@ -49,7 +49,10 @@ export const cancelOldReleaseJobTriggersOnJobDispatch = async ( schema.releaseJobTrigger.id, releaseJobTriggers.map((t) => t.id), )} - and ${schema.environmentPolicy.releaseSequencing} = ${schema.releaseSequencingType.enumValues.at(1)} + and ( + ${schema.environmentPolicy.releaseSequencing} = ${schema.releaseSequencingType.enumValues.at(1)} + or ${isNull(schema.environmentPolicy.releaseSequencing)} + ) `; const jobsToCancel = await db