From bad221d779da4c86fd4ef5fc1c3e34bb6b809596 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Tue, 3 Nov 2026 19:02:47 +0100 Subject: [PATCH] perf: keep none of the CLI's target output runGenerators collected every requested generator's output into the arrays it returns, though neither generate nor preview reads them. With { results: false } it drains them instead: each generator still runs to its end, and once nothing else reads its output, its chunks go as they are recorded. Both commands now use it. Assisted-by: Claude Opus 5.5 Assisted-by: Claude Sonnet 5.5 --- .changeset/drain-results.md | 6 ++ packages/cli/bin/commands/generate.mjs | 2 +- packages/cli/bin/commands/preview.mjs | 2 +- packages/core/src/__tests__/caching.test.mjs | 85 +++++++++++++++++++ .../core/src/__tests__/generators.test.mjs | 23 +++++ packages/core/src/caching.mjs | 24 ++++++ packages/core/src/generators.mjs | 19 +++-- 7 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 .changeset/drain-results.md diff --git a/.changeset/drain-results.md b/.changeset/drain-results.md new file mode 100644 index 00000000..0170fe5d --- /dev/null +++ b/.changeset/drain-results.md @@ -0,0 +1,6 @@ +--- +'@doc-kit/core': minor +'@doc-kit/cli': patch +--- + +`runGenerators(configuration, { results: false })` keeps none of the requested generators' output: each still runs to its end, and once nothing else reads its output, its chunks go as they are recorded. The CLI's `generate` and `preview` commands use it, as they don't read the results. diff --git a/packages/cli/bin/commands/generate.mjs b/packages/cli/bin/commands/generate.mjs index 4122f6fb..358fcf03 100644 --- a/packages/cli/bin/commands/generate.mjs +++ b/packages/cli/bin/commands/generate.mjs @@ -64,6 +64,6 @@ export default generate.action( const config = await setConfig(opts); assertRunnableOptions(config); - await runGenerators(config); + await runGenerators(config, { results: false }); }) ); diff --git a/packages/cli/bin/commands/preview.mjs b/packages/cli/bin/commands/preview.mjs index 84272fe6..e8b8e4d5 100644 --- a/packages/cli/bin/commands/preview.mjs +++ b/packages/cli/bin/commands/preview.mjs @@ -62,7 +62,7 @@ export default preview.action( const config = await setConfig(opts); assertRunnableOptions(config); - await runGenerators(config); + await runGenerators(config, { results: false }); // With the default coming from the configuration, this is always set output = config.global.output; diff --git a/packages/core/src/__tests__/caching.test.mjs b/packages/core/src/__tests__/caching.test.mjs index 22306293..4989de5f 100644 --- a/packages/core/src/__tests__/caching.test.mjs +++ b/packages/core/src/__tests__/caching.test.mjs @@ -409,6 +409,63 @@ describe('caching', () => { deepStrictEqual(chunks, [[1], [2], [3]]); }); + it('should drain a result, alongside its other readers', async () => { + const cache = createCache(); + const recorded = []; + + cache.populateConsumerCounts(['a', 'b'], name => + name === 'b' ? 'a' : undefined + ); + + async function* gen() { + for (const value of [1, 2, 3]) { + recorded.push(value); + + yield [value]; + } + } + + cache.store('a', gen()); + + const reader = await cache.stream('a'); + + await cache.drain('a'); + + strictEqual(cache.has('a'), false); + deepStrictEqual(recorded, [1, 2, 3]); + + const chunks = await collectChunks(reader); + + deepStrictEqual(chunks, [[1], [2], [3]]); + }); + + it('should let go of the chunks of a drained result as they are recorded', async () => { + const cache = createCache(); + const gate = Promise.withResolvers(); + const { released, chunkOf } = trackReleases(); + + cache.populateConsumerCounts(['a'], () => undefined); + + async function* gen() { + yield chunkOf(1); + yield chunkOf(2); + yield chunkOf(3); + await gate.promise; + } + + cache.store('a', gen()); + + const draining = cache.drain('a'); + + // The generator waits, past its chunks: the first is no longer held + await collectGarbage(() => released.has(1)); + + strictEqual(released.has(1), true); + + gate.resolve(); + await draining; + }); + it('should let go of the chunks a reader has read, after the generator ended', async () => { const cache = createCache(); const { released, chunkOf } = trackReleases(); @@ -503,6 +560,34 @@ describe('caching', () => { deepStrictEqual([...lastRead, ...lastRest], [1, 2, 3]); }); + it('should drain a non-streaming result', async () => { + const cache = createCache(); + + cache.populateConsumerCounts(['a'], () => undefined); + cache.store('a', Promise.resolve({ value: 1 })); + + const drained = await cache.drain('a'); + + strictEqual(drained, undefined); + strictEqual(cache.has('a'), false); + }); + + it('should reject draining a generator that fails', async () => { + const cache = createCache(); + + cache.populateConsumerCounts(['a'], () => undefined); + + async function* gen() { + yield [1]; + + throw new Error('drained failure'); + } + + cache.store('a', gen()); + + await rejects(cache.drain('a'), /drained failure/); + }); + it('should count a stream as a read for eviction', async () => { const cache = createCache(); diff --git a/packages/core/src/__tests__/generators.test.mjs b/packages/core/src/__tests__/generators.test.mjs index 07fb99d3..6e146cff 100644 --- a/packages/core/src/__tests__/generators.test.mjs +++ b/packages/core/src/__tests__/generators.test.mjs @@ -377,4 +377,27 @@ describe('createGenerator orchestration', () => { assert.deepStrictEqual(one, [{ array: true }]); assert.deepStrictEqual(two, [{ array: false }]); }); + + it('runs every generator but keeps no results with results: false', async () => { + const { runGenerators } = createGenerator(); + + const results = await runGenerators( + { target: ['gen-c', 'gen-c-all'], threads: 1 }, + { results: false } + ); + + assert.equal(results, undefined); + assert.equal(runs['gen-c'], 1); + assert.equal(runs['gen-c-all'], 1); + }); + + it('rejects with a failure with results: false', async () => { + await assert.rejects( + createGenerator().runGenerators( + { target: ['slow-reader'], threads: 2 }, + { results: false } + ), + /source failed/ + ); + }); }); diff --git a/packages/core/src/caching.mjs b/packages/core/src/caching.mjs index bdfcf4df..6198d196 100644 --- a/packages/core/src/caching.mjs +++ b/packages/core/src/caching.mjs @@ -215,6 +215,30 @@ export const createCache = () => { return value; }, + /** + * Runs a generator to its end, keeping none of its result, which counts as + * one of its reads. + * + * @param {string} name - Cache key (resolved generator specifier) to drain + * @returns {Promise} + */ + drain: async name => { + const result = await results[name]; + + if (!isAsyncIterable(result)) { + release(name); + + return; + } + + const { ended } = replayOf(name, result); + + // Once nothing else reads it, its chunks go as they're recorded + release(name); + + await ended; + }, + /** * Gives a reader of a generator's result as it is produced: chunk by chunk * if it streams, or as a single chunk once it resolves. As with `consume`, diff --git a/packages/core/src/generators.mjs b/packages/core/src/generators.mjs index b88809fb..37f2846b 100644 --- a/packages/core/src/generators.mjs +++ b/packages/core/src/generators.mjs @@ -114,9 +114,13 @@ const createGenerator = () => { * Runs all requested generators with their dependencies. * * @param {import('./utils/configuration/types').Configuration} configuration - Runtime options - * @returns {Promise} Results of all requested generators + * @param {{ results?: boolean }} [options] - With `results: false`, none of + * the requested generators' results is kept, so each can be let go as soon + * as it is produced + * @returns {Promise} Results of all requested + * generators, or `undefined` with `results: false` */ - const runGenerators = async configuration => { + const runGenerators = async (configuration, { results = true } = {}) => { const { target, threads } = configuration; // Resolve shorthand names and load the full dependency closure up front, @@ -149,15 +153,14 @@ const createGenerator = () => { scheduleGenerator(specifier, generators, inputOf, configuration); } - // Start all collections in parallel (don't await sequentially). Consuming - // through the shared path lets the final read also trigger eviction. - const results = await Promise.all( - targets.map(specifier => cache.consume(specifier)) - ); + // Start all reads in parallel (don't await sequentially). Reading through + // the shared path lets the final read also trigger eviction. + const read = results ? cache.consume : cache.drain; + const collected = await Promise.all(targets.map(read)); await pool.destroy(); - return results; + return results ? collected : undefined; }; return { runGenerators };