Skip to content
Merged
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
4 changes: 4 additions & 0 deletions build/dev/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ services:
DOCS_BUILDER_BIND_ADDRESS: 0.0.0.0
DOTNET_USE_POLLING_FILE_WATCHER: "1"
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).
# The runtime matches this name case-sensitively: DOTNET_GCSERVER is ignored.
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).

ports:
- "127.0.0.1:3000:3000"
volumes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Collections.Concurrent;
using Elastic.ApiExplorer.Model;
using Elastic.ApiExplorer.Operations;
using Elastic.Documentation.Configuration.Versions;
Expand Down Expand Up @@ -47,5 +48,5 @@ public record PropertyDisplayOptions
/// <see cref="SchemaAnalyzer"/> it creates so component schemas are looked up in
/// <c>OpenApiDocument.Components</c> at most once per unit rather than on every proxy access.
/// </summary>
internal Dictionary<string, IOpenApiSchema?>? SchemaResolveCache { get; init; }
internal ConcurrentDictionary<string, IOpenApiSchema?>? SchemaResolveCache { get; init; }
}
2 changes: 1 addition & 1 deletion src/Elastic.ApiExplorer/Infrastructure/ApiMarkdown.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public static HtmlString Render(ApiRenderContext context, string? markdown)
var rewritten = Prepare(markdown, context.CurrentNavigation.NavigationRoot.Url);
var source = CreateVirtualSource(context);
var html = context.MarkdownRenderer.RenderApiDescription(rewritten, source);
return new HtmlString(SanitizeHtml(html));
return new HtmlString(string.IsNullOrEmpty(html) ? html : context.SanitizedHtmlCache.GetOrAdd(html, SanitizeHtml));
}

// HtmlSanitizer defaults already cover all standard HTML tags and exclude script/on*/etc.
Expand Down
12 changes: 10 additions & 2 deletions src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Collections.Concurrent;
using System.Collections.Frozen;
using Elastic.ApiExplorer.Landing;
using Elastic.ApiExplorer.Model;
Expand Down Expand Up @@ -63,7 +64,14 @@ internal IReadOnlyDictionary<string, ApiSupplementalDoc> TagSupplemental
/// <remarks>
/// Records propagate reference-type properties shallowly via <c>with</c>, so this dictionary is
/// the same object instance in the unit context and in every page-level copy derived from it.
/// Pages within a unit are still processed sequentially, so no synchronisation is needed.
/// Pages within a unit render in parallel, so the cache must be concurrent.
/// </remarks>
internal Dictionary<string, IOpenApiSchema?> SchemaResolveCache { get; } = [];
internal ConcurrentDictionary<string, IOpenApiSchema?> SchemaResolveCache { get; } = new();

/// <summary>
/// Per-generation-unit cache of sanitized description HTML, shared like <see cref="SchemaResolveCache"/>.
/// The same descriptions repeat across many pages, and HtmlSanitizer serializes on a process-wide
/// AngleSharp pool lock, so sanitizing each distinct fragment once keeps parallel rendering busy.
/// </summary>
internal ConcurrentDictionary<string, string> SanitizedHtmlCache { get; } = new(StringComparer.Ordinal);
}
5 changes: 3 additions & 2 deletions src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Collections.Concurrent;
using Elastic.ApiExplorer.Operations;
using Microsoft.OpenApi;

Expand All @@ -25,12 +26,12 @@ namespace Elastic.ApiExplorer.Model;
public class SchemaAnalyzer(
OpenApiDocument document,
string? currentPageType = null,
Dictionary<string, IOpenApiSchema?>? resolveCache = null
ConcurrentDictionary<string, IOpenApiSchema?>? resolveCache = null
)
{
// Per-unit schema resolve cache; shared (by reference) across all pages that use the same ApiRenderContext.
// Falls back to a fresh per-instance dict when no external cache is provided.
private readonly Dictionary<string, IOpenApiSchema?> _cache = resolveCache ?? [];
private readonly ConcurrentDictionary<string, IOpenApiSchema?> _cache = resolveCache ?? new();

/// <summary>
/// Checks if a type should link to its container page, considering the current page.
Expand Down
21 changes: 21 additions & 0 deletions src/Elastic.ApiExplorer/Model/SpecBodyCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;

namespace Elastic.ApiExplorer.Model;

/// <summary>
/// Downloaded spec bodies keyed by object key. Lives longer than any one <see cref="VersionIndexClient"/>,
/// so <c>serve</c> can re-fetch the version index on each regeneration but skip re-downloading specs.
/// </summary>
public sealed class SpecBodyCache
{
private readonly ConcurrentDictionary<string, byte[]> _bodies = new(StringComparer.Ordinal);

internal bool TryGet(string objectKey, [NotNullWhen(true)] out byte[]? body) => _bodies.TryGetValue(objectKey, out body);

internal byte[] GetOrAdd(string objectKey, byte[] body) => _bodies.GetOrAdd(objectKey, body);
}
24 changes: 23 additions & 1 deletion src/Elastic.ApiExplorer/Model/VersionIndexClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ public VersionIndexClient(
}
}

/// <summary>
/// When set, downloaded spec bodies are stored here and later fetches of the same object key skip
/// the network. <c>serve</c> shares one cache across regenerations while each regeneration gets a
/// new client, so a failed version index fetch is retried instead of memoized for the process.
/// </summary>
public SpecBodyCache? SpecBodies { get; init; }

public async Task<IReadOnlyList<ResolvedApiVersion>> ResolveVersionsAsync(
GitCheckoutInformation git,
string apiKey,
Expand Down Expand Up @@ -161,6 +168,9 @@ public async Task<IReadOnlyList<ResolvedApiVersion>> ResolveVersionsAsync(
$"Version '{version.Moniker}' of API '{apiKey}' is local; read {nameof(ResolvedApiVersion.LocalFile)} instead."
);

if (SpecBodies is not null && SpecBodies.TryGet(objectKey, out var cached))
return new MemoryStream(cached, writable: false);

var uri = new Uri(_baseUri, objectKey);
string? lastError = null;
var attempts = 0;
Expand All @@ -170,7 +180,8 @@ public async Task<IReadOnlyList<ResolvedApiVersion>> ResolveVersionsAsync(
ctx.ThrowIfCancellationRequested();
try
{
return await FetchStreamAsync(uri, attempt, ctx).ConfigureAwait(false);
var stream = await FetchStreamAsync(uri, attempt, ctx).ConfigureAwait(false);
return SpecBodies is { } cache ? await BufferSpecBody(cache, objectKey, stream, ctx).ConfigureAwait(false) : stream;
}
catch (HttpRequestException ex)
{
Expand All @@ -187,6 +198,17 @@ public async Task<IReadOnlyList<ResolvedApiVersion>> ResolveVersionsAsync(
return null;
}

private static async Task<Stream> BufferSpecBody(SpecBodyCache cache, string objectKey, Stream stream, Cancel ctx)
{
await using (stream.ConfigureAwait(false))
{
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, ctx).ConfigureAwait(false);
var body = cache.GetOrAdd(objectKey, buffer.ToArray());
return new MemoryStream(body, writable: false);
}
}

private static ResolvedApiVersion LocalMain(IFileInfo localFile) =>
new() { Moniker = "main", Version = "main", IsLocal = true, LocalFile = localFile };

Expand Down
56 changes: 34 additions & 22 deletions src/Elastic.ApiExplorer/OpenApiGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,15 +294,14 @@ internal async Task<ResolvedProductDocuments> ResolveDocumentsForProduct(string

var latestDeclared = versionsToRender.Any(v => v.Moniker == "main") ? "main" : versionsToRender[0].Moniker;

var results = new List<VersionedOpenApiDocument>(versionsToRender.Length);
foreach (var version in versionsToRender)
{
var document = await ResolveDocumentForVersion(apiKey, apiConfig, version, ctx).ConfigureAwait(false);
if (document is null)
continue;

results.Add(new VersionedOpenApiDocument(version, document));
}
// Download and parse every version at once; results keep the declared version order.
var documents = await Task.WhenAll(
versionsToRender.Select(v => ResolveDocumentForVersion(apiKey, apiConfig, v, ctx))
).ConfigureAwait(false);
var results = versionsToRender
.Zip(documents, static (version, document) => document is null ? null : new VersionedOpenApiDocument(version, document))
.OfType<VersionedOpenApiDocument>()
.ToList();

return ToResolvedProductDocuments(results, latestDeclared);
}
Expand Down Expand Up @@ -507,27 +506,40 @@ item is TagNavigationItem tag
private async Task RenderNavigationItems(
ApiRenderContext renderContext,
IsolatedBuildNavigationHtmlWriter navigationRenderer,
INavigationItem currentNavigation,
INavigationItem root,
Cancel ctx
)
{
if (currentNavigation is ISidebarSeparatorNavigationItem or IntroHeadingNavigationItem)
var pages = new List<(INavigationItem Item, IApiModel Model)>();
CollectPages(root, pages);

// Pages of one version render in parallel. When two items share a URL the last one wins,
// as it did when pages were written sequentially in depth-first order.
var unique = pages.AsEnumerable().Reverse().DistinctBy(p => p.Item.Url).ToArray();
await Parallel.ForEachAsync(
unique,
new ParallelOptions { CancellationToken = ctx, MaxDegreeOfParallelism = Environment.ProcessorCount },
async (page, token) => _ = await Render(page.Item, page.Model, renderContext, navigationRenderer, token).ConfigureAwait(false)
).ConfigureAwait(false);
}

private static void CollectPages(INavigationItem item, List<(INavigationItem Item, IApiModel Model)> pages)
{
if (item is ISidebarSeparatorNavigationItem or IntroHeadingNavigationItem)
return;

if (currentNavigation is INodeNavigationItem<IApiModel, INavigationItem> node)
if (item is INodeNavigationItem<IApiModel, INavigationItem> node)
{
if (currentNavigation is not ClassificationNavigationItem)
_ = await Render(node, node.Index.Model, renderContext, navigationRenderer, ctx);

if (item is not ClassificationNavigationItem)
pages.Add((node, node.Index.Model));
foreach (var child in node.NavigationItems)
await RenderNavigationItems(renderContext, navigationRenderer, child, ctx);
}
else
{
_ = currentNavigation is ILeafNavigationItem<IApiModel> leaf
? await Render(leaf, leaf.Model, renderContext, navigationRenderer, ctx)
: throw new Exception($"Unknown navigation item type {currentNavigation.GetType()}");
CollectPages(child, pages);
return;
}

if (item is not ILeafNavigationItem<IApiModel> leaf)
throw new Exception($"Unknown navigation item type {item.GetType()}");
pages.Add((leaf, leaf.Model));
}

private async Task<IFileInfo> Render<T>(
Expand Down
Loading
Loading