Skip to content

Speed up API page regeneration in serve to under 4 seconds - #4322

Merged
akira28 merged 4 commits into
dev/api-pages-hot-reloadfrom
dev/api-render-parallel-pages
Oct 6, 2026
Merged

akira28 merged 4 commits into
dev/api-pages-hot-reloadfrom
dev/api-render-parallel-pages

Conversation

@akira28

@akira28 akira28 commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Regenerating API Explorer pages in ./dev.sh serve takes 3–4 s instead of about 10 s. A thin-space bug that dropped spaces from rendered Markdown is also fixed.

Affects: API reference, Authoring, Site UI

Stack: 2 of 2, on top of #4321.

Prompt summary: The author wants faster API page regeneration in the dev server. The goal is to render pages within one API version in parallel, and to stop downloading specs again on every regeneration.

Why

After #4321, every code or asset change regenerates all API pages, and one regeneration takes 9–11 s on an 18-core Mac. A profile shows the process uses only 3–4 cores. Workstation GC throttles the allocation-heavy render. Each page also rescans the full sidebar HTML with one substring per tag. HtmlSanitizer serializes on a process-wide AngleSharp pool lock. Specs download and parse one version at a time on every regeneration.

What

Server GC in the dev container

The serve service sets DOTNET_gcServer=1. With workstation GC, extra render threads add no speed. With server GC, a regeneration takes 3.3–4.3 s. The setting applies to the dev container only, so CI and the AOT binaries keep their current GC.

Parallel pages and versions

Pages of one API version now render in parallel, and the versions of one product download and parse in parallel. The shared schema cache becomes concurrent. Sanitized description HTML is cached per version, so each distinct fragment goes through the sanitizer lock once. When two navigation items share a URL, the last one still wins, as in the old depth-first order.

Spec bodies are kept between regenerations

VersionIndexClient takes an optional SpecBodyCache. serve keeps one cache for the life of the server and creates a new client for each regeneration. A regeneration re-parses specs without downloading them again, and a failed version index fetch is retried on the next regeneration. Assembler and isolated builds pass no cache, so they do not hold spec bodies in memory.

Faster sidebar marking for every page

The marker that stamps current on the cached sidebar HTML now inspects tags as spans and applies all edits in one pass. Before, it allocated one string per tag and copied the whole sidebar once per edit. This code also runs for Markdown pages in isolated and assembler builds.

Irregular spaces render as spaces

The space normalizer reused one shared Markdig inline node for every match. A second irregular space in a paragraph moved that node, so an earlier space disappeared. The result also depended on parse order across documents. Each match now gets its own node. In this repo's docs, 23 Elasticsearch API pages change: placement — or used to render as placement —or. The "irregular space" hint is now tracked per build instead of per process, so a serve reload or a second test with the same file name still gets the hint.

Verify

dotnet test tests/Elastic.Authoring.Tests/
# MultipleIrregularSpaces — two thin spaces in one paragraph
# SpaceDetection — the hint still fires when another test parsed the same file name first
dotnet test tests/Elastic.ApiExplorer.Tests/
# FetchSpecStreamAsync_TwoClients_DownloadOnceOnlyWithSharedCache
dotnet test tests/Navigation.Tests/
./dev.sh serve
# open http://localhost:3000/api/doc/elasticsearch/, edit an API Explorer .cshtml, and time the refresh

A docs build from this branch matches one from #4321 once asset hashes are normalized. The only differences are the 23 pages with the thin-space fix.

🤖 Generated with Claude Code

@akira28
akira28 added this pull request to stack #4323 October 6, 2026 10:34
@akira28 akira28 changed the title dev/api render parallel pages Speed up API page regeneration in serve to under 4 seconds Oct 6, 2026
@akira28
akira28 marked this pull request as ready for review October 6, 2026 10:36
@akira28
akira28 requested a review from a team as a code owner October 6, 2026 10:36
@akira28
akira28 requested a review from technige October 6, 2026 10:36
DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER: "1"
# API page regeneration allocates heavily on every core; workstation GC
# throttled it to a few cores (about 10 s per regeneration vs 3-4 s).
DOTNET_gcServer: "1"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick uppercase env vars :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would, but the runtime reads this one case-sensitively on Linux and macOS. A one-line app that prints GCSettings.IsServerGC shows True for DOTNET_gcServer=1, and False for DOTNET_GCSERVER=1 and DOTNET_GCserver=1. Uppercasing it would silently turn server GC off. I added a comment in the compose file saying so (f7b6db5).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes due to a reliability regression: API regeneration in serve can become permanently stuck after a single transient root-index fetch failure because the new long-lived VersionIndexClient memoizes that failure for the lifetime of the process.


What is this? | From workflow: PR Review

Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.

private volatile bool _forceApiRegeneration;
private readonly SemaphoreSlim _apiSemaphore = new(1, 1);
// Outlives each OpenApiGenerator so regenerations reuse the version index and downloaded specs.
private readonly VersionIndexClient _versionIndexClient = new() { CacheSpecBodies = true };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Persistent VersionIndexClient can make API regeneration permanently fail after one transient index outage

Reusing a single VersionIndexClient instance across the full serve lifetime changes failure behavior for root-index fetches: VersionIndexClient.GetRootIndexAsync memoizes the first failure (_rootIndexFetched = true plus _rootIndexFetchError) and then returns that cached error on every later call, without retrying.

That means one temporary index.json fetch failure during serve startup can keep all subsequent API regenerations failing until the process is restarted, even after connectivity recovers.

Please keep spec-body caching, but avoid permanently caching root-index failures for the long-lived client path (for example: only mark _rootIndexFetched when index fetch/parse succeeds, or clear the fetched/error state after a failure so the next regeneration retries).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in bd47520. serve no longer keeps a VersionIndexClient for its whole life. Each regeneration creates its own client, so the root index is fetched again and a failure is retried next time. Only a new SpecBodyCache, passed in via SpecBodies, outlives regenerations, so specs are still downloaded once. Within a single build, the root index result is still memoized per client as before. Warm regeneration stays at about 2.5–5 s.

akira28 and others added 3 commits October 6, 2026 12:55
The space normalizer reused one shared inline node for every match. Markdig inlines are linked-list nodes, so a second match moved the node and the earlier space disappeared. The output also depended on parse order across documents, so parallel parses could race on the shared node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regeneration was bound by workstation GC, a full sidebar rescan on every page, and specs downloaded and resolved one version at a time. The dev container now runs server GC. The sidebar marker scans spans and edits in one pass. Pages and versions render in parallel, and serve keeps downloaded spec bodies between regenerations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hint dedupe used a process-wide set. A later build in the same process, such as a serve reload or a test that reuses a file name, got no hint for that file. The set is now scoped to the build's diagnostics collector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@akira28
akira28 force-pushed the dev/api-render-parallel-pages branch from f3c05d2 to f7b6db5 Compare October 6, 2026 10:58
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Docs preview (local build)

Handbook preview: https://docs-v3-preview.elastic.dev/elastic/docs-builder/pull/4322/

One VersionIndexClient lived for the whole serve process. It memoizes the root index result, including a failure, so a single network error kept every later regeneration from resolving specs. Serve now creates a client per regeneration and shares only a SpecBodyCache, so specs are still downloaded once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No actionable issues found in the current revision; approving.


What is this? | From workflow: PR Review

Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.

@akira28
akira28 merged commit f0bd345 into main Oct 6, 2026
37 checks passed
@akira28
akira28 deleted the dev/api-render-parallel-pages branch October 6, 2026 11:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants