Compare commits

...

3 Commits

Author SHA1 Message Date
Jason Jean b05d5fee28 chore(core): retrigger ci 2026-02-27 08:46:40 -05:00
nx-cloud[bot] 14c2d5ba3f test(core): update tests for new PluginCache API
Co-authored-by: FrozenPandaz <FrozenPandaz@users.noreply.github.com>
2026-02-27 13:28:28 +00:00
Jason Jean c43ee13eed feat(core): add safe plugin cache write utilities with LRU eviction
Add PluginCache<T> class with explicit get/set API that tracks access
order for LRU eviction. Dedup and capping happen at write time, not
per-access. All plugin cache writes are now wrapped in try/catch so
failures never crash the project graph calculation.

Migrated consumers: cypress, playwright, dotnet, gradle (v1+v2),
maven, package-json, js/lockfile, nx-deps-cache.
2026-02-27 00:22:49 -05:00
17 changed files with 695 additions and 190 deletions
+16 -27
View File
@@ -8,9 +8,7 @@ import {
normalizePath,
type NxJsonConfiguration,
type ProjectConfiguration,
readJsonFile,
type TargetConfiguration,
writeJsonFile,
} from '@nx/devkit';
import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
import { loadConfigFile } from '@nx/devkit/src/utils/config-utils';
@@ -19,6 +17,11 @@ import { getLockFileName } from '@nx/js';
import { readdirSync } from 'fs';
import { hashObject } from 'nx/src/devkit-internals';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import {
PluginCache,
readPluginCache,
safeWritePluginCache,
} from 'nx/src/utils/plugin-cache-utils';
import { globWithWorkspaceContext } from 'nx/src/utils/workspace-context';
import { dirname, join, relative } from 'path';
import { NX_PLUGIN_OPTIONS } from '../utils/constants';
@@ -31,20 +34,6 @@ export interface CypressPluginOptions {
ciComponentTestingTargetName?: string;
}
function readTargetsCache(cachePath: string): Record<string, CypressTargets> {
try {
return process.env.NX_CACHE_PROJECT_GRAPH !== 'false'
? readJsonFile(cachePath)
: {};
} catch {
return {};
}
}
function writeTargetsToCache(cachePath: string, results: CypressTargets) {
writeJsonFile(cachePath, results);
}
const cypressConfigGlob = '**/cypress.config.{js,ts,mjs,cjs}';
const defaultPatterns = {
e2e: {
@@ -67,17 +56,17 @@ export const createNodes: CreateNodesV2<CypressPluginOptions> = [
workspaceDataDirectory,
`cypress-${optionsHash}.hash`
);
const targetsCache = readTargetsCache(cachePath);
const cache = readPluginCache<CypressTargets>(cachePath);
try {
return await createNodesFromFiles(
(configFile, options, context) =>
createNodesInternal(configFile, options, context, targetsCache),
createNodesInternal(configFile, options, context, cache),
configFiles,
options,
context
);
} finally {
writeTargetsToCache(cachePath, targetsCache);
safeWritePluginCache(cachePath, cache);
}
},
];
@@ -88,7 +77,7 @@ async function createNodesInternal(
configFilePath: string,
options: CypressPluginOptions,
context: CreateNodesContextV2,
targetsCache: CypressTargets
cache: PluginCache<CypressTargets>
) {
options = normalizeOptions(options);
const projectRoot = dirname(configFilePath);
@@ -109,13 +98,13 @@ async function createNodesInternal(
[getLockFileName(detectPackageManager(context.workspaceRoot))]
);
targetsCache[hash] ??= await buildCypressTargets(
configFilePath,
projectRoot,
options,
context
);
const { targets, metadata } = targetsCache[hash];
if (!cache.has(hash)) {
cache.set(
hash,
await buildCypressTargets(configFilePath, projectRoot, options, context)
);
}
const { targets, metadata } = cache.get(hash);
const project: Omit<ProjectConfiguration, 'root'> = {
projectType: 'application',
+4
View File
@@ -1,4 +1,8 @@
export {
signalToCode,
createProjectRootMappingsFromProjectConfigurations,
PluginCache,
readPluginCache,
safeWritePluginCache,
safeWriteFileCache,
} from 'nx/src/devkit-internals';
+16 -34
View File
@@ -1,16 +1,16 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import {
logger,
workspaceRoot,
ProjectConfiguration,
writeJsonFile,
} from '@nx/devkit';
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { logger, workspaceRoot, ProjectConfiguration } from '@nx/devkit';
import { hashWithWorkspaceContext } from 'nx/src/utils/workspace-context';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { hashObject } from 'nx/src/hasher/file-hasher';
import {
PluginCache,
readPluginCache,
safeWritePluginCache,
} from 'nx/src/utils/plugin-cache-utils';
export interface AnalysisSuccessResult {
// Maps project file path -> node configuration
@@ -26,7 +26,7 @@ export interface AnalysisErrorResult {
}
export type AnalysisResult = AnalysisSuccessResult | AnalysisErrorResult;
const analyzerCaches = new Map<string, Record<string, AnalysisSuccessResult>>();
const analyzerCaches = new Map<string, PluginCache<AnalysisSuccessResult>>();
function getCachePathForOptionsHash(optionsHash: string): string {
return join(workspaceDataDirectory, `dotnet-${optionsHash}.hash`);
@@ -34,37 +34,21 @@ function getCachePathForOptionsHash(optionsHash: string): string {
function readAnalyzerCache(
optionsHash: string
): Record<string, AnalysisSuccessResult> {
): PluginCache<AnalysisSuccessResult> {
if (analyzerCaches.has(optionsHash)) {
return analyzerCaches.get(optionsHash)!;
}
const cacheFilePath = getCachePathForOptionsHash(optionsHash);
try {
return JSON.parse(readFileSync(cacheFilePath, 'utf-8'));
} catch {
return {};
}
return readPluginCache<AnalysisSuccessResult>(cacheFilePath);
}
function writeAnalyzerCache(
optionsHash: string,
cache: Record<string, AnalysisSuccessResult>
cache: PluginCache<AnalysisSuccessResult>
): void {
analyzerCaches.set(optionsHash, cache);
const cacheFilePath = getCachePathForOptionsHash(optionsHash);
const cacheDir = dirname(cacheFilePath);
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true });
}
try {
writeJsonFile(cacheFilePath, cache);
} catch (error) {
logger.warn(
`Failed to write .NET analyzer cache to ${cacheFilePath}: ${
(error as Error).message
}`
);
}
safeWritePluginCache(cacheFilePath, cache);
}
/**
@@ -247,7 +231,7 @@ export async function analyzeProjects(
const optionsHash = hashObject(options);
const analyzerCache = readAnalyzerCache(optionsHash);
const cachedResult = analyzerCache[filesHash];
const cachedResult = analyzerCache.get(filesHash);
if (cachedResult) {
// Update cache
cache = {
@@ -267,10 +251,8 @@ export async function analyzeProjects(
result,
};
// Update persistent cache
writeAnalyzerCache(optionsHash, {
...analyzerCache,
[filesHash]: result,
});
analyzerCache.set(filesHash, result);
writeAnalyzerCache(optionsHash, analyzerCache);
return result;
} catch (error) {
+22 -23
View File
@@ -4,15 +4,17 @@ import {
ProjectConfiguration,
TargetConfiguration,
createNodesFromFiles,
readJsonFile,
writeJsonFile,
logger,
} from '@nx/devkit';
import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
import { existsSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { findProjectForPath } from 'nx/src/devkit-internals';
import {
PluginCache,
readPluginCache,
safeWritePluginCache,
} from 'nx/src/utils/plugin-cache-utils';
import {
populateGradleReport,
@@ -59,14 +61,6 @@ function normalizeOptions(options: GradlePluginOptions): GradlePluginOptions {
type GradleTargets = Record<string, Partial<ProjectConfiguration>>;
function readTargetsCache(cachePath: string): GradleTargets {
return existsSync(cachePath) ? readJsonFile(cachePath) : {};
}
export function writeTargetsToCache(cachePath: string, results: GradleTargets) {
writeJsonFile(cachePath, results);
}
export const createNodesV2: CreateNodesV2<GradlePluginOptions> = [
gradleConfigAndTestGlob,
async (files, options, context) => {
@@ -77,7 +71,7 @@ export const createNodesV2: CreateNodesV2<GradlePluginOptions> = [
workspaceDataDirectory,
`gradle-${optionsHash}.hash`
);
const targetsCache = readTargetsCache(cachePath);
const cache = readPluginCache<Partial<ProjectConfiguration>>(cachePath);
await populateGradleReport(
context.workspaceRoot,
@@ -93,7 +87,7 @@ export const createNodesV2: CreateNodesV2<GradlePluginOptions> = [
return createNodesFromFiles(
makeCreateNodesForGradleConfigFile(
gradleReport,
targetsCache,
cache,
gradleProjectRootToTestFilesMap
),
buildFiles,
@@ -101,7 +95,7 @@ export const createNodesV2: CreateNodesV2<GradlePluginOptions> = [
context
);
} finally {
writeTargetsToCache(cachePath, targetsCache);
safeWritePluginCache(cachePath, cache);
}
},
];
@@ -109,7 +103,7 @@ export const createNodesV2: CreateNodesV2<GradlePluginOptions> = [
export const makeCreateNodesForGradleConfigFile =
(
gradleReport: GradleReport,
targetsCache: GradleTargets = {},
cache: PluginCache<Partial<ProjectConfiguration>> = new PluginCache(),
gradleProjectRootToTestFilesMap: Record<string, string[]> = {}
) =>
async (
@@ -125,14 +119,19 @@ export const makeCreateNodesForGradleConfigFile =
options ?? {},
context
);
targetsCache[hash] ??= await createGradleProject(
gradleReport,
gradleFilePath,
options,
context,
gradleProjectRootToTestFilesMap[projectRoot]
);
const project = targetsCache[hash];
if (!cache.has(hash)) {
cache.set(
hash,
await createGradleProject(
gradleReport,
gradleFilePath,
options,
context,
gradleProjectRootToTestFilesMap[projectRoot]
)
);
}
const project = cache.get(hash);
if (!project) {
return {};
}
+26 -20
View File
@@ -2,16 +2,18 @@ import {
CreateNodesV2,
CreateNodesContextV2,
ProjectConfiguration,
readJsonFile,
writeJsonFile,
workspaceRoot,
ProjectGraphExternalNode,
normalizePath,
} from '@nx/devkit';
import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import {
PluginCache,
readPluginCache,
safeWritePluginCache,
} from 'nx/src/utils/plugin-cache-utils';
import { hashObject } from 'nx/src/hasher/file-hasher';
import {
@@ -29,10 +31,6 @@ import {
type GradleTargets = Record<string, Partial<ProjectConfiguration>>;
function readProjectsCache(cachePath: string): GradleTargets {
return existsSync(cachePath) ? readJsonFile(cachePath) : {};
}
/**
* Strips nxConfig from project and all targets, returning only Gradle-detected configuration.
*/
@@ -111,10 +109,6 @@ function extractNxConfigOnly(
return result;
}
export function writeTargetsToCache(cachePath: string, results: GradleTargets) {
writeJsonFile(cachePath, results);
}
export const createNodesV2: CreateNodesV2<GradlePluginOptions> = [
gradleConfigAndTestGlob,
async (files, options, context) => {
@@ -125,7 +119,7 @@ export const createNodesV2: CreateNodesV2<GradlePluginOptions> = [
workspaceDataDirectory,
`gradle-${optionsHash}.hash`
);
const projectsCache = readProjectsCache(cachePath);
const cache = readPluginCache<Partial<ProjectConfiguration>>(cachePath);
await populateProjectGraph(
context.workspaceRoot,
@@ -153,9 +147,14 @@ export const createNodesV2: CreateNodesV2<GradlePluginOptions> = [
);
// Get project from cache or nodes
projectsCache[hash] ??=
nodes[projectRoot] ?? nodes[join(workspaceRoot, projectRoot)];
const project = projectsCache[hash];
if (!cache.has(hash)) {
const nodeProject =
nodes[projectRoot] ?? nodes[join(workspaceRoot, projectRoot)];
if (nodeProject) {
cache.set(hash, nodeProject);
}
}
const project = cache.get(hash);
if (!project) {
continue;
@@ -195,7 +194,7 @@ export const createNodesV2: CreateNodesV2<GradlePluginOptions> = [
return results;
} finally {
writeTargetsToCache(cachePath, projectsCache);
safeWritePluginCache(cachePath, cache);
}
},
];
@@ -203,7 +202,9 @@ export const createNodesV2: CreateNodesV2<GradlePluginOptions> = [
export const makeCreateNodesForGradleConfigFile =
(
projects: Record<string, Partial<ProjectConfiguration>>,
projectsCache: GradleTargets = {},
projectsCache: PluginCache<
Partial<ProjectConfiguration>
> = new PluginCache(),
externalNodes: Record<string, ProjectGraphExternalNode> = {}
) =>
async (
@@ -219,9 +220,14 @@ export const makeCreateNodesForGradleConfigFile =
options ?? {},
context
);
projectsCache[hash] ??=
projects[projectRoot] ?? projects[join(workspaceRoot, projectRoot)];
const project = projectsCache[hash];
if (!projectsCache.has(hash)) {
const nodeProject =
projects[projectRoot] ?? projects[join(workspaceRoot, projectRoot)];
if (nodeProject) {
projectsCache.set(hash, nodeProject);
}
}
const project = projectsCache.get(hash);
if (!project) {
return {};
}
@@ -4,6 +4,7 @@ import { join } from 'node:path';
import {
AggregateCreateNodesError,
hashArray,
logger,
ProjectConfiguration,
ProjectGraphExternalNode,
readJsonFile,
@@ -56,7 +57,15 @@ export function writeProjectGraphReportToCache(
...results,
};
writeJsonFile(cachePath, projectGraphReportJson);
try {
writeJsonFile(cachePath, projectGraphReportJson);
} catch (e) {
logger.warn(
`Failed to write Gradle project graph report cache to ${cachePath}: ${
e instanceof Error ? e.message : 'unknown error'
}`
);
}
}
let projectGraphReportCache: ProjectGraphReport;
+9 -11
View File
@@ -1,20 +1,18 @@
import { join } from 'path';
import { readJsonFile, writeJsonFile } from '@nx/devkit';
import { MavenAnalysisData } from './types';
import {
PluginCache,
readPluginCache,
safeWritePluginCache,
} from 'nx/src/utils/plugin-cache-utils';
/**
* Read the Maven targets cache from disk
*/
export function readMavenCache(
cachePath: string
): Record<string, MavenAnalysisData> {
try {
return process.env.NX_CACHE_PROJECT_GRAPH !== 'false'
? readJsonFile(cachePath)
: {};
} catch {
return {};
}
): PluginCache<MavenAnalysisData> {
return readPluginCache<MavenAnalysisData>(cachePath);
}
/**
@@ -22,9 +20,9 @@ export function readMavenCache(
*/
export function writeMavenCache(
cachePath: string,
cache: Record<string, MavenAnalysisData>
cache: PluginCache<MavenAnalysisData>
): void {
writeJsonFile(cachePath, cache);
safeWritePluginCache(cachePath, cache);
}
/**
+2 -2
View File
@@ -54,7 +54,7 @@ export const createNodes: CreateNodesV2<MavenPluginOptions> = [
try {
// Try to get cached data first (skip cache if in verbose mode)
let mavenData = isVerbose ? null : mavenCache[hash];
let mavenData = isVerbose ? null : mavenCache.get(hash);
// If no cached data or cache is stale, run fresh Maven analysis
if (!mavenData) {
@@ -63,7 +63,7 @@ export const createNodes: CreateNodesV2<MavenPluginOptions> = [
verbose: isVerbose,
});
// Cache the results with the hash
mavenCache[hash] = mavenData;
mavenCache.set(hash, mavenData);
}
// Store in module-level variable for createDependencies to use
+10 -15
View File
@@ -8,24 +8,19 @@ import {
import { workspaceDataDirectory } from '../src/utils/cache-directory';
import { join } from 'path';
import { ProjectConfiguration } from '../src/config/workspace-json-project-json';
import { readJsonFile, writeJsonFile } from '../src/utils/fileutils';
import { readJsonFile } from '../src/utils/fileutils';
import {
PluginCache,
readPluginCache,
safeWritePluginCache,
} from '../src/utils/plugin-cache-utils';
export type PackageJsonConfigurationCache = {
[hash: string]: ProjectConfiguration;
};
export type PackageJsonConfigurationCache = PluginCache<ProjectConfiguration>;
const cachePath = join(workspaceDataDirectory, 'package-json.hash');
export function readPackageJsonConfigurationCache() {
try {
return readJsonFile<PackageJsonConfigurationCache>(cachePath);
} catch (e) {
return {};
}
}
function writeCache(cache: PackageJsonConfigurationCache) {
writeJsonFile(cachePath, cache);
export function readPackageJsonConfigurationCache(): PackageJsonConfigurationCache {
return readPluginCache<ProjectConfiguration>(cachePath);
}
const plugin: NxPluginV2 = {
@@ -54,7 +49,7 @@ const plugin: NxPluginV2 = {
context
);
writeCache(cache);
safeWritePluginCache(cachePath, cache);
return result;
},
+6
View File
@@ -47,3 +47,9 @@ export { isUsingPrettierInTree } from './utils/is-using-prettier';
export { readYamlFile } from './utils/fileutils';
export { globalSpinner } from './utils/spinner';
export { signalToCode } from './utils/exit-codes';
export {
PluginCache,
readPluginCache,
safeWritePluginCache,
safeWriteFileCache,
} from './utils/plugin-cache-utils';
+6 -7
View File
@@ -1,5 +1,5 @@
import { execSync } from 'child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
import { mkdirSync, readFileSync } from 'fs';
import { dirname, join } from 'path';
import { performance } from 'perf_hooks';
import {
@@ -19,6 +19,7 @@ import { workspaceDataDirectory } from '../../utils/cache-directory';
import { combineGlobPatterns } from '../../utils/globs';
import { detectPackageManager } from '../../utils/package-manager';
import { nxVersion } from '../../utils/versions';
import { safeWriteFileCache } from '../../utils/plugin-cache-utils';
import { workspaceRoot } from '../../utils/workspace-root';
import { readBunLockFile } from './lock-file/bun-parser';
import {
@@ -206,11 +207,10 @@ function writeExternalNodesCache(
nodes: ProjectGraph['externalNodes'],
keyMap: Map<string, any>
) {
mkdirSync(dirname(externalNodesHashFile), { recursive: true });
const serializedKeyMap = serializeKeyMap(keyMap);
const cacheData = { nodes, keyMap: serializedKeyMap };
writeFileSync(externalNodesCache, JSON.stringify(cacheData, null, 2));
writeFileSync(externalNodesHashFile, hash);
safeWriteFileCache(externalNodesCache, JSON.stringify(cacheData, null, 2));
safeWriteFileCache(externalNodesHashFile, hash);
}
function readCachedExternalNodes(): {
@@ -228,9 +228,8 @@ function writeDependenciesCache(
hash: string,
dependencies: RawProjectGraphDependency[]
) {
mkdirSync(dirname(dependenciesHashFile), { recursive: true });
writeFileSync(dependenciesCache, JSON.stringify(dependencies, null, 2));
writeFileSync(dependenciesHashFile, hash);
safeWriteFileCache(dependenciesCache, JSON.stringify(dependencies, null, 2));
safeWriteFileCache(dependenciesHashFile, hash);
}
function readCachedDependencies(): RawProjectGraphDependency[] {
@@ -1,6 +1,7 @@
import '../../internal-testing-utils/mock-fs';
import { vol } from 'memfs';
import { PluginCache } from '../../utils/plugin-cache-utils';
import { createNodeFromPackageJson, createNodesV2 } from './create-nodes';
describe('nx package.json workspaces plugin', () => {
@@ -47,8 +48,14 @@ describe('nx package.json workspaces plugin', () => {
'/root'
);
expect(createNodeFromPackageJson('package.json', '/root', {}, false))
.toMatchInlineSnapshot(`
expect(
createNodeFromPackageJson(
'package.json',
'/root',
new PluginCache(),
false
)
).toMatchInlineSnapshot(`
{
"projects": {
".": {
@@ -99,7 +106,7 @@ describe('nx package.json workspaces plugin', () => {
createNodeFromPackageJson(
'packages/lib-a/package.json',
'/root',
{},
new PluginCache(),
false
)
).toMatchInlineSnapshot(`
@@ -153,7 +160,7 @@ describe('nx package.json workspaces plugin', () => {
createNodeFromPackageJson(
'packages/lib-b/package.json',
'/root',
{},
new PluginCache(),
false
)
).toMatchInlineSnapshot(`
@@ -794,15 +801,19 @@ describe('nx package.json workspaces plugin', () => {
);
expect(
createNodeFromPackageJson('apps/myapp/package.json', '/root', {}, false)
.projects['apps/myapp'].projectType
createNodeFromPackageJson(
'apps/myapp/package.json',
'/root',
new PluginCache(),
false
).projects['apps/myapp'].projectType
).toEqual('application');
expect(
createNodeFromPackageJson(
'packages/mylib/package.json',
'/root',
{},
new PluginCache(),
false
).projects['packages/mylib'].projectType
).toEqual('library');
@@ -826,9 +837,12 @@ describe('nx package.json workspaces plugin', () => {
);
expect(
createNodeFromPackageJson('package.json', '/root', {}, false).projects[
'.'
].projectType
createNodeFromPackageJson(
'package.json',
'/root',
new PluginCache(),
false
).projects['.'].projectType
).toEqual('library');
});
@@ -856,13 +870,17 @@ describe('nx package.json workspaces plugin', () => {
createNodeFromPackageJson(
'packages/mylib/package.json',
'/root',
{},
new PluginCache(),
false
).projects['packages/mylib'].projectType
).toEqual('library');
expect(
createNodeFromPackageJson('example/package.json', '/root', {}, false)
.projects['example'].projectType
createNodeFromPackageJson(
'example/package.json',
'/root',
new PluginCache(),
false
).projects['example'].projectType
).toBeUndefined();
});
@@ -186,7 +186,7 @@ export function createNodeFromPackageJson(
nxVersion,
});
const cached = cache[hash];
const cached = cache.get(hash);
if (cached) {
return {
projects: {
@@ -203,7 +203,7 @@ export function createNodeFromPackageJson(
isInPackageManagerWorkspaces
);
cache[hash] = project;
cache.set(hash, project);
return {
projects: {
[project.root]: project,
+17 -3
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, renameSync } from 'node:fs';
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
import { join } from 'path';
import { performance } from 'perf_hooks';
import { NxJsonConfiguration } from '../config/nx-json';
@@ -10,6 +10,7 @@ import type {
} from '../config/project-graph';
import { ProjectConfiguration } from '../config/workspace-json-project-json';
import { workspaceDataDirectory } from '../utils/cache-directory';
import { logger } from '../utils/logger';
import {
directoryExists,
fileExists,
@@ -261,9 +262,12 @@ export function writeCache(
}
} while (!done && retry < 5);
if (!done) {
throw new Error(
`Failed to write project graph cache to ${nxProjectGraph} and ${nxFileMap} after 5 attempts.`
logger.warn(
`Failed to write project graph cache to ${nxProjectGraph} and ${nxFileMap} after 5 attempts. Continuing without cache.`
);
tryRemoveFile(nxProjectGraph);
tryRemoveFile(nxFileMap);
tryRemoveFile(nxSourceMaps);
}
performance.mark('write cache:end');
performance.measure('write cache', 'write cache:start', 'write cache:end');
@@ -439,6 +443,16 @@ type PluginData = {
options?: unknown;
};
function tryRemoveFile(path: string): void {
try {
if (existsSync(path)) {
rmSync(path);
}
} catch {
// Best effort
}
}
function getNxJsonPluginsData(
nxJson: NxJsonConfiguration,
packageJsonDeps: Record<string, string>
@@ -0,0 +1,277 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
PluginCache,
readPluginCache,
safeWriteFileCache,
safeWritePluginCache,
} from './plugin-cache-utils';
describe('plugin-cache-utils', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'nx-cache-utils-test-'));
});
describe('PluginCache', () => {
it('should return value on get and track access', () => {
const cache = new PluginCache<string>({ a: '1', b: '2' }, ['a', 'b']);
expect(cache.get('a')).toBe('1');
expect(cache.get('missing')).toBeUndefined();
});
it('should store value on set and track access', () => {
const cache = new PluginCache<string>({}, []);
cache.set('x', 'hello');
expect(cache.get('x')).toBe('hello');
});
it('should check existence with has without tracking', () => {
const cache = new PluginCache<string>({ a: '1' }, ['a']);
expect(cache.has('a')).toBe(true);
expect(cache.has('z')).toBe(false);
});
it('should move accessed keys to end of access order', () => {
const cache = new PluginCache<string>({ a: '1', b: '2', c: '3' }, [
'a',
'b',
'c',
]);
cache.get('a'); // access oldest key
const serialized = cache.toSerializable();
// 'a' should move to end, b and c stay in front
expect(serialized.accessOrder).toEqual(['b', 'c', 'a']);
});
it('should put set keys at end of access order', () => {
const cache = new PluginCache<string>({ a: '1', b: '2' }, ['a', 'b']);
cache.set('c', '3');
const serialized = cache.toSerializable();
expect(serialized.accessOrder).toEqual(['a', 'b', 'c']);
expect(serialized.entries['c']).toBe('3');
});
it('should dedupe session log keeping last occurrence', () => {
const cache = new PluginCache<string>({ a: '1', b: '2' }, ['a', 'b']);
cache.get('a');
cache.get('b');
cache.get('a'); // a is most recent now
const serialized = cache.toSerializable();
expect(serialized.accessOrder).toEqual(['b', 'a']);
});
it('should cap entries at maxEntries, dropping oldest', () => {
const entries: Record<string, string> = {};
const order: string[] = [];
for (let i = 0; i < 10; i++) {
entries[`key${i}`] = `val${i}`;
order.push(`key${i}`);
}
const cache = new PluginCache<string>(entries, order);
const serialized = cache.toSerializable(5);
expect(Object.keys(serialized.entries)).toHaveLength(5);
// Should keep the last 5 (most recent by order)
expect(serialized.entries['key5']).toBe('val5');
expect(serialized.entries['key9']).toBe('val9');
expect(serialized.entries['key0']).toBeUndefined();
expect(serialized.accessOrder).toEqual([
'key5',
'key6',
'key7',
'key8',
'key9',
]);
});
it('should not cap when under maxEntries', () => {
const cache = new PluginCache<string>({ a: '1', b: '2' }, ['a', 'b']);
const serialized = cache.toSerializable(500);
expect(Object.keys(serialized.entries)).toHaveLength(2);
});
it('should handle new keys not in original accessOrder', () => {
const cache = new PluginCache<string>({ a: '1' }, []);
// 'a' exists but has no prior access order entry
const serialized = cache.toSerializable();
expect(serialized.accessOrder).toContain('a');
});
it('should prioritize accessed keys over unaccessed when capping', () => {
const cache = new PluginCache<string>({ old: 'x', fresh: 'y' }, [
'old',
'fresh',
]);
cache.get('fresh'); // mark as recently accessed
const serialized = cache.toSerializable(1);
// Should keep 'fresh' (accessed) over 'old' (not accessed this session)
expect(serialized.entries['fresh']).toBe('y');
expect(serialized.entries['old']).toBeUndefined();
});
});
describe('readPluginCache', () => {
it('should read current format with entries and accessOrder', () => {
const cachePath = join(tempDir, 'cache.json');
writeFileSync(
cachePath,
JSON.stringify({
entries: { a: '1', b: '2' },
accessOrder: ['b', 'a'],
})
);
const cache = readPluginCache<string>(cachePath);
expect(cache.get('a')).toBe('1');
expect(cache.get('b')).toBe('2');
});
it('should migrate legacy plain Record format', () => {
const cachePath = join(tempDir, 'legacy.json');
writeFileSync(cachePath, JSON.stringify({ a: '1', b: '2' }));
const cache = readPluginCache<string>(cachePath);
expect(cache.get('a')).toBe('1');
expect(cache.get('b')).toBe('2');
});
it('should return empty cache if file does not exist', () => {
const cache = readPluginCache<string>(join(tempDir, 'nope.json'));
expect(cache.has('anything')).toBe(false);
});
it('should return empty cache if file is corrupted', () => {
const cachePath = join(tempDir, 'bad.json');
writeFileSync(cachePath, 'not json!!!');
const cache = readPluginCache<string>(cachePath);
expect(cache.has('anything')).toBe(false);
});
it('should return empty cache when NX_CACHE_PROJECT_GRAPH is false', () => {
const cachePath = join(tempDir, 'cache.json');
writeFileSync(
cachePath,
JSON.stringify({ entries: { a: '1' }, accessOrder: ['a'] })
);
const origEnv = process.env.NX_CACHE_PROJECT_GRAPH;
process.env.NX_CACHE_PROJECT_GRAPH = 'false';
try {
const cache = readPluginCache<string>(cachePath);
expect(cache.has('a')).toBe(false);
} finally {
if (origEnv === undefined) {
delete process.env.NX_CACHE_PROJECT_GRAPH;
} else {
process.env.NX_CACHE_PROJECT_GRAPH = origEnv;
}
}
});
});
describe('safeWritePluginCache', () => {
it('should write cache with entries and accessOrder', () => {
const cachePath = join(tempDir, 'plugin-cache.json');
const cache = new PluginCache<string>({ a: '1' }, ['a']);
safeWritePluginCache(cachePath, cache);
const written = JSON.parse(readFileSync(cachePath, 'utf-8'));
expect(written.entries).toEqual({ a: '1' });
expect(written.accessOrder).toEqual(['a']);
});
it('should cap entries when maxEntries is set', () => {
const cachePath = join(tempDir, 'plugin-cache.json');
const entries: Record<string, string> = {};
const order: string[] = [];
for (let i = 0; i < 10; i++) {
entries[`key${i}`] = `val${i}`;
order.push(`key${i}`);
}
const cache = new PluginCache<string>(entries, order);
safeWritePluginCache(cachePath, cache, { maxEntries: 3 });
const written = JSON.parse(readFileSync(cachePath, 'utf-8'));
expect(Object.keys(written.entries)).toHaveLength(3);
expect(written.entries['key7']).toBe('val7');
expect(written.entries['key9']).toBe('val9');
});
it('should not throw on write failure', () => {
const dirAsFile = join(tempDir, 'im-a-dir');
mkdirSync(dirAsFile);
const cache = new PluginCache<string>({ a: '1' }, ['a']);
// Writing to a directory path should fail but not throw
expect(() => safeWritePluginCache(dirAsFile, cache)).not.toThrow();
});
it('should write hash file after successful cache write', () => {
const cachePath = join(tempDir, 'plugin-cache.json');
const hashPath = join(tempDir, 'plugin-cache.hash');
const cache = new PluginCache<string>({ a: '1' }, ['a']);
safeWritePluginCache(cachePath, cache, {
hashPath,
hash: 'abc123',
});
expect(readFileSync(hashPath, 'utf-8')).toBe('abc123');
});
it('should NOT write hash file if cache write fails', () => {
const dirAsFile = join(tempDir, 'im-a-dir');
mkdirSync(dirAsFile);
const hashPath = join(tempDir, 'plugin-cache.hash');
const cache = new PluginCache<string>({ a: '1' }, ['a']);
safeWritePluginCache(dirAsFile, cache, {
hashPath,
hash: 'abc123',
});
expect(existsSync(hashPath)).toBe(false);
});
});
describe('safeWriteFileCache', () => {
it('should write data successfully', () => {
const cachePath = join(tempDir, 'cache.json');
safeWriteFileCache(cachePath, JSON.stringify({ foo: 'bar' }));
expect(JSON.parse(readFileSync(cachePath, 'utf-8'))).toEqual({
foo: 'bar',
});
});
it('should create parent directories', () => {
const cachePath = join(tempDir, 'nested', 'dir', 'cache.json');
safeWriteFileCache(cachePath, 'data');
expect(readFileSync(cachePath, 'utf-8')).toBe('data');
});
it('should not throw on write failure', () => {
const dirAsFile = join(tempDir, 'im-a-dir');
mkdirSync(dirAsFile);
expect(() => safeWriteFileCache(dirAsFile, 'data')).not.toThrow();
});
});
});
+220
View File
@@ -0,0 +1,220 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from 'node:fs';
import { dirname } from 'node:path';
import { logger } from './logger';
/**
* On-disk format for plugin caches with LRU metadata.
*/
interface PluginCacheData<T> {
entries: Record<string, T>;
accessOrder: string[];
}
/**
* A plugin cache with explicit get/set that tracks access order for LRU eviction.
*
* Access tracking is append-only during the session (just an array push).
* Dedup and capping happen once at write time via `toSerializable()`.
*/
export class PluginCache<T> {
private entries: Record<string, T>;
private accessOrder: string[];
private sessionLog: string[] = [];
constructor(entries: Record<string, T> = {}, accessOrder: string[] = []) {
this.entries = entries;
this.accessOrder = accessOrder;
}
get(key: string): T | undefined {
if (key in this.entries) {
this.sessionLog.push(key);
return this.entries[key];
}
return undefined;
}
set(key: string, value: T): void {
this.entries[key] = value;
this.sessionLog.push(key);
}
has(key: string): boolean {
return key in this.entries;
}
/**
* Serialize for writing to disk.
*
* 1. Dedupes the session log (last occurrence = most recent)
* 2. Keys not accessed this session keep their prior order at the front
* 3. If maxEntries is set, drops oldest entries from the front
*/
toSerializable(maxEntries?: number): PluginCacheData<T> {
const accessed = new Set<string>();
const recentFirst: string[] = [];
// Walk session log backwards so last occurrence wins
for (let i = this.sessionLog.length - 1; i >= 0; i--) {
const key = this.sessionLog[i];
if (!accessed.has(key) && key in this.entries) {
accessed.add(key);
recentFirst.push(key);
}
}
// Build final order: unaccessed keys first (old order), then accessed keys (most recent last)
const order: string[] = [];
for (const key of this.accessOrder) {
if (!accessed.has(key) && key in this.entries) {
order.push(key);
}
}
// Add any new keys not in the original accessOrder and not in session log
for (const key of Object.keys(this.entries)) {
if (!accessed.has(key) && !order.includes(key)) {
order.push(key);
}
}
// Append accessed keys, reversed so most recent is last
for (let i = recentFirst.length - 1; i >= 0; i--) {
order.push(recentFirst[i]);
}
// Cap if needed
if (maxEntries && order.length > maxEntries) {
const toKeep = order.slice(order.length - maxEntries);
const keepSet = new Set(toKeep);
const capped: Record<string, T> = {};
for (const k of toKeep) {
capped[k] = this.entries[k];
}
return { entries: capped, accessOrder: toKeep };
}
return { entries: { ...this.entries }, accessOrder: order };
}
}
/**
* Reads a plugin cache from disk, returning a PluginCache instance.
*
* Backward compatible with old format: if file contains a plain
* Record<string, T>, all keys start in access order as-is.
*/
export function readPluginCache<T>(cachePath: string): PluginCache<T> {
try {
if (
process.env.NX_CACHE_PROJECT_GRAPH === 'false' ||
!existsSync(cachePath)
) {
return new PluginCache<T>();
}
const raw = JSON.parse(readFileSync(cachePath, 'utf-8'));
// Current format: { entries, accessOrder }
if (
raw &&
typeof raw === 'object' &&
'entries' in raw &&
'accessOrder' in raw &&
Array.isArray(raw.accessOrder)
) {
return new PluginCache<T>(raw.entries, raw.accessOrder);
}
// Legacy format: plain Record<string, T>
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return new PluginCache<T>(raw, Object.keys(raw));
}
return new PluginCache<T>();
} catch {
return new PluginCache<T>();
}
}
export interface SafeWriteOptions {
/** Maximum number of entries to keep. Oldest (least recently used) are dropped. */
maxEntries?: number;
/** Path to a hash file that validates the cache */
hashPath?: string;
/** Hash value to write to the hash file */
hash?: string;
}
/**
* Safely writes a PluginCache to disk.
*
* - Caps entries if maxEntries is set
* - On write failure: warns and removes corrupted file (never throws)
* - Writes hash file only after successful cache write
*/
export function safeWritePluginCache<T>(
cachePath: string,
cache: PluginCache<T>,
options?: SafeWriteOptions
): void {
try {
mkdirSync(dirname(cachePath), { recursive: true });
const data = cache.toSerializable(options?.maxEntries);
writeFileSync(cachePath, JSON.stringify(data));
} catch (e) {
logger.warn(
`Failed to write plugin cache at ${cachePath}: ${
e instanceof Error ? e.message : 'unknown error'
}. Continuing without cache.`
);
tryRemoveFile(cachePath);
if (options?.hashPath) {
tryRemoveFile(options.hashPath);
}
return;
}
// Hash file written only after successful cache write
if (options?.hashPath && options?.hash) {
try {
mkdirSync(dirname(options.hashPath), { recursive: true });
writeFileSync(options.hashPath, options.hash);
} catch (e) {
logger.warn(
`Failed to write cache hash file at ${options.hashPath}: ${
e instanceof Error ? e.message : 'unknown error'
}`
);
}
}
}
/**
* Safely writes already-stringified content to a cache file on disk.
* On failure: warns and removes existing file (never throws).
*/
export function safeWriteFileCache(cachePath: string, content: string): void {
try {
mkdirSync(dirname(cachePath), { recursive: true });
writeFileSync(cachePath, content);
} catch (e) {
logger.warn(
`Failed to write cache at ${cachePath}: ${
e instanceof Error ? e.message : 'unknown error'
}. Removing existing cache file.`
);
tryRemoveFile(cachePath);
}
}
function tryRemoveFile(path: string): void {
try {
unlinkSync(path);
} catch {
// Best effort
}
}
+21 -32
View File
@@ -7,10 +7,8 @@ import {
joinPathFragments,
normalizePath,
type ProjectConfiguration,
readJsonFile,
type TargetConfiguration,
type TargetDependencyConfig,
writeJsonFile,
} from '@nx/devkit';
import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
import { loadConfigFile } from '@nx/devkit/src/utils/config-utils';
@@ -22,6 +20,11 @@ import { readdirSync } from 'node:fs';
import { dirname, join, parse, posix, relative, resolve } from 'node:path';
import { hashObject } from 'nx/src/hasher/file-hasher';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import {
PluginCache,
readPluginCache,
safeWritePluginCache,
} from 'nx/src/utils/plugin-cache-utils';
import { getFilesInDirectoryUsingContext } from 'nx/src/utils/workspace-context';
import { getReporterOutputs, type ReporterOutput } from '../utils/reporters';
@@ -40,25 +43,6 @@ interface NormalizedOptions {
type PlaywrightTargets = Pick<ProjectConfiguration, 'targets' | 'metadata'>;
function readTargetsCache(
cachePath: string
): Record<string, PlaywrightTargets> {
try {
return process.env.NX_CACHE_PROJECT_GRAPH !== 'false'
? readJsonFile(cachePath)
: {};
} catch {
return {};
}
}
function writeTargetsToCache(
cachePath: string,
results: Record<string, PlaywrightTargets>
) {
writeJsonFile(cachePath, results);
}
const playwrightConfigGlob = '**/playwright.config.{js,ts,cjs,cts,mjs,mts}';
export const createNodes: CreateNodesV2<PlaywrightPluginOptions> = [
playwrightConfigGlob,
@@ -68,17 +52,17 @@ export const createNodes: CreateNodesV2<PlaywrightPluginOptions> = [
workspaceDataDirectory,
`playwright-${optionsHash}.hash`
);
const targetsCache = readTargetsCache(cachePath);
const cache = readPluginCache<PlaywrightTargets>(cachePath);
try {
return await createNodesFromFiles(
(configFile, options, context) =>
createNodesInternal(configFile, options, context, targetsCache),
createNodesInternal(configFile, options, context, cache),
configFilePaths,
options,
context
);
} finally {
writeTargetsToCache(cachePath, targetsCache);
safeWritePluginCache(cachePath, cache);
}
},
];
@@ -89,7 +73,7 @@ async function createNodesInternal(
configFilePath: string,
options: PlaywrightPluginOptions,
context: CreateNodesContextV2,
targetsCache: Record<string, PlaywrightTargets>
cache: PluginCache<PlaywrightTargets>
) {
const projectRoot = dirname(configFilePath);
@@ -114,13 +98,18 @@ async function createNodesInternal(
[getLockFileName(detectPackageManager(context.workspaceRoot))]
);
targetsCache[hash] ??= await buildPlaywrightTargets(
configFilePath,
projectRoot,
normalizedOptions,
context
);
const { targets, metadata } = targetsCache[hash];
if (!cache.has(hash)) {
cache.set(
hash,
await buildPlaywrightTargets(
configFilePath,
projectRoot,
normalizedOptions,
context
)
);
}
const { targets, metadata } = cache.get(hash);
return {
projects: {