From 5df6609134b92690fc67472f99423c4b8b36aad8 Mon Sep 17 00:00:00 2001 From: neverland Date: Fri, 7 Aug 2026 21:44:59 +0800 Subject: [PATCH] perf(fmt): cache unsupported parser results --- packages/rstack/src/fmt/cacheStore.ts | 24 ++++--- packages/rstack/src/fmt/runner.ts | 64 ++++++++++++++----- packages/rstack/src/fmt/worker.ts | 13 +++- packages/rstack/tests/fmt/cacheStore.test.ts | 13 ++++ packages/rstack/tests/fmt/runnerCache.test.ts | 34 ++++++++++ .../tests/fmt/runnerWorkerPreflight.test.ts | 32 ++++++++++ packages/rstack/tests/fmt/worker.test.ts | 20 ++++-- website/docs/en/guide/formatting.mdx | 2 +- website/docs/zh/guide/formatting.mdx | 2 +- 9 files changed, 166 insertions(+), 38 deletions(-) diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts index e0ab737..cd725df 100644 --- a/packages/rstack/src/fmt/cacheStore.ts +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -5,8 +5,10 @@ import path from 'node:path'; const fmtCacheFileName = 'v1.json'; const fmtCacheVersion = 1; -type FmtCacheState = 'clean' | 'dirty'; -type FmtCacheEntry = readonly [contentHash: string, optionsHash: string, state: FmtCacheState]; +type FmtCacheState = 'clean' | 'dirty' | 'unsupported'; +type FmtCacheEntry = + | readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty'] + | readonly [contentHash: null, optionsHash: string, state: 'unsupported']; interface FmtCacheFile { version: typeof fmtCacheVersion; @@ -28,13 +30,14 @@ const createEmptyCache = (namespace: string): FmtCacheFile => ({ }); const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => { - if ( - !Array.isArray(value) || - value.length !== 3 || - typeof value[0] !== 'string' || - typeof value[1] !== 'string' || - (value[2] !== 'clean' && value[2] !== 'dirty') - ) { + if (!Array.isArray(value) || value.length !== 3 || typeof value[1] !== 'string') { + return; + } + + if (value[2] === 'unsupported') { + return value[0] === null ? [null, value[1], value[2]] : undefined; + } + if (typeof value[0] !== 'string' || (value[2] !== 'clean' && value[2] !== 'dirty')) { return; } @@ -120,7 +123,8 @@ class FmtCacheStoreImpl implements FmtCacheStore { return; } - this.#cache.files[filePath] = [entry[0], entry[1], entry[2]]; + this.#cache.files[filePath] = + entry[2] === 'unsupported' ? [null, entry[1], entry[2]] : [entry[0], entry[1], entry[2]]; this.#changed = true; } diff --git a/packages/rstack/src/fmt/runner.ts b/packages/rstack/src/fmt/runner.ts index cde24b5..7b65f02 100644 --- a/packages/rstack/src/fmt/runner.ts +++ b/packages/rstack/src/fmt/runner.ts @@ -22,6 +22,12 @@ interface FmtFileRun { entry?: FmtCacheEntry; } +interface FmtFileRunTask { + file: FmtFileRequest; + key?: string; + cache?: FmtFileCache; +} + interface RunCache { store: FmtCacheStore; resolveKey: ReturnType; @@ -81,13 +87,8 @@ const loadPluginFingerprints = async ( return fingerprints; }; -/** Converts a formatter outcome into the shared per-file result. */ -const runFmtFile = async ( - file: FmtFileRequest, - shouldWrite: boolean, - formatFile: FormatFile, - cache?: RunCache, -): Promise => { +/** Resolves the portable cache identity before work is dispatched. */ +const createFmtFileRunTask = (file: FmtFileRequest, cache?: RunCache): FmtFileRunTask => { let key: string | undefined; let fileCache: FmtFileCache | undefined; @@ -106,8 +107,29 @@ const runFmtFile = async ( } } + return { file, key, cache: fileCache }; +}; + +const isCachedUnsupported = ({ cache }: FmtFileRunTask): boolean => { + if (!cache?.entry) { + return false; + } + return cache.entry[1] === cache.optionsHash && cache.entry[2] === 'unsupported'; +}; + +/** Converts a formatter outcome into the shared per-file result. */ +const runFmtFile = async ( + task: FmtFileRunTask, + shouldWrite: boolean, + formatFile: FormatFile, +): Promise => { + if (isCachedUnsupported(task)) { + return { outcome: 'unsupported' }; + } + + const { file, key, cache } = task; try { - const result = await formatFile(file, shouldWrite, fileCache); + const result = await formatFile(file, shouldWrite, cache); const outcome: FmtFileOutcome = result.status === 'changed' ? { @@ -133,23 +155,22 @@ const runFmtFile = async ( /** Starts slower Markdown parsers first while preserving order within both priority groups. */ const runPriorityFmtFiles = async ( - files: FmtFileRequest[], + tasks: FmtFileRunTask[], shouldWrite: boolean, formatFile: FormatFile, - cache?: RunCache, ): Promise => { const priority: number[] = []; const rest: number[] = []; - for (let index = 0; index < files.length; index++) { - (isMarkdown(files[index]) ? priority : rest).push(index); + for (let index = 0; index < tasks.length; index++) { + (isMarkdown(tasks[index].file) ? priority : rest).push(index); } const order = priority.concat(rest); const outcomes = await Promise.all( - order.map((index) => runFmtFile(files[index], shouldWrite, formatFile, cache)), + order.map((index) => runFmtFile(tasks[index], shouldWrite, formatFile)), ); - const results = new Array(files.length); + const results = new Array(tasks.length); for (let index = 0; index < order.length; index++) { results[order[index]] = outcomes[index]; } @@ -163,15 +184,24 @@ const runFmtFilesInWorkerPool = async ( maxWorkers?: number, cache?: RunCache, ): Promise => { + const tasks = files.map((file) => createFmtFileRunTask(file, cache)); + const pendingFileCount = tasks.reduce( + (count, task) => count + (isCachedUnsupported(task) ? 0 : 1), + 0, + ); + if (pendingFileCount === 0) { + return { files: [], processedFileCount: 0 }; + } + const { createFmtWorkerPool } = await import('./workerPool.ts'); - const workerPool = await createFmtWorkerPool(files.length, maxWorkers); + const workerPool = await createFmtWorkerPool(pendingFileCount, maxWorkers); try { const results = workerPool.workerCount >= minPriorityWorkers - ? await runPriorityFmtFiles(files, shouldWrite, workerPool.formatFile, cache) + ? await runPriorityFmtFiles(tasks, shouldWrite, workerPool.formatFile) : await Promise.all( - files.map((file) => runFmtFile(file, shouldWrite, workerPool.formatFile, cache)), + tasks.map((task) => runFmtFile(task, shouldWrite, workerPool.formatFile)), ); const processedFiles: FmtFileResult[] = []; let processedFileCount = 0; diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index 50c29fc..c48198d 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -42,9 +42,13 @@ const formatFile = async ({ }; if (cache?.entry && cache.entry[1] === cache.optionsHash) { + const { entry } = cache; + if (entry[2] === 'unsupported') { + return { status: 'unsupported' }; + } + sourceBuffer = readFileSync(file.path); contentHash = hashContent(sourceBuffer); - const { entry } = cache; if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) { return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; } @@ -53,7 +57,12 @@ const formatFile = async ({ const { formatFmtSource } = await import('./format.ts'); const result = await formatFmtSource(file, () => (source ??= readSource())); if (result.status === 'unsupported') { - return { status: 'unsupported' }; + return cache + ? { + status: 'unsupported', + cacheEntry: [null, cache.optionsHash, 'unsupported'], + } + : { status: 'unsupported' }; } const unchanged = result.source === result.formatted; diff --git a/packages/rstack/tests/fmt/cacheStore.test.ts b/packages/rstack/tests/fmt/cacheStore.test.ts index dcee1a6..fcedc8c 100644 --- a/packages/rstack/tests/fmt/cacheStore.test.ts +++ b/packages/rstack/tests/fmt/cacheStore.test.ts @@ -7,6 +7,7 @@ import { withTempProject } from './helpers.ts'; const namespace = 'test-namespace'; const firstEntry = ['content-a', 'options-a', 'clean'] as const; const secondEntry = ['content-b', 'options-b', 'dirty'] as const; +const unsupportedEntry = [null, 'options-c', 'unsupported'] as const; const readCache = (filePath: string): FmtCacheFile => JSON.parse(readFileSync(filePath, 'utf8')) as FmtCacheFile; @@ -20,11 +21,13 @@ test('writes entries that can be loaded by another store', async () => { expect(existsSync(cachePath)).toBe(false); store.set('src/a.ts', firstEntry); + store.set('src/unknown.fixture', unsupportedEntry); expect(await store.save()).toBe(true); expect(await store.save()).toBe(false); const loaded = await loadFmtCacheStore(cachePath, namespace); expect(loaded.get('src/a.ts')).toEqual(firstEntry); + expect(loaded.get('src/unknown.fixture')).toEqual(unsupportedEntry); }); }); @@ -68,6 +71,16 @@ test('discards invalid data and entries from another namespace', async () => { namespace, files: { 'src/a.ts': ['content', 'options', 'unknown'] }, }), + JSON.stringify({ + version: fmtCacheVersion, + namespace, + files: { 'src/a.ts': ['content', 'options', 'unsupported'] }, + }), + JSON.stringify({ + version: fmtCacheVersion, + namespace, + files: { 'src/a.ts': [null, 'options', 'clean'] }, + }), ]; for (const content of invalidContents) { diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index 05c9aee..ba924b7 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -121,6 +121,40 @@ test('invalidates entries when final options change', async () => { }); }); +test('caches unsupported parser results until final options change', async () => { + await withTempProject(async (rootPath) => { + const filePath = writeProjectFile(rootPath, 'data.unknown', '{"value":true}'); + const cache = createCache(rootPath); + const unsupported = createRequest(filePath, {}); + + const first = await run([unsupported], 'check', cache); + expect(first).toEqual({ + exitCode: 2, + files: [], + processedFileCount: 0, + }); + expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([ + null, + createOptionsHasher()(unsupported.options), + 'unsupported', + ]); + + await expect(run([unsupported], 'check', cache)).resolves.toEqual(first); + + const supported = createRequest(filePath, { parser: 'json' }); + await expect(run([supported], 'check', cache)).resolves.toMatchObject({ + exitCode: 1, + files: [{ path: filePath, status: 'different' }], + processedFileCount: 1, + }); + expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([ + sha256(readFileSync(filePath)), + createOptionsHasher()(supported.options), + 'dirty', + ]); + }); +}); + test('caches only plugins with stable fingerprints', async () => { await withTempProject(async (rootPath) => { const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}'); diff --git a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts index 127beae..78bf9d7 100644 --- a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts +++ b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts @@ -1,5 +1,8 @@ import { readFileSync } from 'node:fs'; +import path from 'node:path'; import { beforeEach, expect, rs, test } from 'rstack/test'; +import { cacheNamespace, createOptionsHasher } from '../../src/fmt/cacheIdentity.ts'; +import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts'; import { runFmtFiles } from '../../src/fmt/runner.ts'; import type { FmtFileRequest } from '../../src/fmt/types.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; @@ -51,3 +54,32 @@ test('does not start the worker pool when there are no files', async () => { }); expect(mocks.createFmtWorkerPoolCalls).toEqual([]); }); + +test('does not start the worker pool when every parser result is cached as unsupported', async () => { + await withTempProject(async (rootPath) => { + const filePath = writeProjectFile(rootPath, 'example.unknown', 'plain text'); + const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json'); + const file: FmtFileRequest = { path: filePath, options: {} }; + const optionsHash = createOptionsHasher()(file.options); + if (optionsHash === undefined) { + throw new Error('Expected cacheable formatter options.'); + } + + const store = await loadFmtCacheStore(cachePath, cacheNamespace); + store.set('example.unknown', [null, optionsHash, 'unsupported']); + await expect(store.save()).resolves.toBe(true); + + await expect( + runFmtFiles({ + files: [file], + mode: 'check', + cache: { filePath: cachePath, rootPath }, + }), + ).resolves.toEqual({ + exitCode: 2, + files: [], + processedFileCount: 0, + }); + expect(mocks.createFmtWorkerPoolCalls).toEqual([]); + }); +}); diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 4b71eac..11ebf0c 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -48,25 +48,28 @@ test('returns cached states before resolving the parser', async () => { await withTempProject(async (rootPath) => { const source = 'const value=1'; const filePath = writeProjectFile(rootPath, 'example.ts', source); + const missingPath = path.join(rootPath, 'missing.unknown'); const contentHash = sha256(source); const optionsHash = 'options'; - for (const [state, shouldWrite, status] of [ - ['clean', false, 'unchanged'], - ['dirty', false, 'changed'], - ['clean', true, 'unchanged'], + for (const [entry, targetPath, shouldWrite, status] of [ + [[contentHash, optionsHash, 'clean'], filePath, false, 'unchanged'], + [[contentHash, optionsHash, 'dirty'], filePath, false, 'changed'], + [[contentHash, optionsHash, 'clean'], filePath, true, 'unchanged'], + [[null, optionsHash, 'unsupported'], missingPath, false, 'unsupported'], + [[null, optionsHash, 'unsupported'], missingPath, true, 'unsupported'], ] as const) { await expect( formatFile({ file: { - path: filePath, + path: targetPath, options: { parser: 'unknown-parser', }, }, shouldWrite, cache: { - entry: [contentHash, optionsHash, state], + entry, optionsHash, }, }), @@ -89,6 +92,9 @@ test('resolves parser support before reading on a cache miss', async () => { optionsHash: 'options', }, }), - ).resolves.toEqual({ status: 'unsupported' }); + ).resolves.toEqual({ + status: 'unsupported', + cacheEntry: [null, 'options', 'unsupported'], + }); }); }); diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index c851910..d7d7605 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -176,7 +176,7 @@ define.fmt({ ## Cache -`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Cache entries use file content and final formatting options, so changing either causes the file to be formatted again. Installed Prettier plugins are identified by their package name, version, and entry point; local, linked, or unversioned plugins bypass the cache. +`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Formatting results use file content and final formatting options, so changing either causes the file to be formatted again. Unsupported parser lookups use the file path and final options because parser inference does not inspect file content. Installed Prettier plugins are identified by their package name, version, and entry point; local, linked, or unversioned plugins bypass the cache. The default cache directory is `.rstack/cache/fmt` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache. diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index cd33ca5..8a8e5ad 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -176,7 +176,7 @@ define.fmt({ ## 缓存 \{#cache} -`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。 +`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。格式化结果基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。由于 parser 推断不会读取文件内容,不支持的 parser 查询结果仅基于文件路径和最终选项。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。 默认缓存目录位于 Rstack 配置根目录下的 `.rstack/cache/fmt`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。