ES|QL|DS: stop destroying a connection per record-boundary probe#154998
Draft
quackaplop wants to merge 4 commits into
Draft
ES|QL|DS: stop destroying a connection per record-boundary probe#154998quackaplop wants to merge 4 commits into
quackaplop wants to merge 4 commits into
Conversation
Record boundary probing needs a few hundred bytes from an arbitrary offset, but the length it must declare up front is unknown, so it declared the whole remainder of the object. That range is undrainable, so releasing it required abortStream, and an aborted response destroys its HTTP connection instead of returning it to the pool. Every probe then re-paid a TCP connect and TLS handshake. ChunkedStorageInputStream reads a range as bounded, fully consumed chunks via StorageObject#readBytes. Each chunk is a closed range read to completion, so it closes cleanly and its connection is reusable. Sizes grow geometrically from 32KB, so a normal record costs one request and a pathological one costs a logarithmic number. Chunk boundaries are invisible to consumers: splitter lookahead past a lone CR reads the first byte of the next chunk, and a splitter byte budget keeps counting across chunks. ChunkedProbeEquivalenceTests pins that, comparing boundaries against a plain stream at every offset with chunk sizes down to one byte. An off-by-one here would be a silently wrong row count, not a failure, so the equivalence is the safety argument. No call site uses this yet.
computeRecordAlignedMacroSplitStarts opened a range covering the rest of the file for every stride probe, read a few hundred bytes to the next record boundary, and aborted. The range was undrainable, so abort was the only exit, and an aborted response destroys its connection rather than returning it to the pool. Each of the ~150 probes over a 9.8GB ndjson file therefore re-paid a TCP connect and TLS handshake, which is most of the ~0.5s per probe seen cross-region. All three probe shapes (strided, proven, exact walk) now read through ChunkedStorageInputStream, so each request is a closed range consumed in full and closing it hands back a poolable connection. Boundaries are unchanged. The existing boundary-value tests pass untouched; the two tests that pinned the abort now assert its absence, since asserting we abort was asserting the defect. Does not close the issue on its own: ~150 probes remain serial, which is a floor no per-probe saving gets under.
computeSegments carries a near-verbatim copy of the discovery probe loop, run per macro-split on the data node to sub-segment it. It had the same defect: a range declared to the end of the split, read for a few hundred bytes, then aborted, destroying the connection. It runs ahead of any parsing dispatch, so on a 9.8GB scan roughly 15 probes per split become dead time in front of every split, on the order of 2000 reconnects across the scan. Both probe loops now share ChunkedStorageInputStream. At read time the object is a RangeStorageObject, whose readBytes already offset translates and clamps to the split view, so the chunked reads need no coordinate handling. The ticket query does not reach this path: a pushed row limit makes openWithParallelism bail before segmenting. This is for scans and aggregates. Segment boundaries are unchanged; the datasources suite passes with only the two abort-pinning tests inverted.
Discovery fans out across files, so a dataset of one file gets no parallelism at all — and one large file is exactly where boundary probing dominates planning. ~150 probes over a 9.8GB ndjson file ran back to back on one thread, each a round trip to a distant store. A strided splitter resolves a boundary at any offset without knowing the previous one, so its probes are independent. The strided path now fixes its grid up front at stride multiples and, when discovery is not already using the pool for files, probes the grid through BoundedParallelGather. The two branches are complements by construction, so a probe gather is never nested inside a file gather. This replaces a chain that advanced by lastBoundary + stride. Both emit true record starts and tile the file; the grid does not accumulate drift, so a split can be shorter than a stride by up to the record straddling its grid point. A record spanning several grid points answers each with the same boundary, so repeats are dropped, and a probe that finds none truncates the grid, leaving the remainder as one trailing split, as the chain did. Probes return the splitter sentinel rather than throwing: a throw would fast-fail the gather and discard boundaries siblings had already found. Cancellation still throws, which is the point. Boundaries are identical with and without an executor, and that is asserted rather than assumed.
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.
What this is about
A
FROM <dataset> | LIMIT 10over a single ~9.8 GB ndjson file on S3 in another region takes ~73 seconds and returns ten rows. The same file read through a localfile://dataset returns in 0.35s. All of the time is spent in planning, before a row is read, in newline-aligned macro-split discovery.Discovery walks the file in
target_split_sizestrides — about 150 of them at the 64 MB default — and probes each stride for the next record boundary. Each probe opened a range covering everything from the stride offset to the end of the file, read the few hundred bytes to the next newline, and aborted.That range is the defect. It is gigabytes long, so it can never be drained, so the only way to release it is
abortStream— and an aborted response destroys its HTTP connection rather than returning it to the pool. Every probe therefore re-paid a TCP connect and a TLS handshake before it could ask its question. Cross-region that is most of the ~0.5s each probe cost. The probes also ran strictly serially: discovery fans out sixteen ways across files, and this dataset is one file, so the fan-out was structurally present and did nothing.Worth noting the abort was not an oversight. It was the fix for a real problem — closing a partially-read stream used to drag the whole body across the wire — and it is correct when a large remainder genuinely needs discarding. It was the wrong trade only here, where we never wanted the remainder at all.
What this PR does
Stops asking for the remainder.
ChunkedStorageInputStreamreads a range as bounded, fully-consumed chunks viaStorageObject.readBytes. Each chunk is a closed range read to completion, so closing it hands back a connection the provider can pool — the same reasonS3StorageObject.fetchMetadatadrains its one-byte length probe instead of aborting it. Chunk sizes grow geometrically from 32 KB to a 4 MB cap, so a normal record costs one request and a pathological one costs a logarithmic number, with total transfer within roughly twice the bytes consumed.Chunk boundaries are invisible to the format code. A splitter peeking past a lone
CRto test forLFsimply reads the first byte of the next chunk, and a splitter's byte budget keeps counting across chunks, soRecordSplitterimplementations are untouched.Probes the strides concurrently when nothing else is using the pool. A strided splitter resolves a boundary at any offset without knowing the previous one, so the probes are independent. The strided path now fixes its grid up front at stride multiples and dispatches it through
BoundedParallelGather, but only on the single-file branch of discovery — the multi-file branch already fans out across files, and the two branches are complements by construction, so a probe gather can never nest inside a file gather and block the pool waiting on itself.Fixes the read-time copy too.
ParallelParsingCoordinator.computeSegmentscarries a near-verbatim copy of the probe loop, run per macro-split on the data node to sub-segment it, ahead of any parsing dispatch. Same defect, roughly 15 probes per split, on the order of 2,000 connection setups across a full scan. Both loops now share the chunked stream.Does it work?
Boundaries are unchanged, and that is the claim the tests are built around — an off-by-one here does not fail, it silently shifts where one split ends and the next begins and the query returns a wrong row count.
ChunkedProbeEquivalenceTestsprobes every offset of each corpus twice, once through the chunked stream and once through a plain whole-remainder stream, and asserts the boundaries are equal. Chunk sizes are randomised down to 1–17 bytes so chunk edges land inside records, on terminators, and between theCRandLFof a pair. Corpora cover LF, CRLF, lone-CR, mixed terminators with ragged records, an unterminated tail, and a record over the byte budget. The quoted-CSV proven probe and exact walk get the same treatment.On top of that, the existing boundary-value and segment tests pass unmodified — those were not written for this change, which is what makes them worth more than the ones I wrote.
The four tests that pinned the old behaviour now assert its absence: they used to require that every probe abort, which was asserting the defect. Neither probe loop calls
abortStreamany more.abortStreamitself stays in the SPI and its schema-detection test is untouched and green — this removes a misuse of the mechanism, not the mechanism.:x-pack:plugin:esql:test --tests "...datasources.*": 140 classes, 2,312 tests, green.precommitandspotlessJavaCheckclean.What this is worth
Modelled, not yet measured — stated plainly because the only measurement so far is the 73s in the report.
A probe today is a TCP connect, a TLS handshake, then the request and time-to-first-byte. Pooling removes the connection setup, leaving one request round trip, which the arithmetic puts at roughly 3–4x. That alone is not enough: ~150 sequential cross-region round trips is a floor no per-probe saving gets under, so the chunked read is the enabling change rather than the fix. Probing the grid sixteen ways is what takes the remainder to about a second, against the 0.35s local floor.
Validating that against the real repro is the next step, and the number here will be replaced with a measured one before this leaves draft.
What's out of scope
The remaining
abortStreamcallers — schema detection inNdJsonFormatReaderandCsvFormatReader, and early scan termination inAsyncExternalSourceOperatorFactory— have the same shape but fire once per file rather than once per 64 MB stride. Worth converting; not worth widening a diff whose safety argument is currently tight.Split discovery still does work proportional to file size before anything has established the query needs it. For a limit-only plan the distribution strategy already routes to local execution and the pushed row limit already forces a single-stream read, so the splits this spends time computing are never used. That is a larger behavioural change and belongs on its own.
What's still to do
Why this is in draft
Adversarial review is still in flight, and the headline numbers are arithmetic rather than measurement. Neither should be taken on trust yet.