#160286·elasticsearch

ESQL: avoid monopolizing the coordinator thread during large LOAD_ALL expansion

Author: edsavageCreated Sep 25, 2026Updated Sep 28, 2026
Labels>enhancementTeam:Analytics:Analytics/ES|QL

Description

Follow-up from #159914 / #160053. The cancellation half of #159914 is done: the ExpandUnmappedFieldsPostProcessor expansion loops now poll the task's cancellation supplier every 1024 rows and throw TaskCancelledException, so a long LOAD_ALL expansion aborts promptly and releases its pages. The issue also floated "maybe we should even yield the thread?" — this issue tracks that second, orthogonal concern.

The concern

The expansion is a coordinator-side, single-threaded scan over every row of every result page (parsing each row's _unmapped_fields JSON). It runs synchronously inside the post-compute SubscribableListener continuation in EsqlSession, i.e. on whichever thread completes the compute result (ComputeListener) — the coordinator compute pool, or potentially a transport worker, depending on completion order. It is not explicitly re-dispatched onto a bounded query pool before expansion begins.

For a wide LOAD_ALL result this loop can run for seconds. Under concurrency that means one query can hold a worker thread for the whole expansion, starving other queued work — and if the completing thread is a transport_worker, it would block I/O, which is worse. The periodic cancellation checks let a cancelled query release the thread, but a non-cancelled large expansion still monopolizes it.

Thread.yield() is not the fix

The issue's phrasing ("yield the thread") should not be taken literally. Thread.yield() is an unreliable JVM scheduling hint: it does not hand a pooled thread back to other queued tasks and does nothing for threadpool starvation. It should be avoided.

Proposed approach

Chunked re-dispatch: process a bounded batch of rows, then re-fork the remainder onto the appropriate executor (e.g. an AbstractRunnable that re-queues itself), so other queued tasks interleave. This turns expand into an iterative/async loop and keeps any single query from holding a worker thread for its whole expansion. Concretely:

  1. Confirm the executor the expansion continuation actually runs on today (coordinator compute pool vs. transport worker) to size the risk and pick the right target executor.
  2. Restructure ExpandUnmappedFieldsPostProcessor.expand (the collectFieldNames and rewritePages scans) to yield back to the executor every N rows, preserving the existing per-row cancellation checks and page-release semantics.

Why the priority is not urgent

  • #160053 already makes long expansions promptly cancellable, so a hogged thread can be reclaimed.
  • LOAD_ALL truncates its output to ~10k rows regardless of LIMIT (observed: a plain query honored LIMIT 50000, LOAD_ALL capped at 10k), so per-query expansion is bounded — in manual testing it stayed sub-second.
  • The circuit breaker bounds memory; the pathological wide/sparse case is more a memory / async-storage concern, tracked in #160278.

Notes

  • Original recommendation was periodic cancellation checks (done, #160053) plus a "maybe yield" aside (this issue).
  • Related: #159914 (parent), #160053 (cancellation fix), #160278 (async storage / circuit-breaking for wide LOAD_ALL).