Skip to content

Commit

Permalink
Parallelize rendering of sibling components to avoid async waterfalls (
Browse files Browse the repository at this point in the history
…#7071)

* Parallelize rendering of sibling components to avoid async waterfalls

* Catch and rethrow errors when eagerly rendering children

* Catch `Response` in rendering stage and throw error

* Add changeset

* Fix test error message

* Improve unit tests

* Start async generators in non-buffered mode, and only start buffering once a component doesn't resolve immediatly

* Add more documentation
  • Loading branch information
Johannes Spohr authored May 18, 2023
1 parent 05695ab commit e186ecc
Show file tree
Hide file tree
Showing 10 changed files with 210 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/clever-garlics-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': minor
---

Render sibling components in parallel
16 changes: 14 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,23 @@ 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 of the next for loop is suspended due to an async component,
// this timeout triggers and we start buffering the other iterators
setTimeout(() => {
// buffer all iterators that haven't started yet
iterables.forEach((it) => !it.isStarted() && it.buffer());
}, 0);
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
9 changes: 9 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,15 @@ export async function renderPage(
}
}

// `chunk` might be a Response that contains a redirect,
// that was rendered eagerly and therefore bypassed the early check
// whether headers can still be modified. In that case, throw an error
if (chunk instanceof Response) {
throw new AstroError({
...AstroErrorData.ResponseSentError,
});
}

const bytes = chunkToByteArray(result, chunk);
controller.enqueue(bytes);
i++;
Expand Down
108 changes: 108 additions & 0 deletions packages/astro/src/runtime/server/render/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,111 @@ 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 after next() has been called once, e.g. the iterator is started
*/
async buffer() {
if (this.#gen) {
// If this called as part of rendering, please open a bug report.
// Any call to buffer() should verify that the iterator isn't running
throw new Error('Cannot not switch from non-buffer to buffer mode');
}
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;
// the previous statement will either put an element in the queue or throw,
// so we can safely assume we have something now
return this.#queue.shift()!;
}

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;
}

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.

0 comments on commit e186ecc

Please sign in to comment.