From 8b1e98b337330286c35d1218c5b3b0a64d7499a4 Mon Sep 17 00:00:00 2001 From: Phil Scott Date: Fri, 24 Jul 2026 17:31:53 -0400 Subject: [PATCH] perf(pipeline): stop retaining a parsed DOM per page in SiteProjection RenderedPage carried an AngleSharp IElement for every page in the corpus, and the projection held it for the life of the build. An IElement keeps its whole owning document reachable via .Owner, so this retained each page's entire parsed DOM -- chrome, navigation rail and all -- not just the selector-matched content. Measured at ~10x the cost of the page's own HTML. At docs-site scale that is invisible. On a large site it is not: a 22k-page corpus drove private memory past 26 GB at a quarter of the crawl and failed with OutOfMemoryException out of OutputGenerationService.FetchPagesAsync, which reports it as a per-page error rather than the systemic failure it is. Only one consumer actually read the DOM. BookComposer already re-parsed from page.Html into its own document; SearchArtifactService needs Sections, which is pure value data; LinkAuditor only ever wanted the HTML string. So: - RenderedPage exposes `bool HasContent` and an eager `IReadOnlyList` in place of `IElement? Content` and `Lazy<...> Sections`. - SiteProjection.RenderOneAsync extracts sections while the element is still live, then returns -- the owning document is unreachable immediately after. Eagerness is load-bearing here, not an oversight, and is commented as such. - LlmsTxtService re-parses page.Html per iteration through an HtmlParser on BuildContext, so the document is collectable straight after conversion and peak cost tracks concurrency rather than corpus size. Verified on examples/ScaleStressExample (5000 pages, `-- build`): peak private memory 1638 MB -> 565 MB (2.9x), with byte-identical output across all 30,224 generated files. BREAKING: RenderedPage.Content is removed from the public API. Consumers matching on it should test HasContent and re-parse Html where they need a DOM. --- src/Pennington.Book/BookArtifactService.cs | 18 +++-- .../Composition/BookComposer.cs | 36 +++++----- src/Pennington/LlmsTxt/LlmsTxtService.cs | 66 +++++++++++++------ src/Pennington/Pipeline/RenderedPage.cs | 23 ++++--- src/Pennington/Pipeline/SiteProjection.cs | 54 ++++++++++----- .../Search/SearchArtifactService.cs | 10 +-- .../Pennington.Book.Tests/BookTestBuilders.cs | 27 ++------ .../Pipeline/SiteProjectionTests.cs | 38 ++++++----- 8 files changed, 164 insertions(+), 108 deletions(-) diff --git a/src/Pennington.Book/BookArtifactService.cs b/src/Pennington.Book/BookArtifactService.cs index 25f1ec8e..72e5f340 100644 --- a/src/Pennington.Book/BookArtifactService.cs +++ b/src/Pennington.Book/BookArtifactService.cs @@ -38,7 +38,10 @@ public sealed class BookArtifactService : IFileWatchAware private readonly ILogger _logger; private readonly AsyncLazy _projectionLazy; - private readonly ConcurrentDictionary> _composed = new(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary> _composed = + new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> _pdfs = new(StringComparer.OrdinalIgnoreCase); /// @@ -104,7 +107,8 @@ internal IReadOnlyList EnumerateArtifacts() public async Task GetPdfAsync(string pdfPath) { var key = pdfPath.Trim('/'); - var artifact = EnumerateArtifacts().FirstOrDefault(a => a.PdfPath.Equals(key, StringComparison.OrdinalIgnoreCase)); + var artifact = EnumerateArtifacts() + .FirstOrDefault(a => a.PdfPath.Equals(key, StringComparison.OrdinalIgnoreCase)); if (artifact is null) { return null; @@ -139,7 +143,8 @@ private AsyncLazy GetComposedLazy(BookArtifact artifact) private async Task ComposeAsync(BookArtifact artifact) { var data = await _projectionLazy; - var scoped = BookScoping.ScopeToc(data.TocItems, artifact.Book.NormalizedRoutePrefix, _localization, artifact.Locale); + var scoped = BookScoping.ScopeToc(data.TocItems, artifact.Book.NormalizedRoutePrefix, _localization, + artifact.Locale); var tree = await _navigationBuilder.BuildTreeAsync(scoped, currentPath: null, locale: artifact.Locale); // Version auto-detection lives here (not in the composer) so composer tests stay @@ -164,7 +169,7 @@ private async Task BuildProjectionAsync() await foreach (var page in _projection.GetPagesAsync()) { // Reuse the existing "don't extract me" opt-out, and skip pages with no body to compose. - if (page.Toc.ExcludeFromLlms || page.Content is null) + if (page.Toc.ExcludeFromLlms || !page.HasContent) { continue; } @@ -179,7 +184,8 @@ private async Task BuildProjectionAsync() private static string NormalizePreview(string path) => path.Trim('/'); - private static int CountPages(ImmutableList tree, IReadOnlyDictionary pages) + private static int CountPages(ImmutableList tree, + IReadOnlyDictionary pages) { var count = 0; Walk(tree); @@ -219,4 +225,4 @@ internal sealed record BookArtifact( string? Locale, string Slug, string PdfPath, - string PreviewPath); + string PreviewPath); \ No newline at end of file diff --git a/src/Pennington.Book/Composition/BookComposer.cs b/src/Pennington.Book/Composition/BookComposer.cs index 8682be80..0f6cd5c9 100644 --- a/src/Pennington.Book/Composition/BookComposer.cs +++ b/src/Pennington.Book/Composition/BookComposer.cs @@ -118,7 +118,8 @@ public string Compose( : $"{siteTitle} — {book.Title}"; var sb = new StringBuilder(); - sb.Append("\n\n\n\n"); + sb.Append("\n\n\n\n"); sb.Append("").Append(Encode(documentTitle)).Append("\n"); sb.Append("\n"); // PagedConfig must precede the polyfill so the auto-run picks up the readiness callback. - sb.Append("\n"); + sb.Append( + "\n"); sb.Append("\n"); sb.Append("\n\n"); @@ -219,7 +221,7 @@ private void RenderNode( var key = NormalizePath(node.Route.CanonicalPath.Value); if (!string.IsNullOrEmpty(key) && pageByPath.TryGetValue(key, out var page) - && page.Content is not null + && page.HasContent && !string.IsNullOrEmpty(page.Html)) { var content = ProcessPageContent(page, depth, slug, rewriteHref, resolveImageSrc); @@ -471,7 +473,8 @@ private void AppendColophon(StringBuilder sb, BookStamp stamp) } var pennington = BookVersion.Pennington(); - AppendColophonLine(sb, pennington is null ? "Produced with Pennington" : $"Produced with Pennington {pennington}"); + AppendColophonLine(sb, + pennington is null ? "Produced with Pennington" : $"Produced with Pennington {pennington}"); sb.Append("\n"); } @@ -487,7 +490,8 @@ private void AppendToc( string? locale) { sb.Append("