Summary
Large row-returning extractions page Elasticsearch strictly sequentially, and the request shape
makes the cost grow with the shard count. On a multi-shard index the reader never uses the
parallelism the cluster is offering: neither the sidecar nor Elasticsearch is anywhere near
saturated, yet the wall clock does not improve when shards and nodes are added.
Where it is
pitSearchAfter (es8/java/.../JavaClientApi.scala:1517, and the es7/es9 equivalents) builds a
single Source.unfoldAsync: fetch a page, await it, use its sort values as search_after, fetch the
next. There is no slice and no shard preference anywhere in the client. A PIT covers the whole
index, so every page fans out to all shards and the coordinating node performs a sorted merge
before returning it.
For a 10M-row extraction at the default page size of 1,000, that is:
- 10,000 sequential round-trips, each one a fan-out + merge across every shard
- on a 6-shard index, roughly 60,000 shard-level operations for a single statement
- per-page cost that rises with shard count, because both the fan-out and the cross-node gather
grow with it
Measurements
10M rows / 8 columns, no ORDER BY, no LIMIT; 6-shard index on a 3-node Elasticsearch cluster
(3 x 2 CPU); sidecar 4 CPU / 4 GB. Medians of 3 runs after 2 warm-ups.
| metric |
value |
| wall |
34.6 s (≈ 3.46 ms per page) |
| sidecar CPU |
19.3 s = 0.56 of 4 cores |
| Elasticsearch CPU |
53.6 s = 1.55 of 6 cores |
| inter-node transport |
2,293 MB |
Neither side is CPU-bound: the pipeline is latency-bound, idle while waiting on round-trips.
The same statement against a 1-shard index on the same host takes essentially the same wall
(34.6 s), i.e. adding shards and nodes bought nothing — while the fan-out per page, the cluster
CPU and the cross-node transport all grew.
Proposal — one slice per shard, merged into one stream
Elasticsearch supports slicing on PIT + search_after. Open one PIT, start N readers sharing it,
each pinned to one slice, and merge them into the single Arrow/row stream the caller already
receives. No API or protocol change; callers still see one result.
The hard constraint
Slice count must equal shard count. With more slices than shards, Elasticsearch applies a
document-level hash filter and each slice re-scans its shard. Measured on this cluster: 5 slices over
a 1-shard index cost 84.9 s of cluster CPU, against 28.2 s for 5 slices over 5 shards
where each slice maps to exactly one shard. Choosing the slice count wrong turns this improvement
into a 3x regression in cluster CPU.
Expected effect (projection from the numbers above)
Pages per slice fall to ~1,667, so the Elasticsearch-side contribution drops to roughly 6 s and the
single-threaded row-to-Arrow conversion (19.3 s CPU) becomes the bottleneck — about 20 s wall.
Parallelising conversion per slice as well puts the floor near 10-12 s. Cluster CPU should also fall,
because the per-page fan-out and merge disappear.
Scope
In scope (first cut): the no-ORDER BY, no-LIMIT row-returning path — the one SearchApi
already routes through scroll (#209).
Out of scope initially, and why:
Risks
Cheaper mitigation to measure first
Raising the page size (1,000 -> 5,000) cuts round-trips 5x with no architectural change and no
correctness risk beyond memory. Worth quantifying before committing to slicing, and the two compose.
Acceptance criteria
- A no-
ORDER BY extraction from an N-shard index opens N slices under one PIT and merges them.
- Slice count is derived from the index's shard count, never configured above it.
- Multi-shard completeness test: exact count and distinct ids, on real Elasticsearch, for
1 / 3 / 6 shards.
- Peak sidecar memory does not regress against the sequential path.
- The PIT is closed exactly once on completion, failure and cancellation.
ORDER BY and explicit LIMIT statements continue to use the sequential path unchanged.
Summary
Large row-returning extractions page Elasticsearch strictly sequentially, and the request shape
makes the cost grow with the shard count. On a multi-shard index the reader never uses the
parallelism the cluster is offering: neither the sidecar nor Elasticsearch is anywhere near
saturated, yet the wall clock does not improve when shards and nodes are added.
Where it is
pitSearchAfter(es8/java/.../JavaClientApi.scala:1517, and the es7/es9 equivalents) builds asingle
Source.unfoldAsync: fetch a page, await it, use its sort values assearch_after, fetch thenext. There is no
sliceand no shardpreferenceanywhere in the client. A PIT covers the wholeindex, so every page fans out to all shards and the coordinating node performs a sorted merge
before returning it.
For a 10M-row extraction at the default page size of 1,000, that is:
grow with it
Measurements
10M rows / 8 columns, no
ORDER BY, noLIMIT; 6-shard index on a 3-node Elasticsearch cluster(3 x 2 CPU); sidecar 4 CPU / 4 GB. Medians of 3 runs after 2 warm-ups.
Neither side is CPU-bound: the pipeline is latency-bound, idle while waiting on round-trips.
The same statement against a 1-shard index on the same host takes essentially the same wall
(34.6 s), i.e. adding shards and nodes bought nothing — while the fan-out per page, the cluster
CPU and the cross-node transport all grew.
Proposal — one slice per shard, merged into one stream
Elasticsearch supports slicing on PIT +
search_after. Open one PIT, start N readers sharing it,each pinned to one slice, and merge them into the single Arrow/row stream the caller already
receives. No API or protocol change; callers still see one result.
The hard constraint
Slice count must equal shard count. With more slices than shards, Elasticsearch applies a
document-level hash filter and each slice re-scans its shard. Measured on this cluster: 5 slices over
a 1-shard index cost 84.9 s of cluster CPU, against 28.2 s for 5 slices over 5 shards
where each slice maps to exactly one shard. Choosing the slice count wrong turns this improvement
into a 3x regression in cluster CPU.
Expected effect (projection from the numbers above)
Pages per slice fall to ~1,667, so the Elasticsearch-side contribution drops to roughly 6 s and the
single-threaded row-to-Arrow conversion (19.3 s CPU) becomes the bottleneck — about 20 s wall.
Parallelising conversion per slice as well puts the floor near 10-12 s. Cluster CPU should also fall,
because the per-page fan-out and merge disappear.
Scope
In scope (first cut): the no-
ORDER BY, no-LIMITrow-returning path — the oneSearchApialready routes through scroll (#209).
Out of scope initially, and why:
ORDER BY— slices return independently, so global order needs a k-way merge across slices.LIMIT/OFFSET— needs post-merge application and per-slice over-fetch; SELECT with an explicit LIMIT above index.max_result_window fails, while the same query with NO LIMIT succeeds #224's boundedpath stays sequential.
Risks
returned wrong answers without raising anything). Any slicing change needs a multi-shard test
asserting both the exact row count and the distinct id count, on real Elasticsearch.
bounded and backpressured, not an unbounded buffer.
watchTerminationsole-owner rule), and a failing slice must fail the stream rather than truncateit.
_shard_doctiebreaker gate is 7.12.maxQueryResults) must be enforced on the merged total, not per slice.Cheaper mitigation to measure first
Raising the page size (1,000 -> 5,000) cuts round-trips 5x with no architectural change and no
correctness risk beyond memory. Worth quantifying before committing to slicing, and the two compose.
Acceptance criteria
ORDER BYextraction from an N-shard index opens N slices under one PIT and merges them.1 / 3 / 6 shards.
ORDER BYand explicitLIMITstatements continue to use the sequential path unchanged.