Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parallelize rendering of sibling components to avoid async waterfalls #7071

Merged
merged 8 commits into from
May 18, 2023
Merged
Show file tree
Hide file tree
Changes from 7 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
5 changes: 5 additions & 0 deletions .changeset/clever-garlics-relate.md
johannesspohr marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': minor
---

Render sibling components in parallel
14 changes: 12 additions & 2 deletions packages/astro/src/runtime/server/render/astro/render-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { RenderInstruction } from '../types';
import { HTMLBytes, markHTMLString } from '../../escape.js';
import { isPromise } from '../../util.js';
import { renderChild } from '../any.js';
import { EagerAsyncIterableIterator } from '../util.js';

const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult');

Expand Down Expand Up @@ -35,12 +36,21 @@ export class RenderTemplateResult {
async *[Symbol.asyncIterator]() {
const { htmlParts, expressions } = this;

let iterables: Array<EagerAsyncIterableIterator> = [];
// all async iterators start running in non-buffered mode to avoid useless caching
for (let i = 0; i < htmlParts.length; i++) {
iterables.push(new EagerAsyncIterableIterator(renderChild(expressions[i])));
}
// once the execution is interrupted, we start buffering the other iterators
setTimeout(() => {
ematipico marked this conversation as resolved.
Show resolved Hide resolved
iterables.forEach((it) => !it.isStarted() && it.buffer());
}, 0);
johannesspohr marked this conversation as resolved.
Show resolved Hide resolved
for (let i = 0; i < htmlParts.length; i++) {
const html = htmlParts[i];
const expression = expressions[i];
const iterable = iterables[i];

yield markHTMLString(html);
yield* renderChild(expression);
yield* iterable;
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/runtime/server/render/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ export async function renderPage(
}
}
}
if (chunk instanceof Response) {
throw new AstroError({
...AstroErrorData.ResponseSentError,
});
}
johannesspohr marked this conversation as resolved.
Show resolved Hide resolved

const bytes = chunkToByteArray(result, chunk);
controller.enqueue(bytes);
Expand Down
104 changes: 104 additions & 0 deletions packages/astro/src/runtime/server/render/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,107 @@ export function renderElement(
}
return `<${name}${internalSpreadAttributes(props, shouldEscape)}>${children}</${name}>`;
}

// This wrapper around an AsyncIterable can eagerly consume its values, so that
// its values are ready to yield out ASAP. This is used for list-like usage of
// Astro components, so that we don't have to wait on earlier components to run
// to even start running those down in the list.
export class EagerAsyncIterableIterator {
#iterable: AsyncIterable<any>;
#queue = new Queue<IteratorResult<any, any>>();
#error: any = undefined;
#next: Promise<IteratorResult<any, any>> | undefined;
/**
* Whether the proxy is running in buffering or pass-through mode
*/
#isBuffering = false;
#gen: AsyncIterator<any> | undefined = undefined;
#isStarted = false;

constructor(iterable: AsyncIterable<any>) {
this.#iterable = iterable;
}

/**
* Starts to eagerly fetch the inner iterator and cache the results.
* Note: This might not be called, once next() has been called once, e.g. the iterator is started
*/
async buffer() {
if (this.#gen) {
throw new Error('Cannot not switch from non-buffer to buffer mode');
johannesspohr marked this conversation as resolved.
Show resolved Hide resolved
}
this.#isBuffering = true;
this.#isStarted = true;
this.#gen = this.#iterable[Symbol.asyncIterator]();
let value: IteratorResult<any, any> | undefined = undefined;
do {
this.#next = this.#gen.next();
try {
value = await this.#next;
this.#queue.push(value);
} catch (e) {
this.#error = e;
}
} while (value && !value.done);
}

async next() {
if (this.#error) {
throw this.#error;
}
// for non-buffered mode, just pass through the next result
if (!this.#isBuffering) {
if (!this.#gen) {
this.#isStarted = true;
this.#gen = this.#iterable[Symbol.asyncIterator]();
}
return await this.#gen.next();
}
if (!this.#queue.isEmpty()) {
return this.#queue.shift()!;
}
await this.#next;
return this.#queue.shift()!;
johannesspohr marked this conversation as resolved.
Show resolved Hide resolved
}

isStarted() {
return this.#isStarted;
}

[Symbol.asyncIterator]() {
return this;
}
}

interface QueueItem<T> {
item: T;
next?: QueueItem<T>;
}

/**
* Basis Queue implementation with a linked list
*/
class Queue<T> {
head: QueueItem<T> | undefined = undefined;
tail: QueueItem<T> | undefined = undefined;

push(item: T) {
if (this.head === undefined) {
this.head = { item };
this.tail = this.head;
} else {
this.tail!.next = { item };
this.tail = this.tail!.next;
}
}

isEmpty() {
return this.head === undefined;
ematipico marked this conversation as resolved.
Show resolved Hide resolved
}

shift(): T | undefined {
const val = this.head?.item;
this.head = this.head?.next;
return val;
}
}
12 changes: 12 additions & 0 deletions packages/astro/test/fixtures/parallel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@test/parallel-components",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "astro build",
"dev": "astro dev"
},
"dependencies": {
"astro": "workspace:*"
}
}
11 changes: 11 additions & 0 deletions packages/astro/test/fixtures/parallel/src/components/Delayed.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
const { ms } = Astro.props
const start = new Date().valueOf();
await new Promise(res => setTimeout(res, ms));
const finished = new Date().valueOf();
---
<section>
<h1>{ms}ms Delayed</h1>
<span class="start">{ start }</span>
<span class="finished">{ finished }</span>
</section>
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/parallel/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
import Delayed from '../components/Delayed.astro'
---

<Delayed ms={30} />
<Delayed ms={20} />
<Delayed ms={40} />
<Delayed ms={10} />
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import Wait from '../components/Wait.astro';
<p>Section content</p>
</Wait>
<h2>Next section</h2>
<Wait ms={50}>
<Wait ms={60}>
<p>Section content</p>
</Wait>
<p>Paragraph 3</p>
Expand Down
36 changes: 36 additions & 0 deletions packages/astro/test/parallel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';

describe('Component parallelization', () => {
let fixture;

before(async () => {
fixture = await loadFixture({
root: './fixtures/parallel/',
});
await fixture.build();
});

it('renders fast', async () => {
let html = await fixture.readFile('/index.html');
let $ = cheerio.load(html);

const startTimes = Array.from($('.start')).map((element) => Number(element.children[0].data));
const finishTimes = Array.from($('.finished')).map((element) =>
Number(element.children[0].data)
);

let renderStartWithin = Math.max(...startTimes) - Math.min(...startTimes);
expect(renderStartWithin).to.be.lessThan(
10, // in theory, this should be 0, so 10ms tolerance
"The components didn't start rendering in parallel"
);

const totalRenderTime = Math.max(...finishTimes) - Math.min(...startTimes);
expect(totalRenderTime).to.be.lessThan(
60, // max component delay is 40ms
'The total render time was significantly longer than the max component delay'
);
});
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.