diff --git a/src/modules/react-loader/ssr-module-loader/cache/memory.test.ts b/src/modules/react-loader/ssr-module-loader/cache/memory.test.ts index 9c25294ae2..11978effb7 100644 --- a/src/modules/react-loader/ssr-module-loader/cache/memory.test.ts +++ b/src/modules/react-loader/ssr-module-loader/cache/memory.test.ts @@ -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"); @@ -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 }); diff --git a/src/modules/react-loader/ssr-module-loader/cache/memory.ts b/src/modules/react-loader/ssr-module-loader/cache/memory.ts index 5414f9063f..953862e9da 100644 --- a/src/modules/react-loader/ssr-module-loader/cache/memory.ts +++ b/src/modules/react-loader/ssr-module-loader/cache/memory.ts @@ -42,7 +42,9 @@ export const globalCrossProjectCache = new LRUCache({ maxEntries: TEMP_PATH_CACHE_MAX_ENTRIES, }); -export const globalInProgress = new Map>(); +// 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>(); export const globalTmpDirs = new LRUCache({ maxEntries: SSR_TMP_DIRS_MAX_ENTRIES, diff --git a/src/modules/react-loader/ssr-module-loader/loader.test.ts b/src/modules/react-loader/ssr-module-loader/loader.test.ts index d4d1621a13..f6689f6a4f 100644 --- a/src/modules/react-loader/ssr-module-loader/loader.test.ts +++ b/src/modules/react-loader/ssr-module-loader/loader.test.ts @@ -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 { @@ -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, @@ -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((resolve) => { + releaseDependencyRead = resolve; + }); + let signalDependencyRead!: () => void; + const dependencyReadStarted = new Promise((resolve) => { + signalDependencyRead = resolve; + }); + let blockedDependencyRead = false; + const adapter: RuntimeAdapter = { + ...baseAdapter, + fs: { + ...baseAdapter.fs, + async readFile(path: string): Promise { + 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(); @@ -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(() => {}); + const pending = new Promise(() => {}); globalInProgress.set(key, pending); try { @@ -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(() => {}); - const replacement = new Promise(() => {}); + const stale = new Promise(() => {}); + const replacement = new Promise(() => {}); globalInProgress.set(key, stale); const timer = __ssrModuleLoaderInternals.scheduleStaleInProgressTransformEviction( key, @@ -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(() => {}); + const stale = new Promise(() => {}); globalInProgress.set(key, stale); const timer = __ssrModuleLoaderInternals.scheduleStaleInProgressTransformEviction( key, @@ -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(() => {}); - const replacementLeader = new Promise(() => {}); + const oldLeader = new Promise(() => {}); + const replacementLeader = new Promise(() => {}); const replacementEntry = { tempPath: "/cache/replacement.mjs", contentHash: "replacement" }; const oldEntry = { tempPath: "/cache/old.mjs", contentHash: "old" }; const timer = setTimeout(() => {}, 60_000); diff --git a/src/modules/react-loader/ssr-module-loader/loader.ts b/src/modules/react-loader/ssr-module-loader/loader.ts index 118c6d9018..b4f65de055 100644 --- a/src/modules/react-loader/ssr-module-loader/loader.ts +++ b/src/modules/react-loader/ssr-module-loader/loader.ts @@ -87,7 +87,7 @@ class InProgressTransformWaitTimeoutError extends Error { function deleteInProgressTransformIfCurrent( key: string, - transformPromise: Promise, + transformPromise: Promise, ): boolean { if (globalInProgress.get(key) !== transformPromise) return false; return globalInProgress.delete(key); @@ -99,7 +99,7 @@ function shouldRetryRejectedInProgressTransform(rejectedLeaderCount: number): bo function scheduleStaleInProgressTransformEviction( key: string, - transformPromise: Promise, + transformPromise: Promise, filePath: string, ): ReturnType { const timer = setTimeout(() => { @@ -115,7 +115,7 @@ function scheduleStaleInProgressTransformEviction( function publishTransformCacheIfCurrent(input: { inProgressKey: string; - transformPromise: Promise; + transformPromise: Promise; staleEvictionTimer: ReturnType; contentCacheKey: string; filePathCacheKey: string; @@ -156,12 +156,12 @@ export const __ssrModuleLoaderInternals = { }; async function waitForInProgressTransform( - transformPromise: Promise, + transformPromise: Promise, filePath: string, -): Promise { +): Promise { let timeoutId: ReturnType | undefined; try { - await Promise.race([ + return await Promise.race([ transformPromise, new Promise((_, reject) => { timeoutId = setTimeout( @@ -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), @@ -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, @@ -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); @@ -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); @@ -504,7 +494,7 @@ export class SSRModuleLoader { source?: string, depth: number = 0, dependencyHashCache: DependencyHashCache = createDependencyHashCache(), - ): Promise { + ): Promise { const fileName = filePath.split("/").pop() || filePath; return withSpan( @@ -522,7 +512,7 @@ export class SSRModuleLoader { source?: string, depth: number = 0, dependencyHashCache: DependencyHashCache = createDependencyHashCache(), - ): Promise { + ): Promise { if (depth > MAX_TRANSFORM_DEPTH) { logger.warn("Max transform depth exceeded", { file: filePath.slice(-40), @@ -567,7 +557,7 @@ export class SSRModuleLoader { ) { globalModuleCache.set(filePathCacheKey, cachedEntry); await this.depValidator.ensureDependenciesExist(code, filePath, depth); - return; + return cachedEntry; } } @@ -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 } @@ -639,7 +629,7 @@ export class SSRModuleLoader { }); await this.depValidator.ensureDependenciesExist(code, filePath, depth); - return; + return entry; } if (mdxCacheResult.status === "corrupted") { @@ -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", { @@ -694,9 +683,9 @@ export class SSRModuleLoader { } } - let resolveTransform!: () => void; + let resolveTransform!: (entry: ModuleCacheEntry) => void; let rejectTransform!: (err: Error) => void; - const transformPromise = new Promise((resolve, reject) => { + const transformPromise = new Promise((resolve, reject) => { resolveTransform = resolve; rejectTransform = reject; }); @@ -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, @@ -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 }; @@ -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; diff --git a/src/modules/react-loader/ssr-module-loader/ssr-dependency-validator.ts b/src/modules/react-loader/ssr-module-loader/ssr-dependency-validator.ts index b2f19f6d6f..122656c480 100644 --- a/src/modules/react-loader/ssr-module-loader/ssr-dependency-validator.ts +++ b/src/modules/react-loader/ssr-module-loader/ssr-dependency-validator.ts @@ -15,7 +15,7 @@ import { createError, toError } from "#veryfront/errors"; import { rendererLogger } from "#veryfront/utils"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { MAX_TRANSFORM_DEPTH, TRANSFORM_BATCH_SIZE } from "./constants.ts"; -import { globalModuleCache } from "./cache/index.ts"; +import type { ModuleCacheEntry } from "./types.ts"; import { createDependencyHashCache, type DependencyHashCache, @@ -34,13 +34,12 @@ export class SSRDependencyValidator { missingDependencies: MissingImport[] = []; constructor( - private getCacheKey: (filePath: string) => string, private transformWithDependencies: ( filePath: string, source: string | undefined, depth: number, dependencyHashCache: DependencyHashCache, - ) => Promise, + ) => Promise, private transformCrossProjectImport: ( crossProjectImport: CrossProjectImport, ) => Promise, @@ -156,24 +155,20 @@ export class SSRDependencyValidator { try { const depSource = await this.readLocalImportSource(imp.absolutePath, localFs); - await this.transformWithDependencies( + const depEntry = await this.transformWithDependencies( imp.absolutePath, depSource, depth + 1, dependencyHashCache, ); - const depCacheKey = this.getCacheKey(imp.absolutePath); - const depEntry = globalModuleCache.get(depCacheKey); - if (depEntry) { - importPathMap.set(imp.specifier, depEntry.tempPath); - importPathMap.set(imp.absolutePath, depEntry.tempPath); - } + importPathMap.set(imp.specifier, depEntry.tempPath); + importPathMap.set(imp.absolutePath, depEntry.tempPath); } catch (error) { this.missingDependencies.push({ specifier: imp.specifier, fromFile: fromFilePath, - reason: `Failed to read file: ${ + reason: `Failed to load dependency: ${ error instanceof Error ? error.message : String(error) }`, }); diff --git a/src/rendering/orchestrator/pipeline.behavior.test.ts b/src/rendering/orchestrator/pipeline.behavior.test.ts index 54f21539e6..1c210bd0fb 100644 --- a/src/rendering/orchestrator/pipeline.behavior.test.ts +++ b/src/rendering/orchestrator/pipeline.behavior.test.ts @@ -451,7 +451,7 @@ describe("RenderPipeline behavior", () => { const projectId = "project-dev-render-active-transform"; const moduleKey = `prefix:${projectId}:module`; const inProgressKey = `prefix:${projectId}:in-progress`; - const leader = Promise.resolve(); + const leader = Promise.resolve({ tempPath: "/tmp/leader.mjs", contentHash: "leader" }); globalModuleCache.set(moduleKey, { tempPath: "/tmp/dev-render.mjs", contentHash: "a" }); globalInProgress.set(inProgressKey, leader); @@ -475,7 +475,7 @@ describe("RenderPipeline behavior", () => { const projectId = "project-dev-page-data-active-transform"; const moduleKey = `prefix:${projectId}:module`; const inProgressKey = `prefix:${projectId}:in-progress`; - const leader = Promise.resolve(); + const leader = Promise.resolve({ tempPath: "/tmp/leader.mjs", contentHash: "leader" }); globalModuleCache.set(moduleKey, { tempPath: "/tmp/dev-page-data.mjs", contentHash: "a" }); globalInProgress.set(inProgressKey, leader);