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
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,10 @@ describe("modules/react-loader/ssr-module-loader/cache/memory", () => {

it("should clear in-progress entries for a specific project", () => {
resetState();
const transformEntry = { tempPath: "/tmp/in-progress.mjs", contentHash: "test" };

globalInProgress.set("prefix:project-1:mod", Promise.resolve());
globalInProgress.set("prefix:project-2:mod", Promise.resolve());
globalInProgress.set("prefix:project-1:mod", Promise.resolve(transformEntry));
globalInProgress.set("prefix:project-2:mod", Promise.resolve(transformEntry));

clearSSRModuleCacheForProject("project-1");

Expand All @@ -270,10 +271,11 @@ describe("modules/react-loader/ssr-module-loader/cache/memory", () => {

it("should preserve in-progress entries for a specific project when requested", () => {
resetState();
const transformEntry = { tempPath: "/tmp/in-progress.mjs", contentHash: "test" };

const projectTransform = Promise.resolve();
const projectTransform = Promise.resolve(transformEntry);
globalInProgress.set("prefix:project-1:mod", projectTransform);
globalInProgress.set("prefix:project-2:mod", Promise.resolve());
globalInProgress.set("prefix:project-2:mod", Promise.resolve(transformEntry));

clearSSRModuleCacheForProject("project-1", { preserveActiveTransforms: true });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ export const globalCrossProjectCache = new LRUCache<string, ModuleCacheEntry>({
maxEntries: TEMP_PATH_CACHE_MAX_ENTRIES,
});

export const globalInProgress = new Map<string, Promise<void>>();
// Each singleflight completion carries its immutable output so requests that
// started before an invalidation can finish without republishing stale state.
export const globalInProgress = new Map<string, Promise<ModuleCacheEntry>>();

export const globalTmpDirs = new LRUCache<string, string>({
maxEntries: SSR_TMP_DIRS_MAX_ENTRIES,
Expand Down
79 changes: 72 additions & 7 deletions src/modules/react-loader/ssr-module-loader/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts";
import { FakeTime } from "#std/testing/time";
import { join } from "#veryfront/compat/path";
import { denoAdapter } from "#veryfront/platform/adapters/runtime/deno/index.ts";
import { clearSSRModuleCache, SSRModuleLoader } from "./index.ts";
import { clearSSRModuleCache, clearSSRModuleCacheForProject, SSRModuleLoader } from "./index.ts";
import { __ssrModuleLoaderInternals } from "./loader.ts";
import { globalInProgress, globalModuleCache } from "./cache/memory.ts";
import {
Expand All @@ -27,6 +27,7 @@ import {
buildMdxEsmPathCacheKey,
} from "#veryfront/transforms/mdx/esm-module-loader/cache-format.ts";
import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts";
import type { ModuleCacheEntry } from "./types.ts";
import {
clearModulePathCache,
getMdxEsmSsrCacheDir,
Expand Down Expand Up @@ -893,6 +894,70 @@ describe("SSRModuleLoader", { sanitizeResources: false, sanitizeOps: false }, ()
assertEquals(component.name, "RootLayout");
});

it("finishes an in-flight load when project invalidation revokes cache publication", async () => {
clearSSRModuleCache();

const projectDir = "/app";
const filePath = "/app/app/page.tsx";
const projectId = "project-invalidated-transform";
const baseAdapter = createProxyProjectAdapter({
"app/dependency.ts": `export const dependencyValue = "ready";`,
});
let releaseDependencyRead!: () => void;
const dependencyReadReleased = new Promise<void>((resolve) => {
releaseDependencyRead = resolve;
});
let signalDependencyRead!: () => void;
const dependencyReadStarted = new Promise<void>((resolve) => {
signalDependencyRead = resolve;
});
let blockedDependencyRead = false;
const adapter: RuntimeAdapter = {
...baseAdapter,
fs: {
...baseAdapter.fs,
async readFile(path: string): Promise<string> {
if (path.endsWith("/dependency.ts") && !blockedDependencyRead) {
blockedDependencyRead = true;
signalDependencyRead();
await dependencyReadReleased;
}
return await baseAdapter.fs.readFile(path);
},
},
};
const source = [
`import { dependencyValue } from "./dependency.ts";`,
`export default function Page() {`,
` return dependencyValue;`,
`}`,
].join("\n");
const loader = new SSRModuleLoader({
projectDir,
projectId,
contentSourceId: "release-1",
adapter,
dev: true,
});

try {
const leaderLoad = loader.loadRawModule(filePath, source);
await dependencyReadStarted;
const followerLoad = loader.loadRawModule(filePath, source);
await new Promise((resolve) => setTimeout(resolve, 0));
clearSSRModuleCacheForProject(projectId);
releaseDependencyRead();

const modules = await Promise.all([leaderLoad, followerLoad]);
for (const module of modules) {
assertEquals((module.default as () => string)(), "ready");
}
} finally {
releaseDependencyRead();
clearSSRModuleCache();
}
});

it("invalidates stale cache entries with unresolved _vf_modules imports and retransforms", async () => {
clearSSRModuleCache();

Expand Down Expand Up @@ -1020,7 +1085,7 @@ describe("SSRModuleLoader", { sanitizeResources: false, sanitizeOps: false }, ()
it("bounds a caller wait without evicting the shared transform", async () => {
using time = new FakeTime();
const key = "test:shared-transform-wait";
const pending = new Promise<void>(() => {});
const pending = new Promise<ModuleCacheEntry>(() => {});
globalInProgress.set(key, pending);

try {
Expand All @@ -1044,8 +1109,8 @@ describe("SSRModuleLoader", { sanitizeResources: false, sanitizeOps: false }, ()
it("evicts only the exact transform that exceeds the stale safety window", async () => {
using time = new FakeTime();
const key = "test:stale-transform-eviction";
const stale = new Promise<void>(() => {});
const replacement = new Promise<void>(() => {});
const stale = new Promise<ModuleCacheEntry>(() => {});
const replacement = new Promise<ModuleCacheEntry>(() => {});
globalInProgress.set(key, stale);
const timer = __ssrModuleLoaderInternals.scheduleStaleInProgressTransformEviction(
key,
Expand All @@ -1069,7 +1134,7 @@ describe("SSRModuleLoader", { sanitizeResources: false, sanitizeOps: false }, ()
it("allows retry after the current transform exceeds the stale safety window", async () => {
using time = new FakeTime();
const key = "test:current-stale-transform-eviction";
const stale = new Promise<void>(() => {});
const stale = new Promise<ModuleCacheEntry>(() => {});
globalInProgress.set(key, stale);
const timer = __ssrModuleLoaderInternals.scheduleStaleInProgressTransformEviction(
key,
Expand All @@ -1090,8 +1155,8 @@ describe("SSRModuleLoader", { sanitizeResources: false, sanitizeOps: false }, ()
const inProgressKey = "test:late-loader-publication";
const contentCacheKey = "test:late-loader-content";
const filePathCacheKey = "test:late-loader-path";
const oldLeader = new Promise<void>(() => {});
const replacementLeader = new Promise<void>(() => {});
const oldLeader = new Promise<ModuleCacheEntry>(() => {});
const replacementLeader = new Promise<ModuleCacheEntry>(() => {});
const replacementEntry = { tempPath: "/cache/replacement.mjs", contentHash: "replacement" };
const oldEntry = { tempPath: "/cache/old.mjs", contentHash: "old" };
const timer = setTimeout(() => {}, 60_000);
Expand Down
80 changes: 39 additions & 41 deletions src/modules/react-loader/ssr-module-loader/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class InProgressTransformWaitTimeoutError extends Error {

function deleteInProgressTransformIfCurrent(
key: string,
transformPromise: Promise<void>,
transformPromise: Promise<ModuleCacheEntry>,
): boolean {
if (globalInProgress.get(key) !== transformPromise) return false;
return globalInProgress.delete(key);
Expand All @@ -99,7 +99,7 @@ function shouldRetryRejectedInProgressTransform(rejectedLeaderCount: number): bo

function scheduleStaleInProgressTransformEviction(
key: string,
transformPromise: Promise<void>,
transformPromise: Promise<ModuleCacheEntry>,
filePath: string,
): ReturnType<typeof setTimeout> {
const timer = setTimeout(() => {
Expand All @@ -115,7 +115,7 @@ function scheduleStaleInProgressTransformEviction(

function publishTransformCacheIfCurrent(input: {
inProgressKey: string;
transformPromise: Promise<void>;
transformPromise: Promise<ModuleCacheEntry>;
staleEvictionTimer: ReturnType<typeof setTimeout>;
contentCacheKey: string;
filePathCacheKey: string;
Expand Down Expand Up @@ -156,12 +156,12 @@ export const __ssrModuleLoaderInternals = {
};

async function waitForInProgressTransform(
transformPromise: Promise<void>,
transformPromise: Promise<ModuleCacheEntry>,
filePath: string,
): Promise<void> {
): Promise<ModuleCacheEntry> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
return await Promise.race([
transformPromise,
new Promise<never>((_, reject) => {
timeoutId = setTimeout(
Expand Down Expand Up @@ -189,7 +189,6 @@ export class SSRModuleLoader {
constructor(private options: SSRModuleLoaderOptions) {
this.cache = new SSRCacheManager(options);
this.depValidator = new SSRDependencyValidator(
(filePath) => this.cache.getCacheKey(filePath),
(filePath, source, depth, dependencyHashCache) =>
this.transformWithDependencies(filePath, source, depth, dependencyHashCache),
(crossImport) => this.transformCrossProjectImport(crossImport),
Expand Down Expand Up @@ -368,21 +367,6 @@ export class SSRModuleLoader {
}
}

private getTransformedCacheEntry(filePath: string): ModuleCacheEntry {
const cacheKey = this.cache.getCacheKey(filePath);
const cacheEntry = globalModuleCache.get(cacheKey);
if (!cacheEntry) {
throw toError(
createError({
type: "build",
message: `Failed to transform module: ${filePath}`,
context: { file: filePath, phase: "transform" },
}),
);
}
return cacheEntry;
}

private async invalidateMdxEsmCacheEntry(
filePath: string,
cacheEntry: ModuleCacheEntry,
Expand Down Expand Up @@ -436,11 +420,14 @@ export class SSRModuleLoader {

try {
const dependencyHashCache = createDependencyHashCache();
await this.transformWithDependencies(filePath, source, 0, dependencyHashCache);
const cacheEntry = await this.transformWithDependencies(
filePath,
source,
0,
dependencyHashCache,
);
this.throwMissingDependencies(filePath);

const cacheEntry = this.getTransformedCacheEntry(filePath);

try {
const mod = await this.importModuleFromCacheEntry(filePath, fileName, cacheEntry);

Expand All @@ -457,10 +444,13 @@ export class SSRModuleLoader {
});

const retryDependencyHashCache = createDependencyHashCache();
await this.transformWithDependencies(filePath, source, 0, retryDependencyHashCache);
const retryCacheEntry = await this.transformWithDependencies(
filePath,
source,
0,
retryDependencyHashCache,
);
this.throwMissingDependencies(filePath);

const retryCacheEntry = this.getTransformedCacheEntry(filePath);
const mod = await this.importModuleFromCacheEntry(filePath, fileName, retryCacheEntry);

this.circuitBreaker.recordSuccess(circuitKey);
Expand Down Expand Up @@ -504,7 +494,7 @@ export class SSRModuleLoader {
source?: string,
depth: number = 0,
dependencyHashCache: DependencyHashCache = createDependencyHashCache(),
): Promise<void> {
): Promise<ModuleCacheEntry> {
const fileName = filePath.split("/").pop() || filePath;

return withSpan(
Expand All @@ -522,7 +512,7 @@ export class SSRModuleLoader {
source?: string,
depth: number = 0,
dependencyHashCache: DependencyHashCache = createDependencyHashCache(),
): Promise<void> {
): Promise<ModuleCacheEntry> {
if (depth > MAX_TRANSFORM_DEPTH) {
logger.warn("Max transform depth exceeded", {
file: filePath.slice(-40),
Expand Down Expand Up @@ -567,7 +557,7 @@ export class SSRModuleLoader {
) {
globalModuleCache.set(filePathCacheKey, cachedEntry);
await this.depValidator.ensureDependenciesExist(code, filePath, depth);
return;
return cachedEntry;
}
}

Expand Down Expand Up @@ -602,7 +592,7 @@ export class SSRModuleLoader {
logger.debug("Redis cache hit", { file: filePath.slice(-40) });

await this.depValidator.ensureDependenciesExist(code, filePath, depth);
return;
return entry;
}
// writeCacheFile returned false — fall through to fresh transform
}
Expand Down Expand Up @@ -639,7 +629,7 @@ export class SSRModuleLoader {
});

await this.depValidator.ensureDependenciesExist(code, filePath, depth);
return;
return entry;
}

if (mdxCacheResult.status === "corrupted") {
Expand All @@ -656,12 +646,11 @@ export class SSRModuleLoader {
if (!existingTransform) break;

try {
await withSpan(
return await withSpan(
SpanNames.SSR_WAIT_IN_PROGRESS,
() => waitForInProgressTransform(existingTransform, filePath),
{ "ssr.file": filePath.split("/").pop() || filePath },
);
return;
} catch (error) {
if (error instanceof InProgressTransformWaitTimeoutError) {
logger.warn("In-progress transform wait timed out", {
Expand Down Expand Up @@ -694,9 +683,9 @@ export class SSRModuleLoader {
}
}

let resolveTransform!: () => void;
let resolveTransform!: (entry: ModuleCacheEntry) => void;
let rejectTransform!: (err: Error) => void;
const transformPromise = new Promise<void>((resolve, reject) => {
const transformPromise = new Promise<ModuleCacheEntry>((resolve, reject) => {
resolveTransform = resolve;
rejectTransform = reject;
});
Expand Down Expand Up @@ -787,7 +776,7 @@ export class SSRModuleLoader {
}

// Hold project slots only around the actual transform and file write.
await this.withTransformCapacity(filePath, "build", async () => {
const entry = await this.withTransformCapacity(filePath, "build", async () => {
const projectId = this.options.projectId;
const transformOpts: TransformOptions = {
projectId,
Expand Down Expand Up @@ -877,8 +866,13 @@ export class SSRModuleLoader {
"SSR-MODULE-LOADER",
);
if (!written) {
// Cache file write failed (directory removed concurrently or verification failed)
return;
throw toError(
createError({
type: "build",
message: `Failed to transform module: ${filePath}`,
context: { file: filePath, phase: "transform" },
}),
);
}

const entry: ModuleCacheEntry = { tempPath, contentHash: transformedHash };
Expand Down Expand Up @@ -909,9 +903,13 @@ export class SSRModuleLoader {
file: filePath.slice(-40),
});
}
// A revoked leader must not update shared caches, but its immutable
// output is still valid for requests that joined this singleflight.
return entry;
});

resolveTransform();
resolveTransform(entry);
return entry;
} catch (error) {
rejectTransform(error instanceof Error ? error : new Error(String(error)));
throw error;
Expand Down
Loading