fix: pool self-fetch connections; upgrade DeweySearch to 0.2.0 - #70
Merged
Conversation
… per fetch HttpDispatcher.CreateClient() built a fresh HttpClientHandler on every call, and both callers (RenderedHtmlFetcher, OutputGenerationService) wrap the result in `using`. Nothing could pool: every self-fetch opened a new loopback socket that then sat in TIME_WAIT for four minutes. On a corpus larger than the ~16k Windows ephemeral port range that exhausts the range partway through the crawl, and the remaining fetches fail with SocketError.AddressAlreadyInUse. SiteProjection.RenderOneAsync treats a failed fetch as a per-page content error, so those pages are dropped from the search index and llms.txt with only a warning. Measured on a 22,178-page site: 6,641 routes silently missing, ~30% of the corpus. Dev-serve only. Build and diag swap Kestrel for TestServer, which dispatches in-memory with no sockets, so static output was unaffected. Build the handler chain once and share it, handing out HttpClient(handler, disposeHandler: false) wrappers so a caller's `using` releases the wrapper and leaves the pool intact. MaxConnectionsPerServer is bounded at ProcessorCount * 2 so a parallel burst reuses connections rather than opening more than it needs. The base address is still resolved per call, since on the Kestrel path it is not known until the server binds. After the fix the same 22,178-page crawl completes with zero socket failures and a complete index.
0.2.0 keeps the C# API identical (IndexBuilder.Build, SearchIndex.ToFiles,
SearchDocument), so no library source changes. What moves is the emitted wire
format: the document table leaves index.json for cold d-{n}.json shards, so the
entrypoint carries only what ranking needs before a result is displayed.
Four integration tests asserted on index.json's `docs` array and broke. Rather
than repeat the decode in each, add SearchIndexReader: one test-side reader that
loads the manifest plus doc shards and yields decoded IndexedDoc rows. Urls and
titles inside a shard are front-coded against the previous row (leading char is
the shared-prefix length as c - '0') and breadcrumbs are indices into the
manifest's label dictionary. The reader deserializes into DeweySearch's own
IndexManifest/DocShard types so the field-name contract stays the package's to
define, and it is now the only place in the suite that knows the layout.
Update the search explanation page, which described the document table as living
in index.json. Entrypoint vs. cold table on this docs site: 2,695 records ship a
43 KB index.json with the 183 KB document table held back until a query hits.
|
🛰️ Docs preview: https://pr-70.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.
Two independent changes that surfaced while profiling a 22,178-page site (cakebuild.net) whose search index took ~3 minutes to warm up on every dev start.
1. Self-fetch connections were never pooled
HttpDispatcher.CreateClient()built a freshHttpClientHandleron every call, and both callers —RenderedHtmlFetcherandOutputGenerationService— wrap the result inusing. Nothing could pool: every self-fetch opened a new loopback socket that then sat in TIME_WAIT for four minutes.Once the corpus exceeds the ~16k Windows ephemeral port range, the range is exhausted partway through the crawl and every remaining fetch fails with
SocketError.AddressAlreadyInUse.SiteProjection.RenderOneAsynctreats a failed fetch as a per-page content error, so those pages vanish from the search index and llms.txt behind a warning.Measured on the 22,178-page site: 6,641 routes silently missing — about 30% of the corpus. The projection's own debug line read
22178 refreshed, which is the attempted count computed before rendering, so nothing in the logs suggested a problem.Dev-serve only. Build and diag swap Kestrel for
TestServer(PenningtonExtensions.cs:87), which dispatches in-memory with no sockets — static output was never affected.Fix
Build the handler chain once and share it, handing out
HttpClient(handler, disposeHandler: false)wrappers so a caller'susingreleases the wrapper and leaves the pool intact.MaxConnectionsPerServeris bounded atProcessorCount * 2. The base address is still resolved per call, since on the Kestrel path it isn't known until the server binds.New regression test drives 200 distinct paths through a real Kestrel host and counts client ports server-side — distinct on purpose, since
CachingHttpHandlerreplays per path and repeating one URL would pass vacuously:After the fix the same 22,178-page crawl completes with zero socket failures and a complete index.
2. DeweySearch 0.2.0
The C# API is unchanged (
IndexBuilder.Build,SearchIndex.ToFiles,SearchDocument), so no library source changes. What moves is the emitted wire format: the document table leavesindex.jsonfor coldd-{n}.jsonshards, leaving the entrypoint carrying only what ranking needs before a result is displayed.Four integration tests asserted on
index.json'sdocsarray and broke. Rather than repeat the decode four times,SearchIndexReaderloads the manifest plus doc shards and yields decodedIndexedDoc(Id, Url, Title, Crumbs, Priority)rows. Urls and titles are front-coded against the previous row (leading char is the shared-prefix length asc - '0'); breadcrumbs are indices into the manifest's label dictionary. It deserializes into DeweySearch's ownIndexManifest/DocShardtypes so the field-name contract stays the package's to define, and it's now the only place in the suite that knows the layout.Entrypoint vs. cold table on this docs site: 2,695 records ship a 43 KB
index.jsonwith the 183 KB document table held back until a query produces hits.The search explanation page described the document table as living in
index.jsonand is updated to match.Verification
Follow-ups (not in this PR)
DocCodechas no public front-code decoder — it exportsFrontCodebut no inverse, so every consumer reading an index back has to reimplement it. Worth exposing on the DeweySearch side;SearchIndexReadercould then drop its decode.RenderOneAsyncswallows infrastructure failures as per-page content errors. 6,641 pages failing from one cause is indistinguishable from 6,641 individually broken pages — the same class of problemSelfFetchUnavailableExceptionalready solves for the not-started case. A failure-rate threshold that fails the projection loudly would have caught this immediately.SiteProjectionOptions.ContentSelectorthen discards it: 202 KB rendered and parsed per page to keep 8.7 KB. Guarding the host layout withCorpusFetchScope.InsideCorpusFetchtook the crawl from 169.0s to 8.6s (19.6x) with a byte-identical index. That's a host-side change today; worth considering whether Pennington should make it first-class.