fix: select rollback artifact pairs atomically

This commit is contained in:
DongHyeonka
2026-08-02 04:03:14 +09:00
parent 172a26b8bd
commit 2c3cab2518
2 changed files with 119 additions and 6 deletions
+73 -5
View File
@@ -292,13 +292,12 @@ export async function verifyRollbackReleaseCoherence(
) {
const readArtifact = options.readArtifact ?? readJsonArtifact;
const paths = options.paths ?? DEFAULT_ROLLBACK_PATHS;
const release = await releaseManifest(readArtifact, paths);
const runtimeValue = await readPrimaryOrFallback(
const artifactPair = await selectRollbackArtifactPair(
readArtifact,
paths.primaryRuntime,
paths.fallbackRuntime,
paths,
);
const runtimeArtifact = parseRuntimeConfigArtifact(runtimeValue);
const release = parseReleaseArtifact(artifactPair.release);
const runtimeArtifact = parseRuntimeConfigArtifact(artifactPair.runtime);
const runtime = {
...runtimeArtifact,
BUILD_ID: requireIdentity(runtimeArtifact.BUILD_ID, "runtime BUILD_ID"),
@@ -377,6 +376,75 @@ function requireIdentity(value: string | undefined, label: string): string {
return value;
}
async function selectRollbackArtifactPair(
readArtifact: JsonArtifactReader,
paths: RollbackArtifactPaths,
): Promise<Readonly<{ release: unknown; runtime: unknown }>> {
const primary = await readArtifactPair(
readArtifact,
paths.primaryRelease,
paths.primaryRuntime,
);
if (
primary.release.status === "fulfilled" &&
primary.runtime.status === "fulfilled"
) {
return Object.freeze({
release: primary.release.value,
runtime: primary.runtime.value,
});
}
const releaseMissing =
primary.release.status === "rejected" &&
hasErrorCode(primary.release.reason, "ENOENT");
const runtimeMissing =
primary.runtime.status === "rejected" &&
hasErrorCode(primary.runtime.reason, "ENOENT");
if (releaseMissing && runtimeMissing) {
const fallback = await readArtifactPair(
readArtifact,
paths.fallbackRelease,
paths.fallbackRuntime,
);
if (fallback.release.status === "rejected") {
throw fallback.release.reason;
}
if (fallback.runtime.status === "rejected") {
throw fallback.runtime.reason;
}
return Object.freeze({
release: fallback.release.value,
runtime: fallback.runtime.value,
});
}
if (primary.release.status === "rejected" && !releaseMissing) {
throw primary.release.reason;
}
if (primary.runtime.status === "rejected" && !runtimeMissing) {
throw primary.runtime.reason;
}
throw new Error("primary rollback artifact pair is incomplete");
}
async function readArtifactPair(
readArtifact: JsonArtifactReader,
releasePath: string,
runtimePath: string,
): Promise<
Readonly<{
release: PromiseSettledResult<unknown>;
runtime: PromiseSettledResult<unknown>;
}>
> {
const [release, runtime] = await Promise.allSettled([
Promise.resolve().then(() => readArtifact(releasePath)),
Promise.resolve().then(() => readArtifact(runtimePath)),
]);
return Object.freeze({ release, runtime });
}
async function readPrimaryOrFallback(
readArtifact: JsonArtifactReader,
primary: string,
+46 -1
View File
@@ -100,8 +100,10 @@ async function releaseV2With(
function artifactReader(
entries: Readonly<Record<string, unknown | Error>>,
reads: string[] = [],
): (path: string) => Promise<unknown> {
return async (path) => {
reads.push(path);
const value = entries[path];
if (value === undefined) {
throw Object.assign(new Error(`missing ${path}`), { code: "ENOENT" });
@@ -315,11 +317,54 @@ describe("release coherence", () => {
}),
).resolves.toMatchObject({ coherence: { compatible: false } });
const releaseOnlyMissingReads: string[] = [];
await expect(
verifyRollbackReleaseCoherence({
readArtifact: artifactReader(validPublic),
readArtifact: artifactReader(
{
"dist/config.json": runtimeV2,
...validPublic,
},
releaseOnlyMissingReads,
),
contractPackages,
}),
).rejects.toThrow("primary rollback artifact pair");
expect(releaseOnlyMissingReads).toEqual([
"dist/release-manifest.json",
"dist/config.json",
]);
const runtimeOnlyMissingReads: string[] = [];
await expect(
verifyRollbackReleaseCoherence({
readArtifact: artifactReader(
{
"dist/release-manifest.json": exactV2,
...validPublic,
},
runtimeOnlyMissingReads,
),
contractPackages,
}),
).rejects.toThrow("primary rollback artifact pair");
expect(runtimeOnlyMissingReads).toEqual([
"dist/release-manifest.json",
"dist/config.json",
]);
const missingPairReads: string[] = [];
await expect(
verifyRollbackReleaseCoherence({
readArtifact: artifactReader(validPublic, missingPairReads),
contractPackages,
}),
).resolves.toMatchObject({ coherence: { compatible: true } });
expect(missingPairReads).toEqual([
"dist/release-manifest.json",
"dist/config.json",
"public/release-manifest.json",
"public/config.json",
]);
});
});