diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/datasources/ChunkedStorageInputStream.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/datasources/ChunkedStorageInputStream.java new file mode 100644 index 0000000000000..f8e2626a33973 --- /dev/null +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/datasources/ChunkedStorageInputStream.java @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.esql.datasources; + +import org.elasticsearch.xpack.esql.datasources.spi.StorageObject; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Objects; + +/** + * Reads a byte range of a {@link StorageObject} as a stream of bounded, fully-consumed chunks. + *
+ * Record-boundary probing needs a few hundred bytes from an arbitrary offset, but the length it must declare + * up front is unknown — a record can legitimately run to {@code maxRecordBytes}. Declaring the whole remainder + * of the object (the previous approach) made the request undrainable, so the only way to release it was + * {@link StorageObject#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, which is most of the cost of a + * cross-region probe and all of the reason ~150 serial probes took ~73s (esql-planning#1528). + *
+ * Fetching in bounded chunks removes the dilemma: each chunk is a closed range that is read to completion, so + * it closes cleanly and its connection is reusable — the same reason {@code S3StorageObject#fetchMetadata} + * drains its one-byte length probe rather than aborting it. Chunk sizes grow geometrically from + * {@link #FIRST_CHUNK_SIZE}, so a normal record costs a single request and a pathological one costs a + * logarithmic number, with total transfer within roughly twice the bytes actually consumed. + *
+ * Consumers see a plain {@link InputStream}: chunk boundaries are invisible, so a record splitter's lookahead
+ * (for instance peeking past a lone {@code CR} to test for {@code LF}) simply reads the first byte of the next
+ * chunk, and a splitter's own byte budget keeps counting across chunks unchanged.
+ */
+final class ChunkedStorageInputStream extends InputStream {
+
+ /**
+ * Size of the first fetch. Large enough that a single request answers essentially any real record, small
+ * enough that the bytes wasted when only a line's prefix is needed stay far below the stride being probed.
+ */
+ static final int FIRST_CHUNK_SIZE = 32 * 1024;
+
+ /** Ceiling on chunk growth, so one probe of a pathological record cannot turn into a single huge request. */
+ static final int MAX_CHUNK_SIZE = 4 * 1024 * 1024;
+
+ private final StorageObject object;
+ private final long endExclusive;
+ private final int maxChunkSize;
+
+ /** Absolute position of the next byte to fetch. */
+ private long fetchPosition;
+ private int nextChunkSize;
+
+ private byte[] chunk = new byte[0];
+ private int chunkPosition;
+ private int chunkLength;
+ private boolean exhausted;
+
+ ChunkedStorageInputStream(StorageObject object, long position, long endExclusive) {
+ this(object, position, endExclusive, FIRST_CHUNK_SIZE, MAX_CHUNK_SIZE);
+ }
+
+ ChunkedStorageInputStream(StorageObject object, long position, long endExclusive, int firstChunkSize, int maxChunkSize) {
+ if (position < 0) {
+ throw new IllegalArgumentException("position must be non-negative, got: " + position);
+ }
+ if (firstChunkSize <= 0 || maxChunkSize < firstChunkSize) {
+ throw new IllegalArgumentException("require 0 < firstChunkSize <= maxChunkSize, got: " + firstChunkSize + ", " + maxChunkSize);
+ }
+ this.object = Objects.requireNonNull(object);
+ this.endExclusive = endExclusive;
+ this.fetchPosition = position;
+ this.nextChunkSize = firstChunkSize;
+ this.maxChunkSize = maxChunkSize;
+ }
+
+ @Override
+ public int read() throws IOException {
+ if (ensureAvailable() == false) {
+ return -1;
+ }
+ return chunk[chunkPosition++] & 0xFF;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ Objects.checkFromIndexSize(off, len, b.length);
+ if (len == 0) {
+ return 0;
+ }
+ if (ensureAvailable() == false) {
+ return -1;
+ }
+ int n = Math.min(len, chunkLength - chunkPosition);
+ System.arraycopy(chunk, chunkPosition, b, off, n);
+ chunkPosition += n;
+ return n;
+ }
+
+ /**
+ * Ensures at least one unread byte is buffered, fetching the next chunk only when the current one is spent.
+ * A read that is satisfied from the current chunk never triggers a fetch, so the read that finds a record
+ * boundary cannot pull a speculative chunk across the wire.
+ *
+ * @return false once the range is exhausted
+ */
+ private boolean ensureAvailable() throws IOException {
+ if (chunkPosition < chunkLength) {
+ return true;
+ }
+ if (exhausted || fetchPosition >= endExclusive) {
+ return false;
+ }
+ int size = (int) Math.min(nextChunkSize, endExclusive - fetchPosition);
+ if (chunk.length < size) {
+ chunk = new byte[size];
+ }
+ ByteBuffer target = ByteBuffer.wrap(chunk, 0, size);
+ // Positional reads may return short (a FileChannel-backed provider is entitled to), so refill until the
+ // chunk is full or the object ends.
+ while (target.hasRemaining()) {
+ int read = object.readBytes(fetchPosition + target.position(), target);
+ if (read <= 0) {
+ break;
+ }
+ }
+ int filled = target.position();
+ if (filled == 0) {
+ exhausted = true;
+ return false;
+ }
+ chunkPosition = 0;
+ chunkLength = filled;
+ fetchPosition += filled;
+ nextChunkSize = (int) Math.min((long) nextChunkSize * 2, maxChunkSize);
+ return true;
+ }
+
+ /**
+ * No-op: every chunk is fully consumed by the time it is replaced, so this stream holds no provider
+ * resource between fetches and there is nothing to release — which is the point of reading in chunks.
+ */
+ @Override
+ public void close() {}
+}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/datasources/FileSplitProvider.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/datasources/FileSplitProvider.java
index 214c225652f3d..c2fe8a80c35de 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/datasources/FileSplitProvider.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/datasources/FileSplitProvider.java
@@ -55,7 +55,6 @@
import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.LessThanOrEqual;
import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.NotEquals;
-import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
@@ -382,14 +381,19 @@ public SplitDiscoveryResult discoverSplits(SplitDiscoveryContext context) {
if (executor != null && tasks.size() > 1) {
perFileSplits = BoundedParallelGather.gather(
tasks,
- task -> processFileForSplits(task, hoistedProvider, isCancelled),
+ task -> processFileForSplits(task, hoistedProvider, isCancelled, null),
MAX_PARALLEL_SPLIT_DISCOVERY,
executor
);
} else {
+ // Files are the usual unit of parallelism, but a dataset of one file gets none from that — and a
+ // single large file is exactly where boundary probing dominates planning. Lend the pool to the
+ // probes instead. The two branches are complements by construction, so probes never nest a
+ // second blocking gather inside a task already waiting on the first.
+ Executor probeExecutor = tasks.size() == 1 ? executor : null;
perFileSplits = new ArrayList<>(tasks.size());
for (FileTask task : tasks) {
- perFileSplits.add(processFileForSplits(task, hoistedProvider, isCancelled));
+ perFileSplits.add(processFileForSplits(task, hoistedProvider, isCancelled, probeExecutor));
}
}
} catch (IOException e) {
@@ -457,18 +461,29 @@ private static Map
+ * A strided splitter can probe any offset directly, so its stride grid is fixed up front at multiples of
+ * {@code targetStrideBytes} and every probe is independent — which is what lets {@code probeExecutor}, when
+ * supplied, run them concurrently. Each probe still costs a network round trip, so on a single large file
+ * over a distant object store the serial version is the dominant cost of planning. Boundaries do not depend
+ * on whether an executor was supplied.
+ *
+ * The grid replaces an earlier chain that advanced by {@code lastBoundary + stride}. Both emit true record
+ * starts and both tile the file; the grid simply does not accumulate drift, so a split can come out shorter
+ * than a stride by up to the length of the record straddling its grid point, where the chain was always at
+ * least a stride. A record long enough to span several grid points yields the same boundary from each, so
+ * repeats are dropped rather than emitted as empty splits.
+ *
+ * A non-strided (but proven-probing) splitter is genuinely sequential — each probe's fallback walks from the
+ * previous proven record start — so it keeps the chained loop and ignores {@code probeExecutor}.
+ */
+ static List
+ * Reading in chunks must be invisible to record splitting: for every offset, probing through the chunked stream
+ * has to return exactly the boundary that probing through a plain whole-remainder stream returns. This is not a
+ * nice-to-have equivalence — a boundary that lands one byte off does not fail, it silently shifts where one
+ * split ends and the next begins, and the query returns a wrong row count. So the tests here run adversarially
+ * small chunks, which puts chunk edges on every interesting byte: inside a record, on a terminator, between the
+ * {@code CR} and {@code LF} of a {@code CRLF} pair, and on the last byte before the range ends.
+ */
+public class ChunkedProbeEquivalenceTests extends ESTestCase {
+
+ private static final int MAX_RECORD_BYTES = SegmentableFormatReader.DEFAULT_MAX_RECORD_BYTES;
+
+ private BlockFactory blockFactory() {
+ return BlockFactory.builder(BigArrays.NON_RECYCLING_INSTANCE).breaker(new NoopCircuitBreaker("test")).build();
+ }
+
+ private SegmentableFormatReader plainCsvReader() {
+ return (SegmentableFormatReader) new CsvFormatReader(blockFactory()).withConfig(Map.of("mode", "plain"));
+ }
+
+ private SegmentableFormatReader quotedCsvReader() {
+ return (SegmentableFormatReader) new CsvFormatReader(blockFactory()).withConfig(Map.of("mode", "quoted"));
+ }
+
+ private SegmentableFormatReader ndjsonReader() {
+ return new NdJsonFormatReader(Settings.EMPTY, blockFactory(), List.of());
+ }
+
+ /**
+ * Probes every offset of a payload both ways and asserts the answers agree. Chunk sizes are deliberately
+ * tiny so the chunk grid sweeps across every byte of every record.
+ */
+ private void assertBoundaryEquivalence(SegmentableFormatReader reader, byte[] payload, int maxRecordBytes) throws IOException {
+ RecordSplitter splitter = reader.recordSplitter(maxRecordBytes);
+ assertTrue("this corpus is only meaningful for a strided splitter", splitter.supportsStridedProbing());
+ RecordingStorageObject object = new RecordingStorageObject(payload);
+
+ for (int pos = 0; pos < payload.length; pos++) {
+ long viaPlainStream;
+ try (InputStream plain = object.newStream(pos, payload.length - pos)) {
+ viaPlainStream = splitter.findNextRecordBoundary(plain);
+ }
+
+ int firstChunk = randomIntBetween(1, 17);
+ long viaChunkedStream;
+ try (InputStream chunked = new ChunkedStorageInputStream(object, pos, payload.length, firstChunk, firstChunk * 2)) {
+ viaChunkedStream = splitter.findNextRecordBoundary(chunked);
+ }
+
+ assertEquals("boundary disagreed at offset " + pos + " with first chunk " + firstChunk, viaPlainStream, viaChunkedStream);
+ }
+ assertEquals("the chunked probe path must never abort", 0, object.abortCalls.get());
+ }
+
+ public void testNdjsonBoundaryEquivalenceAcrossEveryOffset() throws IOException {
+ StringBuilder ndjson = new StringBuilder();
+ for (int i = 0; i < 60; i++) {
+ ndjson.append("{\"id\":").append(i).append(",\"name\":\"value_").append(i).append("\"}\n");
+ }
+ assertBoundaryEquivalence(ndjsonReader(), ndjson.toString().getBytes(StandardCharsets.UTF_8), MAX_RECORD_BYTES);
+ }
+
+ public void testNdjsonWithCrlfBoundaryEquivalence() throws IOException {
+ StringBuilder ndjson = new StringBuilder();
+ for (int i = 0; i < 60; i++) {
+ ndjson.append("{\"id\":").append(i).append("}\r\n");
+ }
+ assertBoundaryEquivalence(ndjsonReader(), ndjson.toString().getBytes(StandardCharsets.UTF_8), MAX_RECORD_BYTES);
+ }
+
+ public void testNdjsonWithLoneCarriageReturnsBoundaryEquivalence() throws IOException {
+ StringBuilder ndjson = new StringBuilder();
+ for (int i = 0; i < 60; i++) {
+ ndjson.append("{\"id\":").append(i).append("}\r");
+ }
+ assertBoundaryEquivalence(ndjsonReader(), ndjson.toString().getBytes(StandardCharsets.UTF_8), MAX_RECORD_BYTES);
+ }
+
+ public void testNdjsonWithMixedTerminatorsAndRaggedRecordsBoundaryEquivalence() throws IOException {
+ StringBuilder ndjson = new StringBuilder();
+ for (int i = 0; i < 80; i++) {
+ ndjson.append("{\"id\":").append(i).append(",\"pad\":\"").append("x".repeat(randomIntBetween(0, 40))).append("\"}");
+ switch (randomIntBetween(0, 2)) {
+ case 0 -> ndjson.append('\n');
+ case 1 -> ndjson.append("\r\n");
+ default -> ndjson.append('\r');
+ }
+ }
+ assertBoundaryEquivalence(ndjsonReader(), ndjson.toString().getBytes(StandardCharsets.UTF_8), MAX_RECORD_BYTES);
+ }
+
+ public void testPayloadWithNoTrailingTerminatorBoundaryEquivalence() throws IOException {
+ byte[] payload = "{\"id\":1}\n{\"id\":2}\n{\"id\":3-unterminated".getBytes(StandardCharsets.UTF_8);
+ assertBoundaryEquivalence(ndjsonReader(), payload, MAX_RECORD_BYTES);
+ }
+
+ public void testPlainCsvBoundaryEquivalenceAcrossEveryOffset() throws IOException {
+ StringBuilder csv = new StringBuilder("id,name\n");
+ for (int i = 0; i < 60; i++) {
+ csv.append(i).append(",value_").append(i).append('\n');
+ }
+ assertBoundaryEquivalence(plainCsvReader(), csv.toString().getBytes(StandardCharsets.UTF_8), MAX_RECORD_BYTES);
+ }
+
+ /**
+ * A record longer than the byte budget must reach the same verdict either way. The budget is counted by the
+ * splitter as it consumes bytes, so it has to survive those bytes arriving in many chunks rather than one.
+ */
+ public void testRecordExceedingMaxRecordBytesReachesSameVerdict() throws IOException {
+ byte[] payload = ("{\"id\":1}\n" + "y".repeat(4096) + "\n{\"id\":2}\n").getBytes(StandardCharsets.UTF_8);
+ int budget = 512;
+ RecordSplitter splitter = ndjsonReader().recordSplitter(budget);
+ RecordingStorageObject object = new RecordingStorageObject(payload);
+
+ int pos = 9; // first byte of the oversized record
+ long viaPlainStream;
+ try (InputStream plain = object.newStream(pos, payload.length - pos)) {
+ viaPlainStream = splitter.findNextRecordBoundary(plain);
+ }
+ long viaChunkedStream;
+ try (InputStream chunked = new ChunkedStorageInputStream(object, pos, payload.length, 7, 32)) {
+ viaChunkedStream = splitter.findNextRecordBoundary(chunked);
+ }
+
+ assertEquals(RecordSplitter.RECORD_TOO_LARGE, viaPlainStream);
+ assertEquals("the budget must be counted identically across chunk boundaries", viaPlainStream, viaChunkedStream);
+ }
+
+ /** The proven-probe and exact-walk shapes read the same stream, so they need the same guarantee. */
+ public void testQuotedCsvProvenProbeAndExactWalkEquivalence() throws IOException {
+ StringBuilder csv = new StringBuilder("id,name\n");
+ for (int i = 0; i < 60; i++) {
+ csv.append(i).append(",\"value ").append(i).append("\"\n");
+ }
+ byte[] payload = csv.toString().getBytes(StandardCharsets.UTF_8);
+
+ RecordSplitter splitter = quotedCsvReader().recordSplitter(MAX_RECORD_BYTES);
+ assumeTrue("quoted CSV must support proven probing for this test", splitter.supportsProvenProbing());
+ RecordingStorageObject object = new RecordingStorageObject(payload);
+
+ for (int pos = 1; pos < payload.length; pos++) {
+ long provenPlain;
+ try (InputStream plain = object.newStream(pos, payload.length - pos)) {
+ provenPlain = splitter.findProvenRecordBoundary(plain);
+ }
+ long provenChunked;
+ try (InputStream chunked = new ChunkedStorageInputStream(object, pos, payload.length, randomIntBetween(1, 13), 32)) {
+ provenChunked = splitter.findProvenRecordBoundary(chunked);
+ }
+ assertEquals("proven probe disagreed at offset " + pos, provenPlain, provenChunked);
+
+ long walkPlain;
+ try (InputStream plain = object.newStream(0, payload.length)) {
+ walkPlain = splitter.findRecordStartAtOrAfter(plain, pos, () -> false);
+ }
+ long walkChunked;
+ try (InputStream chunked = new ChunkedStorageInputStream(object, 0, payload.length, randomIntBetween(1, 13), 32)) {
+ walkChunked = splitter.findRecordStartAtOrAfter(chunked, pos, () -> false);
+ }
+ assertEquals("exact walk disagreed at min skip " + pos, walkPlain, walkChunked);
+ }
+ assertEquals("the chunked probe path must never abort", 0, object.abortCalls.get());
+ }
+
+ /** The chunked probe must not read materially more than the plain probe consumed to find the same answer. */
+ public void testProbeTransfersStayBoundedRelativeToTheRecordLength() throws IOException {
+ StringBuilder ndjson = new StringBuilder();
+ for (int i = 0; i < 5_000; i++) {
+ ndjson.append("{\"id\":").append(i).append(",\"name\":\"value_").append(i).append("\"}\n");
+ }
+ byte[] payload = ndjson.toString().getBytes(StandardCharsets.UTF_8);
+ RecordingStorageObject object = new RecordingStorageObject(payload);
+ RecordSplitter splitter = ndjsonReader().recordSplitter(MAX_RECORD_BYTES);
+
+ try (InputStream chunked = new ChunkedStorageInputStream(object, payload.length / 2, payload.length)) {
+ assertThat(splitter.findNextRecordBoundary(chunked), Matchers.greaterThanOrEqualTo(0L));
+ }
+
+ assertEquals("a short record must be answered by one request", 1, object.readBytesCalls.size());
+ assertThat(
+ "one request must not exceed the first chunk size",
+ object.readBytesCalls.get(0)[1],
+ Matchers.lessThanOrEqualTo((long) ChunkedStorageInputStream.FIRST_CHUNK_SIZE)
+ );
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/datasources/ChunkedStorageInputStreamTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/datasources/ChunkedStorageInputStreamTests.java
new file mode 100644
index 0000000000000..9ae704705d47e
--- /dev/null
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/datasources/ChunkedStorageInputStreamTests.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.datasources;
+
+import org.elasticsearch.test.ESTestCase;
+import org.hamcrest.Matchers;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Request-shape tests for {@link ChunkedStorageInputStream}. What matters here is not only that the bytes come
+ * out right, but that they arrive as bounded closed ranges that are read to completion — that property is what
+ * lets the provider pool the connection, and it is the whole reason this stream exists.
+ */
+public class ChunkedStorageInputStreamTests extends ESTestCase {
+
+ public void testBoundaryInsideFirstChunkCostsOneRequest() throws IOException {
+ byte[] payload = "line one\nline two\n".getBytes(StandardCharsets.UTF_8);
+ RecordingStorageObject object = new RecordingStorageObject(payload);
+
+ try (InputStream stream = new ChunkedStorageInputStream(object, 0, payload.length)) {
+ assertEquals('l', stream.read());
+ }
+
+ assertEquals("a read inside the first chunk must not fetch a second", 1, object.readBytesCalls.size());
+ assertEquals("the probe path must never open a raw stream", 0, object.newStreamCalls.size());
+ assertEquals("the probe path must never abort", 0, object.abortCalls.get());
+ }
+
+ public void testChunkSizesGrowGeometricallyAndAreCapped() throws IOException {
+ byte[] payload = new byte[600];
+ RecordingStorageObject object = new RecordingStorageObject(payload);
+
+ // firstChunk 32, cap 128: expect 32, 64, 128, 128, ...
+ try (InputStream stream = new ChunkedStorageInputStream(object, 0, payload.length, 32, 128)) {
+ assertEquals(payload.length, stream.readAllBytes().length);
+ }
+
+ assertThat(object.readBytesCalls.get(0)[1], Matchers.equalTo(32L));
+ assertThat(object.readBytesCalls.get(1)[1], Matchers.equalTo(64L));
+ assertThat(object.readBytesCalls.get(2)[1], Matchers.equalTo(128L));
+ assertThat(object.readBytesCalls.get(3)[1], Matchers.equalTo(128L));
+ }
+
+ public void testNoRequestReachesPastTheDeclaredEnd() throws IOException {
+ byte[] payload = new byte[1000];
+ RecordingStorageObject object = new RecordingStorageObject(payload);
+ long end = 300;
+
+ try (InputStream stream = new ChunkedStorageInputStream(object, 100, end, 64, 1024)) {
+ assertEquals(200, stream.readAllBytes().length);
+ }
+
+ for (long[] call : object.readBytesCalls) {
+ assertThat(
+ "request [" + call[0] + "," + call[1] + ") must stay within the declared end",
+ call[0] + call[1],
+ Matchers.lessThanOrEqualTo(end)
+ );
+ }
+ }
+
+ public void testContentMatchesTheUnderlyingRangeAcrossChunkSizes() throws IOException {
+ byte[] payload = randomByteArrayOfLength(randomIntBetween(1, 4096));
+ int from = randomIntBetween(0, payload.length - 1);
+ RecordingStorageObject object = new RecordingStorageObject(payload, randomIntBetween(1, 64));
+
+ byte[] read;
+ try (
+ InputStream stream = new ChunkedStorageInputStream(
+ object,
+ from,
+ payload.length,
+ randomIntBetween(1, 128),
+ randomIntBetween(128, 512)
+ )
+ ) {
+ read = stream.readAllBytes();
+ }
+
+ byte[] expected = new byte[payload.length - from];
+ System.arraycopy(payload, from, expected, 0, expected.length);
+ assertArrayEquals("short positional reads must be refilled, not truncated", expected, read);
+ }
+
+ public void testSingleByteAndBulkReadsAgree() throws IOException {
+ byte[] payload = randomByteArrayOfLength(randomIntBetween(1, 2048));
+ RecordingStorageObject object = new RecordingStorageObject(payload);
+
+ byte[] oneAtATime = new byte[payload.length];
+ try (InputStream stream = new ChunkedStorageInputStream(object, 0, payload.length, 16, 64)) {
+ for (int i = 0; i < payload.length; i++) {
+ int b = stream.read();
+ assertThat("stream ended early at " + i, b, Matchers.greaterThanOrEqualTo(0));
+ oneAtATime[i] = (byte) b;
+ }
+ assertEquals("stream must be exhausted", -1, stream.read());
+ }
+ assertArrayEquals(payload, oneAtATime);
+ }
+
+ public void testEmptyRangeReadsAsEof() throws IOException {
+ RecordingStorageObject object = new RecordingStorageObject(new byte[100]);
+
+ try (InputStream stream = new ChunkedStorageInputStream(object, 50, 50)) {
+ assertEquals(-1, stream.read());
+ assertEquals(-1, stream.read(new byte[8], 0, 8));
+ }
+
+ assertEquals("an empty range must not issue a request", 0, object.readBytesCalls.size());
+ }
+
+ public void testEndBeyondObjectStopsAtObjectEnd() throws IOException {
+ byte[] payload = new byte[10];
+ RecordingStorageObject object = new RecordingStorageObject(payload);
+
+ try (InputStream stream = new ChunkedStorageInputStream(object, 0, 1_000_000)) {
+ assertEquals(payload.length, stream.readAllBytes().length);
+ assertEquals(-1, stream.read());
+ }
+ }
+
+ public void testZeroLengthReadReturnsZeroWithoutFetching() throws IOException {
+ RecordingStorageObject object = new RecordingStorageObject(new byte[100]);
+
+ try (InputStream stream = new ChunkedStorageInputStream(object, 0, 100)) {
+ assertEquals(0, stream.read(new byte[8], 0, 0));
+ }
+
+ assertEquals(0, object.readBytesCalls.size());
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/datasources/FileSplitProviderTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/datasources/FileSplitProviderTests.java
index 1ecbd10eaa9e0..2593f08ffd410 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/datasources/FileSplitProviderTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/datasources/FileSplitProviderTests.java
@@ -73,12 +73,15 @@
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.instanceOf;
-import static org.hamcrest.Matchers.lessThan;
+import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
@@ -1019,11 +1022,13 @@ private static Set
+ * This is the instrument for the connection-reuse claim behind {@link ChunkedStorageInputStream}: a request is
+ * poolable exactly when it is a closed range that is read to completion, so the counters a test cares about are
+ * how many requests were issued, how long each one declared itself to be, and whether any of them had to be
+ * aborted. Aborts are the failure signal — the probe path is expected to produce none.
+ *
+ * {@code maxBytesPerReadCall} makes positional reads return short, which providers backed by a
+ * {@code FileChannel} are entitled to do, so callers that assume a single call fills the buffer are caught here
+ * rather than in production.
+ */
+final class RecordingStorageObject implements StorageObject {
+
+ private final byte[] data;
+ private final int maxBytesPerReadCall;
+ private final StoragePath path;
+
+ /** One {@code [position, requestedLength]} entry per {@link #readBytes} call, in call order. */
+ final List