Skip to content

Commit 60ae897

Browse files
committed
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 <noreply@anthropic.com> Assisted-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent eac0b5f commit 60ae897

7 files changed

Lines changed: 151 additions & 10 deletions

File tree

‎.changeset/drain-results.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@doc-kit/core': minor
3+
'@doc-kit/cli': patch
4+
---
5+
6+
`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.

‎packages/cli/bin/commands/generate.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,6 @@ export default generate.action(
6464
const config = await setConfig(opts);
6565
assertRunnableOptions(config);
6666

67-
await runGenerators(config);
67+
await runGenerators(config, { results: false });
6868
})
6969
);

‎packages/cli/bin/commands/preview.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export default preview.action(
6262
const config = await setConfig(opts);
6363
assertRunnableOptions(config);
6464

65-
await runGenerators(config);
65+
await runGenerators(config, { results: false });
6666

6767
// With the default coming from the configuration, this is always set
6868
output = config.global.output;

‎packages/core/src/__tests__/caching.test.mjs‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,63 @@ describe('caching', () => {
409409
deepStrictEqual(chunks, [[1], [2], [3]]);
410410
});
411411

412+
it('should drain a result, alongside its other readers', async () => {
413+
const cache = createCache();
414+
const recorded = [];
415+
416+
cache.populateConsumerCounts(['a', 'b'], name =>
417+
name === 'b' ? 'a' : undefined
418+
);
419+
420+
async function* gen() {
421+
for (const value of [1, 2, 3]) {
422+
recorded.push(value);
423+
424+
yield [value];
425+
}
426+
}
427+
428+
cache.store('a', gen());
429+
430+
const reader = await cache.stream('a');
431+
432+
await cache.drain('a');
433+
434+
strictEqual(cache.has('a'), false);
435+
deepStrictEqual(recorded, [1, 2, 3]);
436+
437+
const chunks = await collectChunks(reader);
438+
439+
deepStrictEqual(chunks, [[1], [2], [3]]);
440+
});
441+
442+
it('should let go of the chunks of a drained result as they are recorded', async () => {
443+
const cache = createCache();
444+
const gate = Promise.withResolvers();
445+
const { released, chunkOf } = trackReleases();
446+
447+
cache.populateConsumerCounts(['a'], () => undefined);
448+
449+
async function* gen() {
450+
yield chunkOf(1);
451+
yield chunkOf(2);
452+
yield chunkOf(3);
453+
await gate.promise;
454+
}
455+
456+
cache.store('a', gen());
457+
458+
const draining = cache.drain('a');
459+
460+
// The generator waits, past its chunks: the first is no longer held
461+
await collectGarbage(() => released.has(1));
462+
463+
strictEqual(released.has(1), true);
464+
465+
gate.resolve();
466+
await draining;
467+
});
468+
412469
it('should let go of the chunks a reader has read, after the generator ended', async () => {
413470
const cache = createCache();
414471
const { released, chunkOf } = trackReleases();
@@ -503,6 +560,34 @@ describe('caching', () => {
503560
deepStrictEqual([...lastRead, ...lastRest], [1, 2, 3]);
504561
});
505562

563+
it('should drain a non-streaming result', async () => {
564+
const cache = createCache();
565+
566+
cache.populateConsumerCounts(['a'], () => undefined);
567+
cache.store('a', Promise.resolve({ value: 1 }));
568+
569+
const drained = await cache.drain('a');
570+
571+
strictEqual(drained, undefined);
572+
strictEqual(cache.has('a'), false);
573+
});
574+
575+
it('should reject draining a generator that fails', async () => {
576+
const cache = createCache();
577+
578+
cache.populateConsumerCounts(['a'], () => undefined);
579+
580+
async function* gen() {
581+
yield [1];
582+
583+
throw new Error('drained failure');
584+
}
585+
586+
cache.store('a', gen());
587+
588+
await rejects(cache.drain('a'), /drained failure/);
589+
});
590+
506591
it('should count a stream as a read for eviction', async () => {
507592
const cache = createCache();
508593

‎packages/core/src/__tests__/generators.test.mjs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,4 +377,27 @@ describe('createGenerator orchestration', () => {
377377
assert.deepStrictEqual(one, [{ array: true }]);
378378
assert.deepStrictEqual(two, [{ array: false }]);
379379
});
380+
381+
it('runs every generator but keeps no results with results: false', async () => {
382+
const { runGenerators } = createGenerator();
383+
384+
const results = await runGenerators(
385+
{ target: ['gen-c', 'gen-c-all'], threads: 1 },
386+
{ results: false }
387+
);
388+
389+
assert.equal(results, undefined);
390+
assert.equal(runs['gen-c'], 1);
391+
assert.equal(runs['gen-c-all'], 1);
392+
});
393+
394+
it('rejects with a failure with results: false', async () => {
395+
await assert.rejects(
396+
createGenerator().runGenerators(
397+
{ target: ['slow-reader'], threads: 2 },
398+
{ results: false }
399+
),
400+
/source failed/
401+
);
402+
});
380403
});

‎packages/core/src/caching.mjs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,30 @@ export const createCache = () => {
214214
return value;
215215
},
216216

217+
/**
218+
* Runs a generator to its end, keeping none of its result, which counts as
219+
* one of its reads.
220+
*
221+
* @param {string} name - Cache key (resolved generator specifier) to drain
222+
* @returns {Promise<void>}
223+
*/
224+
drain: async name => {
225+
const result = await results[name];
226+
227+
if (!isAsyncIterable(result)) {
228+
release(name);
229+
230+
return;
231+
}
232+
233+
const { ended } = replayOf(name, result);
234+
235+
// Once nothing else reads it, its chunks go as they're recorded
236+
release(name);
237+
238+
await ended;
239+
},
240+
217241
/**
218242
* Gives a reader of a generator's result as it is produced: chunk by chunk
219243
* if it streams, or as a single chunk once it resolves. As with `consume`,

‎packages/core/src/generators.mjs‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,13 @@ const createGenerator = () => {
112112
* Runs all requested generators with their dependencies.
113113
*
114114
* @param {import('./utils/configuration/types').Configuration} configuration - Runtime options
115-
* @returns {Promise<unknown[]>} Results of all requested generators
115+
* @param {{ results?: boolean }} [options] - With `results: false`, none of
116+
* the requested generators' results is kept, so each can be let go as soon
117+
* as it is produced
118+
* @returns {Promise<unknown[] | undefined>} Results of all requested
119+
* generators, or `undefined` with `results: false`
116120
*/
117-
const runGenerators = async configuration => {
121+
const runGenerators = async (configuration, { results = true } = {}) => {
118122
const { target, threads } = configuration;
119123

120124
// Resolve shorthand names and load the full dependency closure up front,
@@ -147,15 +151,14 @@ const createGenerator = () => {
147151
scheduleGenerator(specifier, generators, inputOf, configuration);
148152
}
149153

150-
// Start all collections in parallel (don't await sequentially). Consuming
151-
// through the shared path lets the final read also trigger eviction.
152-
const results = await Promise.all(
153-
targets.map(specifier => cache.consume(specifier))
154-
);
154+
// Start all reads in parallel (don't await sequentially). Reading through
155+
// the shared path lets the final read also trigger eviction.
156+
const read = results ? cache.consume : cache.drain;
157+
const collected = await Promise.all(targets.map(read));
155158

156159
await pool.destroy();
157160

158-
return results;
161+
return results ? collected : undefined;
159162
};
160163

161164
return { runGenerators };

0 commit comments

Comments
 (0)