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
15 changes: 15 additions & 0 deletions .changeset/chubby-sites-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@eddeee888/gcg-typescript-resolver-files': minor
---

Add LRU cache for resolver types vs mappers type check

Resolver types vs mappers check is the most expensive check in this plugin. We need this to determine which resolvers need to be added to avoid runtime errors.

Previously in watch mode, we run this expensive check every run, even if the schema or mappers don't change. Adding a LRU cache helps said scenario by re-using a previously parsed data.

This ensures the codemod scenarios, where schema or mappers don't change, run as efficiently as possible:

- ensuring resolver exports exist
- ensuring resolvers are injected correctly to avoid runtime issues
- etc.
15 changes: 10 additions & 5 deletions packages/typescript-resolver-files/benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,18 @@ Presets (in `generateSchema.ts`): `small` (~20 types), `medium` (~100),

### What it measures

Each iteration runs in a fresh child process and does codegen **twice**:
Each iteration runs in a fresh child process and does codegen **three times**:

- **COLD** — nothing generated yet (true first run / CI).
- **WARM** — output already on disk and the preset's module-level ts-morph
`Project` singleton reused (i.e. a `--watch` re-run).

The runner prints a per-phase median/min/max table for both, plus two totals:
- **WARM, cache hit** — output on disk + the preset's module-level ts-morph
`Project` reused, and nothing changed, so the phase-7 result cache hits (a
`--watch` re-run after editing a resolver implementation).
- **WARM, cache miss** — a mapper file is edited between runs, so the phase-7
cache key changes and `getGraphQLObjectTypeResolversToGenerate` recomputes (a
`--watch` re-run after editing a mapper). Cheaper than COLD because ts-morph is
already warm, but not free like the cache hit.

The runner prints a per-phase median/min/max table for each, plus two totals:
`preset phases subtotal` (work inside the preset) and `total generate() wall`
(the whole pipeline, including downstream plugin rendering + file writes). The
gap between them is downstream codegen cost.
Expand Down
54 changes: 46 additions & 8 deletions packages/typescript-resolver-files/benchmark/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
*/
import { spawnSync } from 'child_process';
import { performance } from 'perf_hooks';
import * as fs from 'fs';
import * as path from 'path';
import { CodegenContext, generate } from '@graphql-codegen/cli';
import config from './codegen.js';
import {
Expand Down Expand Up @@ -67,8 +69,34 @@ interface SingleResult {
stats: { modules: number; types: number; mappers: number };
cold: RunTimings;
warm: RunTimings;
warmMapperChanged: RunTimings;
}

/**
* Edit one mapper file on disk so its contents (and therefore the phase-7 cache
* key) change. Used to measure the cache-miss path: the ts-morph project is still
* warm, but `getGraphQLObjectTypeResolversToGenerate` must recompute.
*/
const changeOneMapperFile = (modulesDir: string): void => {
for (const entry of fs.readdirSync(modulesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const dir = path.join(modulesDir, entry.name);
const mapperFile = fs
.readdirSync(dir)
.find((f) => f.endsWith('.mappers.ts'));
if (mapperFile) {
fs.appendFileSync(
path.join(dir, mapperFile),
`\n// benchmark: mapper edited at ${Date.now()}\n`
);
return;
}
}
throw new Error('No mapper file found to edit');
};

/**
* Run codegen once against the current on-disk state, returning the wall time
* and per-phase timings. Cold vs warm is decided by the caller (i.e. by whether
Expand Down Expand Up @@ -96,10 +124,13 @@ const runProfiledGenerate = async (): Promise<RunTimings> => {
};

/**
* Produce one data point for a preset: (re)generate the workload fresh, then
* measure a COLD run (nothing generated yet) immediately followed by a WARM run
* (output on disk + the preset's ts-morph Project singleton reused, i.e. a watch
* re-run). Runs in its own process so COLD is genuinely cold.
* Produce one data point for a preset. In its own process (so COLD is genuinely
* cold), measure three runs:
* - COLD: nothing generated yet.
* - WARM: output on disk + the ts-morph Project singleton reused, and nothing
* changed -> the phase-7 cache HITS (watch re-run after editing a resolver).
* - WARM + mapper changed: a mapper file is edited between runs, so the phase-7
* cache MISSES and it recomputes (watch re-run after editing a mapper).
*/
const run = async (presetName: string): Promise<SingleResult> => {
const preset = workloadPresets[presetName];
Expand All @@ -110,15 +141,18 @@ const run = async (presetName: string): Promise<SingleResult> => {
).join(', ')}`
);
}
const { stats } = generateWorkload({
const { modulesDir, stats } = generateWorkload({
preset,
workloadDir: defaultWorkloadDir(),
});

const cold = await runProfiledGenerate(); // nothing on disk -> cold
const warm = await runProfiledGenerate(); // output exists + singleton reused -> warm
const warm = await runProfiledGenerate(); // unchanged -> phase-7 cache hit

changeOneMapperFile(modulesDir); // invalidate the phase-7 cache key
const warmMapperChanged = await runProfiledGenerate(); // cache miss -> recompute

return { preset: presetName, stats, cold, warm };
return { preset: presetName, stats, cold, warm, warmMapperChanged };
};

// ---------- orchestrator ----------
Expand Down Expand Up @@ -235,9 +269,13 @@ const orchestrate = async (
results.map((r) => r.cold)
);
printTable(
'WARM (watch re-run)',
'WARM (watch re-run, nothing changed — cache hit)',
results.map((r) => r.warm)
);
printTable(
'WARM (watch re-run, mapper changed — cache miss)',
results.map((r) => r.warmMapperChanged)
);
};

// ---------- entry ----------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { createHash } from 'crypto';
import type { Project, SourceFile } from 'ts-morph';
import type { TypeMappersMap } from '../parseTypeMappers/index.js';
import type { GraphQLObjectTypeResolversToGenerate } from './getGraphQLObjectTypeResolversToGenerate.js';

type Cache = {
get(key: string): GraphQLObjectTypeResolversToGenerate | undefined;
set(
key: string,
value: GraphQLObjectTypeResolversToGenerate
): GraphQLObjectTypeResolversToGenerate;
createCacheKey({
mode,
typesSourceFile,
typeMappersMap,
tsMorphProject,
}: {
mode: string;
typesSourceFile: SourceFile;
typeMappersMap: TypeMappersMap;
tsMorphProject: Project;
}): string;
};

/**
* Maximum number of entries kept in the cache. Each entry corresponds to one
* distinct `(mode + types file + mappers)` state. A long watch session can visit
* many such states, and separate `generates` targets each contribute their own
* key, so we keep the N most-recently-used entries and evict the rest to bound
* memory. N is comfortably larger than the number of concurrent targets a single
* codegen run has.
*/
const MAX_CACHE_ENTRIES = 50;

export const createCache = (): Cache => {
/**
* Cache of previous runs' results, so the ts-morph type-checker work is skipped
* when nothing it depends on has changed (e.g. a watch re-run where only a
* resolver implementation file was edited). The result is a pure function of
* - `mode`
* - the generated types file
* - the mapper file contents
*
* so the key is a hash of exactly those. Reused only on an exact key match, so
* it can never go stale. A `Map` is used because its insertion order gives a
* cheap LRU: the first key is the least-recently-used.
*/
const resultCache = new Map<string, GraphQLObjectTypeResolversToGenerate>();

return {
get(key) {
const result = resultCache.get(key);
if (result === undefined) {
return undefined;
}

// Mark as most-recently-used by re-inserting at the end.
resultCache.delete(key);
resultCache.set(key, result);

return structuredClone(result);
},
/**
* set
* Create a structured clone in the cache
* because downstream can update the value object
*/
set(key, value) {
// Re-insert so this key becomes the most-recently-used entry.
resultCache.delete(key);
resultCache.set(key, structuredClone(value));

// Evict least-recently-used entries beyond the cap.
while (resultCache.size > MAX_CACHE_ENTRIES) {
const lruKey = resultCache.keys().next().value;
if (lruKey === undefined) {
break;
}
resultCache.delete(lruKey);
}

return value;
},

/**
* Hash of everything the result depends on: the mode, the generated types file
* text (captures all schema-derived inputs), and each mapper file's contents.
* Same key => same result.
*/
createCacheKey({ mode, typesSourceFile, typeMappersMap, tsMorphProject }) {
const hash = createHash('sha1');
hash.update(mode);
hash.update(' ');
hash.update(typesSourceFile.getFullText());

// The parsed mapper map (which schema type -> which mapper declaration) depends
// on config such as mappersSuffix, so hash it too: mapper file text alone would
// not distinguish two configs that read the same files differently.
hash.update(' ');
hash.update(
JSON.stringify(
Object.entries(typeMappersMap).sort(([a], [b]) => (a < b ? -1 : 1))
)
);

const mapperFilenames = [
...new Set(Object.values(typeMappersMap).map((m) => m.mapper.filename)),
].sort();
for (const filename of mapperFilenames) {
const sourceFile = tsMorphProject.getSourceFile(filename);
hash.update(' ');
hash.update(filename);
hash.update(' ');
hash.update(sourceFile ? sourceFile.getFullText() : '');
}

return hash.digest('hex');
},
} satisfies Cache;
};
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ import {
} from './getNodePropertyMap.js';
import type { ParsedGraphQLSchemaMeta } from '../parseGraphQLSchema/index.js';
import type { GeneratedTypesFileMeta } from '../generateResolverFiles/index.js';
import { createCache } from './cache.js';

export type GraphQLObjectTypeResolversToGenerate = Record<
string,
Record<string, { resolverName: string; resolverDeclaration: string }>
>;

const cache = createCache();

export const getGraphQLObjectTypeResolversToGenerate = ({
mode,
tsMorphProject,
Expand All @@ -45,6 +48,18 @@ export const getGraphQLObjectTypeResolversToGenerate = ({
return {};
}

const cacheKey = cache.createCacheKey({
mode,
typesSourceFile,
typeMappersMap,
tsMorphProject,
});

const cachedResult = cache.get(cacheKey);
if (cachedResult) {
return cachedResult;
}

/**
* `generatedTypesFileMeta.generatedResolverTypes.userDefined` is `schemaType` -> `generatedResolverTypes`
* We will be parsing `types.generated.ts` file for the `generatedResolverTypes`
Expand Down Expand Up @@ -73,6 +88,7 @@ export const getGraphQLObjectTypeResolversToGenerate = ({
{}
);

// "Fast" mode
if (mode === 'fast') {
// 1. Get property map of all schema types
const resolverTypesMap: Record<
Expand Down Expand Up @@ -172,9 +188,11 @@ export const getGraphQLObjectTypeResolversToGenerate = ({
});
});

return result;
const newResult = cache.set(cacheKey, result);
return newResult;
}

// "Smart" mode
// 1. Get property map of all schema types
const schemaResolversTypePropertyMap: Record<string, NodePropertyMap> = {};

Expand Down Expand Up @@ -269,8 +287,8 @@ export const getGraphQLObjectTypeResolversToGenerate = ({
}
);
});

return result;
const newResult = cache.set(cacheKey, result);
return newResult;
};

const mustGetMapperOriginalDeclarationNode = ({
Expand Down