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
3 changes: 2 additions & 1 deletion src/release-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export interface ReleaseArtifactInput {
compatibleSkus: string[];
}

export const DEFAULT_ROLLOUT_PERCENTAGE = 10;
/** A new release is registered but served to no device until an operator raises it. */
export const DEFAULT_ROLLOUT_PERCENTAGE = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep zero-percent releases from shadowing active rollouts

When an older release is still rolling out (for example, v2 at 50%), syncing v3 with this new 0% default makes v3 the result of getLatestRelease; Retrieve rejects it for every device and getDefaultRelease only considers 100% rows, so v2 immediately stops being offered and clients fall back to v1. Registering an inactive release therefore interrupts any in-progress rollout; release selection should ignore 0% releases or retain the previous staged candidate until the new release is raised.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2c64c11: getLatestRelease ignores rows at 0%, so a registered but unreleased version does not displace an older release mid-rollout. Test added: 2.0.0 @100, 2.1.0 @50, 2.2.0 @0; bucket 8 still gets 2.1.0, bucket 95 gets 2.0.0.


export type ReleaseOutcome =
| "created"
Expand Down
10 changes: 8 additions & 2 deletions src/releases.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Request, Response } from "express";
import type { Prisma } from "@prisma/client";
import { prisma } from "./db";
import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "./errors";
import type { ReleaseSyncRunner } from "./release-sync";
Expand Down Expand Up @@ -468,17 +469,22 @@ async function getDefaultRelease(prefix: string, sku: string): Promise<DbRelease
return latestDefaultRelease;
}

/**
* Newest release that is rolling out at all. A row at 0% is registered but
* not yet released, so it must not displace an older release mid-rollout.
*/
async function getLatestRelease(prefix: string, sku: string): Promise<DbRelease> {
return getReleaseByRange(prefix, sku, "*");
return getReleaseByRange(prefix, sku, "*", { rolloutPercentage: { gt: 0 } });
}

async function getReleaseByRange(
prefix: string,
sku: string,
range: string,
where: Prisma.ReleaseWhereInput = {},
): Promise<DbRelease> {
const releases = await prisma.release.findMany({
where: { type: prefix },
where: { type: prefix, ...where },
select: compatibleReleaseSelect(sku),
});

Expand Down
15 changes: 15 additions & 0 deletions test/releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,21 @@ describe("Retrieve handler", () => {
});
});

it("keeps an in-progress rollout when a newer release is registered at 0%", async () => {
// early-adopter hashes to bucket 8, late-adopter to bucket 95.
await createDbReleasePair("2.0.0", 100);
await createDbReleasePair("2.1.0", 50);
await createDbReleasePair("2.2.0", 0);

const inBucket = createMockResponse();
await Retrieve(createMockRequest({ deviceId: "early-adopter" }), inBucket);
expect(jsonBody(inBucket)).toMatchObject({ appVersion: "2.1.0", systemVersion: "2.1.0" });

const outOfBucket = createMockResponse();
await Retrieve(createMockRequest({ deviceId: "late-adopter" }), outOfBucket);
expect(jsonBody(outOfBucket)).toMatchObject({ appVersion: "2.0.0", systemVersion: "2.0.0" });
});

it("applies app and system rollout independently", async () => {
await createDbReleasePair("2.4.0", 100);
await createDbRelease("app", "2.5.0", 100);
Expand Down
10 changes: 5 additions & 5 deletions test/sync-releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe("syncReleases", () => {
expect(artifacts).toEqual([]);
});

it("creates new releases at 10% with their S3 artifacts and skips already-synced versions", async () => {
it("creates new releases at 0% with their S3 artifacts and skips already-synced versions", async () => {
const version = "9.9.4";

// Pre-existing system row simulates a release the migration (or a prior
Expand Down Expand Up @@ -249,8 +249,8 @@ describe("syncReleases", () => {
where: { version_type: { version: "10.0.0-beta.1", type: "app" } },
});

// App release is new — created at 10% rollout with a single legacy-compatible artifact.
expect(appRelease.rolloutPercentage).toBe(10);
// App release is new — created at 0% rollout with a single legacy-compatible artifact.
expect(appRelease.rolloutPercentage).toBe(0);
expect(appRelease.artifacts).toEqual([
expect.objectContaining({
url: `https://cdn.test.com/app/${version}/jetkvm_app`,
Expand Down Expand Up @@ -480,7 +480,7 @@ describe("scheduleReleaseSync", () => {
const release = await testPrisma.release.findUnique({
where: { version_type: { version, type: "app" } },
});
expect(release?.rolloutPercentage).toBe(10);
expect(release?.rolloutPercentage).toBe(0);
});
});

Expand Down Expand Up @@ -551,7 +551,7 @@ describe("Sync handler", () => {
await testPrisma.release.findUnique({
where: { version_type: { version: FRESH_VERSION, type: "app" } },
}),
).toMatchObject({ rolloutPercentage: 10 });
).toMatchObject({ rolloutPercentage: 0 });
expect(
await testPrisma.release.findUnique({
where: { version_type: { version: OTHER_FRESH_VERSION, type: "app" } },
Expand Down
Loading