From 882842afe5f75a29a64373d389ed6b3f9286f510 Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Tue, 6 Oct 2026 12:34:24 +0200 Subject: [PATCH 1/4] Render every irregular space in a paragraph as a space 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 --- src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs | 9 ++++----- .../Elastic.Authoring.Tests/Linters/SpaceNormalizers.cs | 9 +++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs b/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs index a7df69a9cd..a48b0f9f7d 100644 --- a/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs +++ b/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs @@ -101,7 +101,9 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) if (span.IndexOfAny(SpaceSearchValues) == -1) return false; - processor.Inline = IrregularSpace.Instance; + // Each match needs its own node: an inline is a linked-list node, so reusing one instance + // moves it and drops earlier occurrences, and races across documents parsed in parallel. + processor.Inline = new IrregularSpace(); // Emit a single hint per file on first detection var context = processor.GetContext(); @@ -125,10 +127,7 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) } } -public class IrregularSpace : LeafInline -{ - public static readonly IrregularSpace Instance = new(); -}; +public class IrregularSpace : LeafInline; public class SpaceNormalizerRenderer : HtmlObjectRenderer { diff --git a/tests/Elastic.Authoring.Tests/Linters/SpaceNormalizers.cs b/tests/Elastic.Authoring.Tests/Linters/SpaceNormalizers.cs index c93e73490c..518b893b4e 100644 --- a/tests/Elastic.Authoring.Tests/Linters/SpaceNormalizers.cs +++ b/tests/Elastic.Authoring.Tests/Linters/SpaceNormalizers.cs @@ -16,3 +16,12 @@ public class SpaceDetection : MarkdownTest public async Task EmitsHint() => await Docs.HasHint("Irregular space detected. Run 'docs-builder format --write' to automatically fix all instances."); } + +public class MultipleIrregularSpaces : MarkdownTest +{ + //   is a thin space; OpenAPI descriptions use it around em dashes. + protected override string Markdown => "shard placement — or routing\n"; + + [Test, DisplayName("validate HTML: every irregular space in a paragraph becomes a space")] + public async Task ValidateHtml() => await Docs.ConvertsToHtml("

shard placement — or routing

"); +} From 80942de91233b656815609efbbabca899ba9c58d Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Tue, 6 Oct 2026 12:34:27 +0200 Subject: [PATCH 2/4] Cut serve API page regeneration from about 10 s to under 4 s 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 --- build/dev/docker-compose.yml | 3 + .../PropertyTree/PropertyDisplayOptions.cs | 3 +- .../Infrastructure/ApiMarkdown.cs | 2 +- .../Infrastructure/ApiRenderContext.cs | 12 +- .../Model/SchemaAnalyzer.cs | 5 +- .../Model/VersionIndexClient.cs | 25 ++- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 56 +++--- .../Navigation/NavigationCurrentMarker.cs | 164 ++++++++++-------- .../Http/ReloadableGeneratorState.cs | 6 +- .../OpenApiGeneratorCatalogSplitTests.cs | 5 +- .../OpenApiGeneratorMarkdownEmissionTests.cs | 5 +- .../OpenApiGeneratorMultiVersionTests.cs | 44 +++-- .../OpenApiGeneratorSpecDownloadTests.cs | 5 +- .../VersionIndexClientTests.cs | 34 ++++ 14 files changed, 241 insertions(+), 128 deletions(-) diff --git a/build/dev/docker-compose.yml b/build/dev/docker-compose.yml index f9d01a769e..81509ee871 100644 --- a/build/dev/docker-compose.yml +++ b/build/dev/docker-compose.yml @@ -41,6 +41,9 @@ 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). + DOTNET_gcServer: "1" ports: - "127.0.0.1:3000:3000" volumes: diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/PropertyDisplayOptions.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/PropertyDisplayOptions.cs index a6321c485d..53cdbc92dd 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/PropertyDisplayOptions.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/PropertyDisplayOptions.cs @@ -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; @@ -47,5 +48,5 @@ public record PropertyDisplayOptions /// it creates so component schemas are looked up in /// OpenApiDocument.Components at most once per unit rather than on every proxy access. /// - internal Dictionary? SchemaResolveCache { get; init; } + internal ConcurrentDictionary? SchemaResolveCache { get; init; } } diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiMarkdown.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiMarkdown.cs index 78d95d4703..c9c9a8ba6e 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiMarkdown.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiMarkdown.cs @@ -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. diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs index 5748729fbc..793ac40865 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs @@ -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; @@ -63,7 +64,14 @@ internal IReadOnlyDictionary TagSupplemental /// /// Records propagate reference-type properties shallowly via with, 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. /// - internal Dictionary SchemaResolveCache { get; } = []; + internal ConcurrentDictionary SchemaResolveCache { get; } = new(); + + /// + /// Per-generation-unit cache of sanitized description HTML, shared like . + /// 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. + /// + internal ConcurrentDictionary SanitizedHtmlCache { get; } = new(StringComparer.Ordinal); } diff --git a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs index 093bc07b6a..cba015689c 100644 --- a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs +++ b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs @@ -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; @@ -25,12 +26,12 @@ namespace Elastic.ApiExplorer.Model; public class SchemaAnalyzer( OpenApiDocument document, string? currentPageType = null, - Dictionary? resolveCache = null + ConcurrentDictionary? 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 _cache = resolveCache ?? []; + private readonly ConcurrentDictionary _cache = resolveCache ?? new(); /// /// Checks if a type should link to its container page, considering the current page. diff --git a/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs b/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs index 3a24dd7bc2..85e8ee112f 100644 --- a/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs +++ b/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs @@ -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.IO.Abstractions; using System.Net; using System.Text.Json; @@ -65,6 +66,7 @@ public sealed class VersionIndexClient : IDisposable private readonly int _maxAttempts; private readonly Func _sleep; + private readonly ConcurrentDictionary _specBodies = new(StringComparer.Ordinal); private readonly SemaphoreSlim _rootIndexLock = new(1, 1); private bool _rootIndexFetched; private RootVersionIndex? _rootIndex; @@ -91,6 +93,12 @@ public VersionIndexClient( } } + /// + /// Keeps downloaded spec bodies in memory so later fetches of the same object key skip the network. + /// A long-lived client in serve sets this so regenerating API pages only re-parses specs. + /// + public bool CacheSpecBodies { get; init; } + public async Task> ResolveVersionsAsync( GitCheckoutInformation git, string apiKey, @@ -161,6 +169,9 @@ public async Task> ResolveVersionsAsync( $"Version '{version.Moniker}' of API '{apiKey}' is local; read {nameof(ResolvedApiVersion.LocalFile)} instead." ); + if (CacheSpecBodies && _specBodies.TryGetValue(objectKey, out var cached)) + return new MemoryStream(cached, writable: false); + var uri = new Uri(_baseUri, objectKey); string? lastError = null; var attempts = 0; @@ -170,7 +181,8 @@ public async Task> ResolveVersionsAsync( ctx.ThrowIfCancellationRequested(); try { - return await FetchStreamAsync(uri, attempt, ctx).ConfigureAwait(false); + var stream = await FetchStreamAsync(uri, attempt, ctx).ConfigureAwait(false); + return CacheSpecBodies ? await BufferSpecBody(objectKey, stream, ctx).ConfigureAwait(false) : stream; } catch (HttpRequestException ex) { @@ -187,6 +199,17 @@ public async Task> ResolveVersionsAsync( return null; } + private async Task BufferSpecBody(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 = _specBodies.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 }; diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 46f6b698c0..fa059d9e31 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -294,15 +294,14 @@ internal async Task ResolveDocumentsForProduct(string var latestDeclared = versionsToRender.Any(v => v.Moniker == "main") ? "main" : versionsToRender[0].Moniker; - var results = new List(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() + .ToList(); return ToResolvedProductDocuments(results, latestDeclared); } @@ -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 node) + if (item is INodeNavigationItem 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 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 leaf) + throw new Exception($"Unknown navigation item type {item.GetType()}"); + pages.Add((leaf, leaf.Model)); } private async Task Render( diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationCurrentMarker.cs b/src/Elastic.Documentation.Site/Navigation/NavigationCurrentMarker.cs index d72aff1549..363d63234c 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationCurrentMarker.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationCurrentMarker.cs @@ -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.Text; using Elastic.Documentation.Navigation; namespace Elastic.Documentation.Site.Navigation; @@ -33,6 +34,8 @@ public static string Apply(string html, string currentUrl) return edits.Count == 0 ? html : ApplyEdits(html, edits); } + // Runs once per rendered page over the full sidebar HTML, so tags are inspected as spans of + // the original string and only the few edited tags are materialized. private static void CollectCurrentEdits(string html, string target, List edits) { var stack = new List(); @@ -48,7 +51,7 @@ private static void CollectCurrentEdits(string html, string target, List 0) @@ -68,7 +71,7 @@ private static void CollectCurrentEdits(string html, string target, List stack, string tag, int tagStart, int tagEnd) + private static void NoteCheckbox(List stack, ReadOnlySpan tag, int tagStart, int tagEnd) { if (stack.Count == 0 || !IsCheckbox(tag)) return; @@ -83,7 +86,7 @@ private static void NoteCheckbox(List stack, string tag, int tagStart, stack[^1] = top; } - private static void NoteClip(List stack, string tag, int tagStart) + private static void NoteClip(List stack, ReadOnlySpan tag, int tagStart) { if (stack.Count == 0 || !IsTag(tag, "div", end: false) || !ElementHasClass(tag, "nav-subtree-clip")) return; @@ -105,7 +108,7 @@ private static void NoteCurrentAnchor( string html, string target, List stack, - string tag, + ReadOnlySpan tag, int tagStart, int tagEnd, List edits @@ -114,12 +117,10 @@ List edits if (!IsTag(tag, "a", end: false) || !ElementHasClass(tag, "sidebar-link")) return; - var href = GetQuotedAttribute(tag, "href"); - if (href is null || NormalizePath(href) != target) + if (!TryGetQuotedAttribute(tag, "href", out var href) || !NormalizePath(href).SequenceEqual(target)) return; - var marked = WithCurrentClass(tag); - if (!marked.Equals(tag, StringComparison.Ordinal)) + if (WithCurrentClass(tag) is { } marked) edits.Add(new SpanEdit(tagStart, tagEnd, marked)); for (var i = 0; i < stack.Count; i++) @@ -166,88 +167,98 @@ public static string ResolveActiveUrl(INavigationItem current) } internal static string NormalizePath(string url) + { + var path = NormalizePath(url.AsSpan()); + return path.Length == url.Length ? url : path.ToString(); + } + + private static ReadOnlySpan NormalizePath(ReadOnlySpan url) { var path = url; - var cut = path.IndexOfAny(['?', '#']); + var cut = path.IndexOfAny('?', '#'); if (cut >= 0) path = path[..cut]; path = path.TrimEnd('/'); - return path.Length == 0 ? "/" : path; + return path.Length == 0 ? "/".AsSpan() : path; } - private static string? GetQuotedAttribute(string tag, string name) + private static bool TryGetQuotedAttribute(ReadOnlySpan tag, string name, out ReadOnlySpan value) { - var needle = name + "=\""; - var start = tag.IndexOf(needle, StringComparison.Ordinal); + value = default; + var start = IndexOfAttribute(tag, name); if (start < 0) - return null; + return false; + + var end = tag[start..].IndexOf('"'); + if (end < 0) + return false; + + value = tag.Slice(start, end); + return true; + } - start += needle.Length; - var end = tag.IndexOf('"', start); - return end < 0 ? null : tag[start..end]; + private static int IndexOfAttribute(ReadOnlySpan tag, string name) + { + var offset = 0; + while (true) + { + var index = tag[offset..].IndexOf(name, StringComparison.Ordinal); + if (index < 0) + return -1; + + var valueStart = offset + index + name.Length; + if (tag.Length > valueStart + 1 && tag[valueStart] == '=' && tag[valueStart + 1] == '"') + return valueStart + 2; + + offset = offset + index + 1; + } } - private static string WithCurrentClass(string tag) + /// Returns the tag with current added to its class list, or when nothing changes. + private static string? WithCurrentClass(ReadOnlySpan tag) { const string prefix = " class=\""; var classStart = tag.IndexOf(prefix, StringComparison.Ordinal); if (classStart < 0) - return tag; + return null; var valueStart = classStart + prefix.Length; - var valueEnd = tag.IndexOf('"', valueStart); - if (valueEnd < 0) - return tag; + var valueLength = tag[valueStart..].IndexOf('"'); + if (valueLength < 0) + return null; - var classes = tag[valueStart..valueEnd]; - if (HasClass(classes, "current")) - return tag; + var valueEnd = valueStart + valueLength; + if (HasClass(tag[valueStart..valueEnd], "current")) + return null; - return string.Concat(tag.AsSpan(0, valueEnd), " current", tag.AsSpan(valueEnd)); + return string.Concat(tag[..valueEnd], " current", tag[valueEnd..]); } - private static bool HasClass(string classes, string name) + private static bool HasClass(ReadOnlySpan classes, string name) { - var start = 0; - while (start < classes.Length) + foreach (var range in classes.Split(' ')) { - while (start < classes.Length && classes[start] == ' ') - start++; - - var end = classes.IndexOf(' ', start); - if (end < 0) - end = classes.Length; - - if (end > start && classes.AsSpan(start, end - start).Equals(name, StringComparison.Ordinal)) + if (classes[range].Equals(name, StringComparison.Ordinal)) return true; - - start = end + 1; } return false; } - private static bool ElementHasClass(string tag, string name) - { - var classes = GetQuotedAttribute(tag, "class"); - return classes is not null && HasClass(classes, name); - } + private static bool ElementHasClass(ReadOnlySpan tag, string name) => + TryGetQuotedAttribute(tag, "class", out var classes) && HasClass(classes, name); - private static bool IsTag(string tag, string name, bool end) + private static bool IsTag(ReadOnlySpan tag, string name, bool end) { - if (end) - { - if (!tag.StartsWith(" tag) => + IsTag(tag, "input", end: false) + && TryGetQuotedAttribute(tag, "type", out var type) + && type.Equals("checkbox", StringComparison.OrdinalIgnoreCase); - private static bool HasCheckedAttribute(string tag) + private static bool HasCheckedAttribute(ReadOnlySpan tag) { + const string attribute = "checked"; var index = 0; - while ((index = tag.IndexOf("checked", index, StringComparison.OrdinalIgnoreCase)) >= 0) + while (true) { + var found = tag[index..].IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + if (found < 0) + return false; + + index += found; var before = index == 0 ? ' ' : tag[index - 1]; - var afterIndex = index + "checked".Length; + var afterIndex = index + attribute.Length; var after = afterIndex < tag.Length ? tag[afterIndex] : ' '; if ((char.IsWhiteSpace(before) || before == '<') && (char.IsWhiteSpace(after) || after is '=' or '/')) return true; index = afterIndex; } - - return false; } - private static int ClassValueEnd(string tag, int tagStart) + private static int ClassValueEnd(ReadOnlySpan tag, int tagStart) { const string prefix = " class=\""; var classStart = tag.IndexOf(prefix, StringComparison.Ordinal); if (classStart < 0) return -1; - var valueEnd = tag.IndexOf('"', classStart + prefix.Length); - return valueEnd < 0 ? -1 : tagStart + valueEnd; + var valueStart = classStart + prefix.Length; + var valueLength = tag[valueStart..].IndexOf('"'); + return valueLength < 0 ? -1 : tagStart + valueStart + valueLength; } + // Edits are applied in one pass; copying the full sidebar once per edit made pages with deep + // ancestor chains copy the whole HTML several times. private static string ApplyEdits(string html, List edits) { - edits.Sort(static (left, right) => right.Start.CompareTo(left.Start)); - var seen = new HashSet(); + edits.Sort(static (left, right) => left.Start.CompareTo(right.Start)); + var builder = new StringBuilder(html.Length + (edits.Count * 32)); + var position = 0; + var lastStart = -1; foreach (var edit in edits) { - if (!seen.Add(edit.Start)) + if (edit.Start == lastStart || edit.Start < position) continue; - html = string.Concat(html.AsSpan(0, edit.Start), edit.Text, html.AsSpan(edit.End)); + lastStart = edit.Start; + _ = builder.Append(html, position, edit.Start - position).Append(edit.Text); + position = edit.End; } - return html; + return builder.Append(html, position, html.Length - position).ToString(); } private readonly record struct SpanEdit(int Start, int End, string Text); diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index b99b59c6d2..abbc620c18 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; using Elastic.ApiExplorer; +using Elastic.ApiExplorer.Model; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; @@ -68,6 +69,8 @@ bool isWatchBuild // 1 when the next /api/ request must regenerate; consumed atomically so a concurrent invalidation is never lost. private int _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 }; private CancellationTokenSource? _apiGenerationCts; public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true) @@ -267,7 +270,7 @@ private async Task ReloadApiReferences(IMarkdownStringRenderer markdownStringRen // longer than regenerating it. Pages are overwritten in place instead; a page whose operation // was removed from the spec stays reachable until the server restarts. ApiPath.Create(); - var generator = new OpenApiGenerator(_logFactory, _context, markdownStringRenderer); + var generator = new OpenApiGenerator(_logFactory, _context, markdownStringRenderer, _versionIndexClient); await generator.Generate(ctx); } @@ -276,6 +279,7 @@ public void Dispose() _apiGenerationCts?.Cancel(); _apiGenerationCts?.Dispose(); _apiSemaphore.Dispose(); + _versionIndexClient.Dispose(); (_codexReader as IDisposable)?.Dispose(); GC.SuppressFinalize(this); } diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCatalogSplitTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCatalogSplitTests.cs index 1805b51041..eb785247df 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCatalogSplitTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCatalogSplitTests.cs @@ -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 System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; @@ -404,10 +405,10 @@ private static OpenApiDocument SpecDocument(string title) => private static IOpenApiSpecificationReader CreateSequentialReader(params OpenApiDocument[] documents) { - var queue = new Queue(documents); + var queue = new ConcurrentQueue(documents); var reader = A.Fake(); A.CallTo(() => reader.ReadAsync(A._, A._, A._)).ReturnsLazily( - _ => Task.FromResult(queue.Dequeue()) + _ => Task.FromResult(queue.TryDequeue(out var next) ? next : null) ); return reader; } diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMarkdownEmissionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMarkdownEmissionTests.cs index c5c6466e1a..5e8c7a8826 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMarkdownEmissionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMarkdownEmissionTests.cs @@ -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 System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; @@ -257,10 +258,10 @@ private static BuildContext CreateGenerateContext(string outputRoot) private static IOpenApiSpecificationReader CreateSequentialReader(params OpenApiDocument[] documents) { - var queue = new Queue(documents); + var queue = new ConcurrentQueue(documents); var reader = A.Fake(); A.CallTo(() => reader.ReadAsync(A._, A._, A._)).ReturnsLazily( - _ => Task.FromResult(queue.Dequeue()) + _ => Task.FromResult(queue.TryDequeue(out var next) ? next : null) ); return reader; } diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs index 41e8e2c791..cdaed95ebb 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs @@ -99,10 +99,10 @@ public async Task ResolveDocumentsForProduct_MultiMajorIndex_ResolvesMainAndNume var context = CreateContext(collector, stack, ProductsFor(product), GitForElasticsearch()); var handler = MultiVersionHandler(); using var versionIndexClient = new VersionIndexClient(BaseUri, handler, sleep: (_, _) => Task.CompletedTask); - var reader = CreateSequentialReader( - SpecDocument("Elasticsearch main"), - SpecDocument("Elasticsearch 9"), - SpecDocument("Elasticsearch 8") + var reader = CreateVersionedReader( + ("main", SpecDocument("Elasticsearch main")), + ("9.4", SpecDocument("Elasticsearch 9")), + ("8.19", SpecDocument("Elasticsearch 8")) ); var generator = CreateGenerator(context, versionIndexClient, reader); @@ -140,7 +140,7 @@ public async Task ResolveDocumentsForProduct_VersionlessProduct_RendersMainOnly( MultiVersionHandler(repository: "elastic/serverless-api-specification"), sleep: (_, _) => Task.CompletedTask ); - var reader = CreateSequentialReader(SpecDocument("Serverless main")); + var reader = CreateVersionedReader(("main", SpecDocument("Serverless main"))); var generator = CreateGenerator(context, versionIndexClient, reader); var apiConfig = ApiConfig( product, @@ -219,10 +219,10 @@ public async Task ResolveDocumentsForProduct_MainFetchFails_DoesNotMarkOlderSpec ); if (path.Contains("/main/", StringComparison.Ordinal)) return new HttpResponseMessage(HttpStatusCode.NotFound); - return SpecResponse(); + return SpecResponse(request); }); using var versionIndexClient = new VersionIndexClient(BaseUri, handler, maxAttempts: 1, sleep: (_, _) => Task.CompletedTask); - var reader = CreateSequentialReader(SpecDocument("Elasticsearch 9"), SpecDocument("Elasticsearch 8")); + var reader = CreateVersionedReader(("9.4", SpecDocument("Elasticsearch 9")), ("8.19", SpecDocument("Elasticsearch 8"))); var generator = CreateGenerator(context, versionIndexClient, reader); var resolved = await generator.ResolveDocumentsForProduct( @@ -265,10 +265,10 @@ public async Task Generate_WritesDistinctOutputTreesForMainAndReleasedMajors() var outputRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, $"api-explorer-output-{Guid.NewGuid():N}"); var context = CreateGenerateContext(collector, stack, ProductsFor(product), outputRoot, GitForElasticsearch()); using var versionIndexClient = new VersionIndexClient(BaseUri, MultiVersionHandler(), sleep: (_, _) => Task.CompletedTask); - var reader = CreateSequentialReader( - SpecDocument("Elasticsearch main"), - SpecDocument("Elasticsearch 9"), - SpecDocument("Elasticsearch 8") + var reader = CreateVersionedReader( + ("main", SpecDocument("Elasticsearch main")), + ("9.4", SpecDocument("Elasticsearch 9")), + ("8.19", SpecDocument("Elasticsearch 8")) ); var generator = CreateGenerator(context, versionIndexClient, reader); @@ -362,13 +362,18 @@ private static OpenApiGenerator CreateGenerator( IOpenApiSpecificationReader reader ) => new(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance, versionIndexClient, reader); - private static IOpenApiSpecificationReader CreateSequentialReader(params OpenApiDocument[] documents) + /// Returns the document whose version segment (main, 9.4, 8.19) is in the fetched spec path. + private static IOpenApiSpecificationReader CreateVersionedReader(params (string Version, OpenApiDocument Document)[] documents) { - var queue = new Queue(documents); var reader = A.Fake(); - A.CallTo(() => reader.ReadAsync(A._, A._, A._)).ReturnsLazily( - _ => Task.FromResult(queue.Dequeue()) - ); + A.CallTo(() => reader.ReadAsync(A._, A._, A._)).ReturnsLazily(call => + { + using var body = new StreamReader(call.GetArgument(0)!); + var path = body.ReadToEnd(); + return Task.FromResult( + documents.Single(d => path.Contains($"/{d.Version}/", StringComparison.Ordinal)).Document + ); + }); return reader; } @@ -396,7 +401,7 @@ private static HttpMessageHandler MultiVersionHandler(string repository = "elast ); } - return SpecResponse(); + return SpecResponse(request); }); private static GitCheckoutInformation GitForElasticsearch() => @@ -405,12 +410,13 @@ private static GitCheckoutInformation GitForElasticsearch() => private static HttpResponseMessage IndexResponse(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; - private static HttpResponseMessage SpecResponse() => + // The body carries the request path so a versioned reader can tell versions apart. + private static HttpResponseMessage SpecResponse(HttpRequestMessage request) => new(HttpStatusCode.OK) { Content = new StringContent( /*lang=json,strict*/ - """{"openapi":"3.1.0","info":{"title":"Spec","version":"1.0"},"paths":{}}""", + $$$"""{"openapi":"3.1.0","info":{"title":"{{{request.RequestUri!.AbsolutePath}}}","version":"1.0"},"paths":{}}""", System.Text.Encoding.UTF8, "application/json" ) diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorSpecDownloadTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorSpecDownloadTests.cs index 949b6d33da..58f3297207 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorSpecDownloadTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorSpecDownloadTests.cs @@ -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 System.IO.Abstractions.TestingHelpers; using System.Net; @@ -161,10 +162,10 @@ private static BuildContext CreateGenerateContext(string outputRoot) private static IOpenApiSpecificationReader CreateSequentialReader(params OpenApiDocument[] documents) { - var queue = new Queue(documents); + var queue = new ConcurrentQueue(documents); var reader = A.Fake(); A.CallTo(() => reader.ReadAsync(A._, A._, A._)).ReturnsLazily( - _ => Task.FromResult(queue.Dequeue()) + _ => Task.FromResult(queue.TryDequeue(out var next) ? next : null) ); return reader; } diff --git a/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs b/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs index 07880284fc..f990360bcd 100644 --- a/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs @@ -478,6 +478,40 @@ public async Task FetchSpecStreamAsync_HappyPath_ReturnsContent() collector.Warnings.Should().Be(0); } + [Test] + [Arguments(true, 1)] + [Arguments(false, 2)] + public async Task FetchSpecStreamAsync_FetchedTwice_DownloadsOnceOnlyWhenCaching(bool cacheSpecBodies, int expectedRequests) + { + var handler = new StubHandler( + _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(/*lang=json,strict*/ """{"openapi":"3.1.0"}""") } + ); + using var client = new VersionIndexClient(BaseUri, handler, sleep: (_, _) => Task.CompletedTask) + { + CacheSpecBodies = cacheSpecBodies + }; + var collector = new CapturingDiagnosticsCollector(); + var version = new ResolvedApiVersion + { + Moniker = "8", + Version = "8.19", + IsLocal = false, + ObjectKey = "elastic/elasticsearch/8.19/elasticsearch-openapi.json" + }; + var ctx = TestContext.Current!.Execution.CancellationToken; + + var bodies = new List(); + for (var i = 0; i < 2; i++) + { + var stream = await client.FetchSpecStreamAsync("elasticsearch", version, collector, ctx); + using var reader = new StreamReader(stream!); + bodies.Add(await reader.ReadToEndAsync(ctx)); + } + + bodies.Should().AllSatisfy(b => b.Should().Contain("openapi")); + handler.RequestedPaths.Should().HaveCount(expectedRequests); + } + [Test] public async Task FetchSpecStreamAsync_PersistentFailure_EmitsWarningAndReturnsNull() { From f7b6db570fdbdfe62ce0a7a4aaaa07eddaa67e37 Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Tue, 6 Oct 2026 12:54:24 +0200 Subject: [PATCH 3/4] Emit the irregular-space hint once per file per build 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 --- build/dev/docker-compose.yml | 1 + .../Myst/Linters/SpaceNormalizer.cs | 28 +++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/build/dev/docker-compose.yml b/build/dev/docker-compose.yml index 81509ee871..741fb81fe7 100644 --- a/build/dev/docker-compose.yml +++ b/build/dev/docker-compose.yml @@ -43,6 +43,7 @@ services: 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" ports: - "127.0.0.1:3000:3000" diff --git a/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs b/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs index a48b0f9f7d..12fae037c9 100644 --- a/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs +++ b/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs @@ -3,7 +3,10 @@ // See the LICENSE file in the project root for more information using System.Buffers; +using System.Collections.Concurrent; using System.Linq; +using System.Runtime.CompilerServices; +using Elastic.Documentation.Diagnostics; using Elastic.Markdown.Diagnostics; using Markdig; using Markdig.Helpers; @@ -90,8 +93,9 @@ public class SpaceNormalizerParser : InlineParser private static readonly char[] CharactersToFix = CharactersToRemove.Concat(CharactersToReplace).ToArray(); private static readonly SearchValues SpaceSearchValues = SearchValues.Create(CharactersToFix); - // Track which files have already had the hint emitted to avoid duplicates - private static readonly HashSet FilesWithHintEmitted = []; + // Files that already had the hint, per build: a process-wide set suppressed the hint in every + // later build of the same path (serve reloads, or tests that reuse a file name). + private static readonly ConditionalWeakTable> FilesWithHintEmitted = []; public SpaceNormalizerParser() => OpeningCharacters = CharactersToFix; @@ -109,17 +113,17 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) var context = processor.GetContext(); var filePath = context.MarkdownSourcePath.FullName; - lock (FilesWithHintEmitted) + var emitted = FilesWithHintEmitted.GetValue( + context.Build.Collector, + static _ => new ConcurrentDictionary(StringComparer.Ordinal) + ); + if (emitted.TryAdd(filePath, 0)) { - if (!FilesWithHintEmitted.Contains(filePath)) - { - _ = FilesWithHintEmitted.Add(filePath); - processor.EmitHint( - processor.Inline, - 1, - "Irregular space detected. Run 'docs-builder format --write' to automatically fix all instances." - ); - } + processor.EmitHint( + processor.Inline, + 1, + "Irregular space detected. Run 'docs-builder format --write' to automatically fix all instances." + ); } slice.SkipChar(); From bd475209f227a17a2fd5df40b8e1a23c0f77b5c3 Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Tue, 6 Oct 2026 13:14:13 +0200 Subject: [PATCH 4/4] Retry a failed version index fetch on the next serve regeneration 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 --- .../Model/SpecBodyCache.cs | 21 +++++++++++++++++++ .../Model/VersionIndexClient.cs | 17 +++++++-------- .../Http/ReloadableGeneratorState.cs | 8 +++---- .../VersionIndexClientTests.cs | 9 ++++---- 4 files changed, 37 insertions(+), 18 deletions(-) create mode 100644 src/Elastic.ApiExplorer/Model/SpecBodyCache.cs diff --git a/src/Elastic.ApiExplorer/Model/SpecBodyCache.cs b/src/Elastic.ApiExplorer/Model/SpecBodyCache.cs new file mode 100644 index 0000000000..b47216bb96 --- /dev/null +++ b/src/Elastic.ApiExplorer/Model/SpecBodyCache.cs @@ -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; + +/// +/// Downloaded spec bodies keyed by object key. Lives longer than any one , +/// so serve can re-fetch the version index on each regeneration but skip re-downloading specs. +/// +public sealed class SpecBodyCache +{ + private readonly ConcurrentDictionary _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); +} diff --git a/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs b/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs index 85e8ee112f..f2c4cab250 100644 --- a/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs +++ b/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs @@ -2,7 +2,6 @@ // 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.IO.Abstractions; using System.Net; using System.Text.Json; @@ -66,7 +65,6 @@ public sealed class VersionIndexClient : IDisposable private readonly int _maxAttempts; private readonly Func _sleep; - private readonly ConcurrentDictionary _specBodies = new(StringComparer.Ordinal); private readonly SemaphoreSlim _rootIndexLock = new(1, 1); private bool _rootIndexFetched; private RootVersionIndex? _rootIndex; @@ -94,10 +92,11 @@ public VersionIndexClient( } /// - /// Keeps downloaded spec bodies in memory so later fetches of the same object key skip the network. - /// A long-lived client in serve sets this so regenerating API pages only re-parses specs. + /// When set, downloaded spec bodies are stored here and later fetches of the same object key skip + /// the network. serve 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. /// - public bool CacheSpecBodies { get; init; } + public SpecBodyCache? SpecBodies { get; init; } public async Task> ResolveVersionsAsync( GitCheckoutInformation git, @@ -169,7 +168,7 @@ public async Task> ResolveVersionsAsync( $"Version '{version.Moniker}' of API '{apiKey}' is local; read {nameof(ResolvedApiVersion.LocalFile)} instead." ); - if (CacheSpecBodies && _specBodies.TryGetValue(objectKey, out var cached)) + if (SpecBodies is not null && SpecBodies.TryGet(objectKey, out var cached)) return new MemoryStream(cached, writable: false); var uri = new Uri(_baseUri, objectKey); @@ -182,7 +181,7 @@ public async Task> ResolveVersionsAsync( try { var stream = await FetchStreamAsync(uri, attempt, ctx).ConfigureAwait(false); - return CacheSpecBodies ? await BufferSpecBody(objectKey, stream, ctx).ConfigureAwait(false) : stream; + return SpecBodies is { } cache ? await BufferSpecBody(cache, objectKey, stream, ctx).ConfigureAwait(false) : stream; } catch (HttpRequestException ex) { @@ -199,13 +198,13 @@ public async Task> ResolveVersionsAsync( return null; } - private async Task BufferSpecBody(string objectKey, Stream stream, Cancel ctx) + private static async Task 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 = _specBodies.GetOrAdd(objectKey, buffer.ToArray()); + var body = cache.GetOrAdd(objectKey, buffer.ToArray()); return new MemoryStream(body, writable: false); } } diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index abbc620c18..837797f734 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -69,8 +69,8 @@ bool isWatchBuild // 1 when the next /api/ request must regenerate; consumed atomically so a concurrent invalidation is never lost. private int _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 }; + // Outlives each regeneration so specs are not downloaded again; the version index is re-fetched each time. + private readonly SpecBodyCache _specBodies = new(); private CancellationTokenSource? _apiGenerationCts; public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true) @@ -270,7 +270,8 @@ private async Task ReloadApiReferences(IMarkdownStringRenderer markdownStringRen // longer than regenerating it. Pages are overwritten in place instead; a page whose operation // was removed from the spec stays reachable until the server restarts. ApiPath.Create(); - var generator = new OpenApiGenerator(_logFactory, _context, markdownStringRenderer, _versionIndexClient); + using var versionIndexClient = new VersionIndexClient { SpecBodies = _specBodies }; + var generator = new OpenApiGenerator(_logFactory, _context, markdownStringRenderer, versionIndexClient); await generator.Generate(ctx); } @@ -279,7 +280,6 @@ public void Dispose() _apiGenerationCts?.Cancel(); _apiGenerationCts?.Dispose(); _apiSemaphore.Dispose(); - _versionIndexClient.Dispose(); (_codexReader as IDisposable)?.Dispose(); GC.SuppressFinalize(this); } diff --git a/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs b/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs index f990360bcd..61911a8ca1 100644 --- a/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs @@ -481,15 +481,12 @@ public async Task FetchSpecStreamAsync_HappyPath_ReturnsContent() [Test] [Arguments(true, 1)] [Arguments(false, 2)] - public async Task FetchSpecStreamAsync_FetchedTwice_DownloadsOnceOnlyWhenCaching(bool cacheSpecBodies, int expectedRequests) + public async Task FetchSpecStreamAsync_TwoClients_DownloadOnceOnlyWithSharedCache(bool shareCache, int expectedRequests) { var handler = new StubHandler( _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(/*lang=json,strict*/ """{"openapi":"3.1.0"}""") } ); - using var client = new VersionIndexClient(BaseUri, handler, sleep: (_, _) => Task.CompletedTask) - { - CacheSpecBodies = cacheSpecBodies - }; + var cache = shareCache ? new SpecBodyCache() : null; var collector = new CapturingDiagnosticsCollector(); var version = new ResolvedApiVersion { @@ -500,9 +497,11 @@ public async Task FetchSpecStreamAsync_FetchedTwice_DownloadsOnceOnlyWhenCaching }; var ctx = TestContext.Current!.Execution.CancellationToken; + // One client per regeneration, as serve does, so only the spec cache carries over. var bodies = new List(); for (var i = 0; i < 2; i++) { + using var client = new VersionIndexClient(BaseUri, handler, sleep: (_, _) => Task.CompletedTask) { SpecBodies = cache }; var stream = await client.FetchSpecStreamAsync("elasticsearch", version, collector, ctx); using var reader = new StreamReader(stream!); bodies.Add(await reader.ReadToEndAsync(ctx));