208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { PublicCacheAsset } from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts";
|
|
import { createDefaultPublicCachePolicy } from "../../src/adapters/cache-storage/public-cache-policy.ts";
|
|
import { createPublicResponseCacheAdapter } from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
|
|
import {
|
|
MemoryCacheStorage,
|
|
digestHex,
|
|
immediateLock,
|
|
manifestFor,
|
|
} from "./public-response-cache-fixture.ts";
|
|
|
|
/**
|
|
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
|
|
* the last thing a repair may destroy. A transient marker read failure is not
|
|
* evidence of damage, and a repair that has not yet fetched anything has not
|
|
* yet earned the right to delete what still works.
|
|
*/
|
|
describe("public response cache repair is failure-atomic", () => {
|
|
async function stagedRelease(releaseRegistryId: string) {
|
|
const policy = createDefaultPublicCachePolicy(
|
|
"https://assets.example.test",
|
|
);
|
|
const firstBytes = new Uint8Array([1, 1, 1, 1]);
|
|
const secondBytes = new Uint8Array([2, 2, 2, 2]);
|
|
const assets: readonly PublicCacheAsset[] = [
|
|
{
|
|
absoluteUrl: "https://assets.example.test/first.js",
|
|
expectedByteLength: firstBytes.byteLength,
|
|
expectedContentType: "application/javascript",
|
|
integrity: {
|
|
algorithm: "SHA-256",
|
|
digestHex: await digestHex(firstBytes),
|
|
},
|
|
},
|
|
{
|
|
absoluteUrl: "https://assets.example.test/second.js",
|
|
expectedByteLength: secondBytes.byteLength,
|
|
expectedContentType: "application/javascript",
|
|
integrity: {
|
|
algorithm: "SHA-256",
|
|
digestHex: await digestHex(secondBytes),
|
|
},
|
|
},
|
|
];
|
|
const bodies = new Map<string, Uint8Array>([
|
|
[assets[0]!.absoluteUrl, firstBytes],
|
|
[assets[1]!.absoluteUrl, secondBytes],
|
|
]);
|
|
const cacheStorage = new MemoryCacheStorage();
|
|
const fetchLog: string[] = [];
|
|
let failFrom: string | null = null;
|
|
const adapter = createPublicResponseCacheAdapter({
|
|
cacheStorage: cacheStorage as unknown as CacheStorage,
|
|
crypto: globalThis.crypto,
|
|
mutationLock: immediateLock,
|
|
policy,
|
|
fetcher: async (request: Request) => {
|
|
fetchLog.push(request.url);
|
|
if (failFrom !== null && request.url === failFrom) {
|
|
throw new TypeError("network is down");
|
|
}
|
|
const body = bodies.get(request.url);
|
|
if (!body) throw new TypeError(`unknown asset ${request.url}`);
|
|
return new Response(Uint8Array.from(body), {
|
|
headers: {
|
|
"cache-control": "public",
|
|
"content-type": "application/javascript",
|
|
},
|
|
});
|
|
},
|
|
});
|
|
const manifest = await manifestFor(releaseRegistryId, assets, policy);
|
|
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
|
ok: true,
|
|
});
|
|
expect(
|
|
await adapter.admin.activateRelease(
|
|
manifest.releaseRegistryId,
|
|
manifest.manifestDigestHex,
|
|
),
|
|
).toMatchObject({ ok: true });
|
|
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
|
|
name.includes(releaseRegistryId),
|
|
);
|
|
if (!cacheName) throw new Error("staged cache missing");
|
|
|
|
return {
|
|
adapter,
|
|
assets,
|
|
cacheName,
|
|
cacheStorage,
|
|
fetchLog,
|
|
manifest,
|
|
setFailure(url: string | null) {
|
|
failFrom = url;
|
|
},
|
|
};
|
|
}
|
|
|
|
it("does not delete an active candidate when the marker read fails transiently", async () => {
|
|
const release = await stagedRelease("transient-marker");
|
|
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
|
const realMatch = cache.match.bind(cache);
|
|
let markerReads = 0;
|
|
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
|
|
cache.match = async (request: RequestInfo | URL) => {
|
|
const url =
|
|
request instanceof Request ? request.url : String(request);
|
|
if (!assetUrls.has(url)) {
|
|
markerReads += 1;
|
|
throw new DOMException("Storage is busy", "InvalidStateError");
|
|
}
|
|
return await realMatch(request);
|
|
};
|
|
|
|
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
|
|
|
expect(markerReads).toBeGreaterThan(0);
|
|
expect(restaged.ok).toBe(false);
|
|
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
|
cache.match = realMatch;
|
|
expect(
|
|
await release.adapter.responses.matchActiveExact({
|
|
absoluteUrl: release.assets[0]!.absoluteUrl,
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
});
|
|
|
|
it("keeps every healthy asset when one repair fetch fails", async () => {
|
|
const release = await stagedRelease("partial-repair");
|
|
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
|
// Corrupt only the first asset's stored bytes.
|
|
const corrupted = cache.responses.findIndex(
|
|
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
|
|
);
|
|
expect(corrupted).toBeGreaterThanOrEqual(0);
|
|
cache.responses.splice(corrupted, 1);
|
|
|
|
release.setFailure(release.assets[0]!.absoluteUrl);
|
|
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
|
expect(restaged.ok).toBe(false);
|
|
|
|
// The cache still exists and the healthy asset is still served.
|
|
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
|
expect(
|
|
await release.adapter.responses.matchActiveExact({
|
|
absoluteUrl: release.assets[1]!.absoluteUrl,
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
});
|
|
|
|
it("still removes a candidate this call created when staging fails", async () => {
|
|
const policy = createDefaultPublicCachePolicy(
|
|
"https://assets.example.test",
|
|
);
|
|
const bytes = new Uint8Array([7, 7, 7, 7]);
|
|
const asset: PublicCacheAsset = {
|
|
absoluteUrl: "https://assets.example.test/fresh.js",
|
|
expectedByteLength: bytes.byteLength,
|
|
expectedContentType: "application/javascript",
|
|
integrity: {
|
|
algorithm: "SHA-256",
|
|
digestHex: await digestHex(bytes),
|
|
},
|
|
};
|
|
const cacheStorage = new MemoryCacheStorage();
|
|
const adapter = createPublicResponseCacheAdapter({
|
|
cacheStorage: cacheStorage as unknown as CacheStorage,
|
|
crypto: globalThis.crypto,
|
|
mutationLock: immediateLock,
|
|
policy,
|
|
fetcher: async () => {
|
|
throw new TypeError("network is down");
|
|
},
|
|
});
|
|
const manifest = await manifestFor("fresh-release", [asset], policy);
|
|
|
|
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
|
ok: false,
|
|
});
|
|
expect(await cacheStorage.keys()).toEqual([]);
|
|
});
|
|
|
|
it("repairs an evicted asset in place and keeps the release usable", async () => {
|
|
const release = await stagedRelease("in-place-repair");
|
|
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
|
const evicted = cache.responses.findIndex(
|
|
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
|
|
);
|
|
cache.responses.splice(evicted, 1);
|
|
|
|
expect(
|
|
await release.adapter.admin.stageRelease(release.manifest),
|
|
).toMatchObject({ ok: true });
|
|
expect(
|
|
await release.adapter.responses.matchActiveExact({
|
|
absoluteUrl: release.assets[1]!.absoluteUrl,
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
expect(
|
|
await release.adapter.responses.matchActiveExact({
|
|
absoluteUrl: release.assets[0]!.absoluteUrl,
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
});
|
|
});
|