Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/drain-results.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/cli/bin/commands/generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,6 @@ export default generate.action(
const config = await setConfig(opts);
assertRunnableOptions(config);

await runGenerators(config);
await runGenerators(config, { results: false });
})
);
2 changes: 1 addition & 1 deletion packages/cli/bin/commands/preview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
85 changes: 85 additions & 0 deletions packages/core/src/__tests__/caching.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/__tests__/generators.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/
);
});
});
24 changes: 24 additions & 0 deletions packages/core/src/caching.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
*/
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`,
Expand Down
19 changes: 11 additions & 8 deletions packages/core/src/generators.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,13 @@ const createGenerator = () => {
* Runs all requested generators with their dependencies.
*
* @param {import('./utils/configuration/types').Configuration} configuration - Runtime options
* @returns {Promise<unknown[]>} 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<unknown[] | undefined>} 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,
Expand Down Expand Up @@ -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 };
Expand Down
Loading