perf(pipeline): stop retaining a parsed DOM per page in SiteProjection - #69
Merged
Merged
Conversation
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<HeadingSection>` 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.
|
🛰️ Docs preview: https://pr-69.pennington-dev.pages.dev Rebuilt on every push to this PR; torn down when it closes. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
RenderedPagecarried an AngleSharpIElementfor every page in the corpus, and the projection held it for the life of the build. AnIElementkeeps its whole owning document reachable through.Owner— so this retained each page's entire parsed DOM, chrome and navigation rail included, not just the selector-matched content the consumers asked for.Measured on a real generated page, holding 200 of them:
At docs-site scale that's invisible. On a large site it isn't. A 22k-page corpus drove private memory past 26 GB at a quarter of the crawl (free RAM hit 0 on a 64 GB box) and died with
OutOfMemoryException. BecauseOutputGenerationService.FetchPagesAsynccatches everything intoFetchOutcome.Error, it surfaces as a scattering of per-page failures rather than the systemic problem it is — which is how it went unnoticed. WithDOTNET_GCHeapHardLimit=8GBit OOMs at the same point instead of collecting harder, confirming a genuine live set rather than GC greed.Why this is safe
Only one consumer ever read the DOM:
IElement?SearchArtifactServiceSections+ guardHeadingSectionis pure value dataBookComposerpage.HtmlBookArtifactServiceLinkAuditor(viaAuditRunner)page.HtmlLlmsTxtServiceConvert(page.Content, …)BookComposeralready did the right thing, with a comment saying why. The pattern just wasn't applied where it mattered.The change
RenderedPage—bool HasContentand an eagerIReadOnlyList<HeadingSection>replaceIElement? ContentandLazy<…> Sections.SiteProjection.RenderOneAsync— extracts sections while the element is still live, then returns; the owning document is unreachable immediately after. The eagerness is load-bearing, not an oversight, and is commented as such.RenderedHtmlFetcher's signature is untouched.LlmsTxtService— re-parsespage.Htmlper iteration via anHtmlParseronBuildContext. The document is collectable straight after conversion, so peak cost tracks concurrency rather than corpus size.HasContent.Verification
examples/ScaleStressExample(5,000 pages,dotnet run -- build), A/B across the same commit:2.9x less, and the output is byte-identical — 30,224 files,
diff -rqclean.dotnet buildclean; full suite green (1,243 passed, 4 skipped — the gated Chromium PDF test).The 2.9x understates what content-heavy sites get: ScaleStress pages are small generated markdown, and the ratio scales with DOM-to-HTML size. On a real API-reference page with a large chrome sidebar it measured ~10x.
Breaking change
RenderedPage.Contentis removed from the public API. Consumers matching on it should testHasContentand re-parseHtmlwhere they genuinely need a DOM. Nothing in this repo outside the six changed files was reading it — the compiler found every call site, and only two test files needed updating.Note on
LinkAuditorAn earlier draft of this description claimed the build "re-renders the corpus a second time" via
LinkAuditor. That was wrong —BuildHtmlCachecollapses repeat self-fetches, so the audit pass replays cached renders rather than re-rendering. Measured on the same 5,000-page corpus by removing theIRenderedAuditorregistration:LinkAuditor~38 MB and no measurable wall-time. No opt-out is warranted; the auditor earns its keep.
Its cost does scale with links-per-page, and that is governed by
SiteProjectionOptions.ContentSelector—LinkAuditorreadspage.Html, which is selector-scoped. A host that leaves the selector unset audits its own chrome on every page. On one such site that measured 40 links/page instead of 6 (0.9M vs 0.1M checks corpus-wide), which is a host misconfiguration rather than a framework defect.Formatting
Trailing-newline and whitespace churn in the diff is the repo's Rider format-on-save; the substantive change is ~30 lines.