From d1be9cad1a21ce2e36fd18f74171f390bbc2d34d Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Tue, 3 Nov 2026 19:02:47 +0100 Subject: [PATCH 1/2] 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, 150 insertions(+), 11 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 000000000..0170fe5dd --- /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 4122f6fb3..358fcf039 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 84272fe67..e8b8e4d56 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 b6106f345..b8d15a3ba 100644 --- a/packages/core/src/__tests__/caching.test.mjs +++ b/packages/core/src/__tests__/caching.test.mjs @@ -1,4 +1,4 @@ -import { deepStrictEqual, strictEqual, throws } from 'node:assert'; +import { deepStrictEqual, rejects, strictEqual, throws } from 'node:assert'; import { describe, it } from 'node:test'; import { collectAsyncGenerator, createCache } from '../caching.mjs'; @@ -212,6 +212,61 @@ describe('caching', () => { deepStrictEqual(await Array.fromAsync(stream), [[1], [2], [3]]); }); + it('should drain a result, alongside its other readers', async () => { + const cache = createCache(); + const produced = []; + + cache.populateConsumerCounts(['a', 'b'], name => + name === 'b' ? 'a' : undefined + ); + + async function* gen() { + for (const value of [1, 2, 3]) { + produced.push(value); + + yield [value]; + } + } + + cache.store('a', gen()); + + const reader = await cache.stream('a'); + + await cache.drain('a'); + + strictEqual(cache.has('a'), false); + deepStrictEqual(produced, [1, 2, 3]); + + deepStrictEqual(await Array.fromAsync(reader), [[1], [2], [3]]); + }); + + it('should let go of the chunks of a drained result as they are produced', 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 a chunk once every streaming reader has read it, while it still runs', async () => { const cache = createCache(); const gate = Promise.withResolvers(); @@ -252,5 +307,33 @@ describe('caching', () => { await readValues(first); await readValues(second); }); + + 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/); + }); }); }); diff --git a/packages/core/src/__tests__/generators.test.mjs b/packages/core/src/__tests__/generators.test.mjs index 457393439..6235a3d90 100644 --- a/packages/core/src/__tests__/generators.test.mjs +++ b/packages/core/src/__tests__/generators.test.mjs @@ -347,4 +347,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: ['failing-reader'], threads: 2 }, + { results: false } + ), + /source failed/ + ); + }); }); diff --git a/packages/core/src/caching.mjs b/packages/core/src/caching.mjs index ce533e104..183276281 100644 --- a/packages/core/src/caching.mjs +++ b/packages/core/src/caching.mjs @@ -214,6 +214,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 } = teeOf(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 06b497d54..087e3842a 100644 --- a/packages/core/src/generators.mjs +++ b/packages/core/src/generators.mjs @@ -112,9 +112,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, @@ -147,15 +151,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 }; From bbf208157622f56d35b2b837c49c2c70b502f9f0 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Tue, 10 Nov 2026 22:37:07 +0100 Subject: [PATCH 2/2] refactor: move results into the configuration, as discardResults From the review, which asked for it in the configuration. runGenerators reads discardResults from it, next to threads and chunkSize, and the CLI's generate and preview commands set it. The configuration docs list it. The failure test of a dependency read as a stream runs with and without results, so the drain path also checks that no rejection goes unhandled, and the test of discarding results keeps only what it checks of its own. The cache's comments say a requested target is read once more when the run ends, rather than by the final collection, and that a drained result's chunks go as they're produced. Assisted-by: Claude Opus 5.5 --- .changeset/drain-results.md | 2 +- docs/configuration.md | 3 ++ packages/cli/bin/commands/generate.mjs | 2 +- packages/cli/bin/commands/preview.mjs | 2 +- .../core/src/__tests__/generators.test.mjs | 42 ++++++++----------- packages/core/src/caching.mjs | 8 ++-- packages/core/src/generators.mjs | 14 +++---- .../core/src/utils/configuration/types.d.ts | 4 ++ 8 files changed, 37 insertions(+), 40 deletions(-) diff --git a/.changeset/drain-results.md b/.changeset/drain-results.md index 0170fe5dd..951bd95e6 100644 --- a/.changeset/drain-results.md +++ b/.changeset/drain-results.md @@ -3,4 +3,4 @@ '@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. +`runGenerators` with `discardResults: true` in its configuration 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 produced. The CLI's `generate` and `preview` commands use it, as they don't read the results. diff --git a/docs/configuration.md b/docs/configuration.md index a0d750dd9..9d8f2cac9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -137,6 +137,9 @@ Top-level, alongside `target` and `global`: CPU count. Each worker's heap is limited to 512MB; to raise the limit, run Node.js with `--max-old-space-size` (directly or through `NODE_OPTIONS`). - `chunkSize` {number} Items processed per worker thread. **Default:** `10`. +- `discardResults` {boolean} Keeps none of the requested generators' results, + so each can be let go as soon as it is produced. `doc-kit generate` and + `doc-kit preview` set it. **Default:** `false`. ## Generator options diff --git a/packages/cli/bin/commands/generate.mjs b/packages/cli/bin/commands/generate.mjs index 358fcf039..d58a4facf 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, { results: false }); + await runGenerators({ ...config, discardResults: true }); }) ); diff --git a/packages/cli/bin/commands/preview.mjs b/packages/cli/bin/commands/preview.mjs index e8b8e4d56..04b4c51fc 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, { results: false }); + await runGenerators({ ...config, discardResults: true }); // With the default coming from the configuration, this is always set output = config.global.output; diff --git a/packages/core/src/__tests__/generators.test.mjs b/packages/core/src/__tests__/generators.test.mjs index 6235a3d90..f4060c9ca 100644 --- a/packages/core/src/__tests__/generators.test.mjs +++ b/packages/core/src/__tests__/generators.test.mjs @@ -312,18 +312,23 @@ describe('createGenerator orchestration', () => { } ); - it('rejects with the failure of a dependency read as a stream', async () => { - const { runGenerators } = createGenerator(); + it('rejects with the failure of a dependency read as a stream, with or without results', async () => { const unhandled = []; const onUnhandled = error => unhandled.push(error); process.on('unhandledRejection', onUnhandled); try { - await assert.rejects( - runGenerators({ target: ['failing-reader'], threads: 2 }), - /source failed/ - ); + for (const discardResults of [false, true]) { + await assert.rejects( + createGenerator().runGenerators({ + target: ['failing-reader'], + threads: 2, + discardResults, + }), + /source failed/ + ); + } // A rejection left unhandled is reported once the microtasks have run await new Promise(resolve => setImmediate(resolve)); @@ -348,26 +353,13 @@ describe('createGenerator orchestration', () => { 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 } - ); + it('returns no results with discardResults', async () => { + const results = await createGenerator().runGenerators({ + target: ['gen-c', 'gen-c-all'], + threads: 1, + discardResults: true, + }); 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: ['failing-reader'], threads: 2 }, - { results: false } - ), - /source failed/ - ); }); }); diff --git a/packages/core/src/caching.mjs b/packages/core/src/caching.mjs index 183276281..eabf4c3ea 100644 --- a/packages/core/src/caching.mjs +++ b/packages/core/src/caching.mjs @@ -141,8 +141,8 @@ export const createCache = () => { /** * Computes and records consumer counts across the dependency closure of the * requested generators. A generator is consumed once per dependent - * generator, plus once more when it is a requested target (read by the - * final collection). This drives eviction without coupling the cache to the + * generator, plus once more when it is a requested target (read when the run + * ends). This drives eviction without coupling the cache to the * generator registry: callers supply how to resolve a dependency. * * @param {string[]} targets - Requested generator cache keys @@ -179,7 +179,7 @@ export const createCache = () => { } } - // Each requested target is consumed once more by the final collection + // Each requested target is read once more when the run ends for (const name of targets) { remaining[name] = (remaining[name] ?? 0) + 1; } @@ -232,7 +232,7 @@ export const createCache = () => { const { ended } = teeOf(name, result); - // Once nothing else reads it, its chunks go as they're recorded + // Once nothing else reads it, its chunks go as they're produced release(name); await ended; diff --git a/packages/core/src/generators.mjs b/packages/core/src/generators.mjs index 087e3842a..e374b923f 100644 --- a/packages/core/src/generators.mjs +++ b/packages/core/src/generators.mjs @@ -112,14 +112,12 @@ const createGenerator = () => { * Runs all requested generators with their dependencies. * * @param {import('./utils/configuration/types').Configuration} configuration - Runtime options - * @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` + * generators, or `undefined` with `discardResults`, which lets each go as + * soon as it is produced */ - const runGenerators = async (configuration, { results = true } = {}) => { - const { target, threads } = configuration; + const runGenerators = async configuration => { + const { target, threads, discardResults } = configuration; // Resolve shorthand names and load the full dependency closure up front, // so scheduling below is fully synchronous. @@ -153,12 +151,12 @@ const createGenerator = () => { // 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 read = discardResults ? cache.drain : cache.consume; const collected = await Promise.all(targets.map(read)); await pool.destroy(); - return results ? collected : undefined; + return discardResults ? undefined : collected; }; return { runGenerators }; diff --git a/packages/core/src/utils/configuration/types.d.ts b/packages/core/src/utils/configuration/types.d.ts index 2437302bc..d29b3b2a0 100644 --- a/packages/core/src/utils/configuration/types.d.ts +++ b/packages/core/src/utils/configuration/types.d.ts @@ -16,6 +16,10 @@ export type Configuration = { // Number of items to process per worker thread chunkSize: number; + + // Keeps none of the requested generators' results, for runs that only need + // what the generators write + discardResults?: boolean; } & { [K in keyof AllGenerators]: GlobalConfiguration & AllGenerators[K]['defaultConfiguration'];