Skip to content

Commit 1abb9ef

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 557f319 commit 1abb9ef

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
@@ -215,6 +215,30 @@ export const createCache = () => {
215215
return value;
216216
},
217217

218+
/**
219+
* Runs a generator to its end, keeping none of its result, which counts as
220+
* one of its reads.
221+
*
222+
* @param {string} name - Cache key (resolved generator specifier) to drain
223+
* @returns {Promise<void>}
224+
*/
225+
drain: async name => {
226+
const result = await results[name];
227+
228+
if (!isAsyncIterable(result)) {
229+
release(name);
230+
231+
return;
232+
}
233+
234+
const { ended } = replayOf(name, result);
235+
236+
// Once nothing else reads it, its chunks go as they're recorded
237+
release(name);
238+
239+
await ended;
240+
},
241+
218242
/**
219243
* Gives a reader of a generator's result as it is produced: chunk by chunk
220244
* 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
@@ -114,9 +114,13 @@ const createGenerator = () => {
114114
* Runs all requested generators with their dependencies.
115115
*
116116
* @param {import('./utils/configuration/types').Configuration} configuration - Runtime options
117-
* @returns {Promise<unknown[]>} Results of all requested generators
117+
* @param {{ results?: boolean }} [options] - With `results: false`, none of
118+
* the requested generators' results is kept, so each can be let go as soon
119+
* as it is produced
120+
* @returns {Promise<unknown[] | undefined>} Results of all requested
121+
* generators, or `undefined` with `results: false`
118122
*/
119-
const runGenerators = async configuration => {
123+
const runGenerators = async (configuration, { results = true } = {}) => {
120124
const { target, threads } = configuration;
121125

122126
// Resolve shorthand names and load the full dependency closure up front,
@@ -149,15 +153,14 @@ const createGenerator = () => {
149153
scheduleGenerator(specifier, generators, inputOf, configuration);
150154
}
151155

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

158161
await pool.destroy();
159162

160-
return results;
163+
return results ? collected : undefined;
161164
};
162165

163166
return { runGenerators };

0 commit comments

Comments
 (0)