opensearch-project / opensearch-project/sql
[FEATURE] PPL Partial Results
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 176
- Forks
- 229
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 43
Description
Endpoint layout
| Purpose | Method | Path |
|---|---|---|
| Submit / long-poll for completion | POST |
/_plugins/_ppl |
| Poll status + results | GET |
/_plugins/_ppl/jobs/{id} |
| Cancel and release | DELETE |
/_plugins/_ppl/jobs/{id} |
Submit
Request
POST /_plugins/_ppl
Content-Type: application/json
{
"query": "source=account | stats count() by age",
"wait_for_completion_timeout": "1s",
"keep_alive": "5m"
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
query |
string | yes | — | PPL query. |
wait_for_completion_timeout |
duration | no | 1s on the async-capable path |
Server blocks up to this long waiting for completion. ≤ 30s. If execution finishes within it, the response carries the final result and no id. |
keep_alive |
duration | no | 5m on the async-capable path |
Job lease interval. Each accepted, authenticated poll renews expiration using this interval before waiting for a newer snapshot. > 0, ≤ 24h. |
Omitting wait_for_completion_timeout and keep_alive keeps the existing synchronous request
behavior for backward compatibility. Presence of either field enables the async-capable path. The
async defaults above are applied only after that path has been selected.
Fast-path response (completed within timeout)
The existing synchronous PPL response, with lifecycle fields added. Field origin marked:
[existing] = returned by the current synchronous PPL response today, unchanged;
[new] = added by this proposal.
{
"status": "SUCCEEDED", // [new]
"took": 214, // [new] (OpenSearch convention: millis, implicit)
"start_time_in_millis": 1789142700000, // [new]
"schema": [ // [existing]
{"name": "count()", "type": "long"},
{"name": "age", "type": "integer"}
],
"datarows": [[521, 20], [442, 30], [398, 40]], // [existing]
"total": 3, // [existing]
"size": 3 // [existing]
}
No id. The client has nothing further to poll. total == size on the fast path (single-shot,
whole result in one response).
Async-path response (not completed within timeout)
{
"id": "<opaque-id>", // [new]
"status": "RUNNING", // [new]
"sequence": 0, // [new]
"start_time_in_millis": 1789142700000, // [new]
"expiration_time_in_millis": 1789143000000, // [new]
"progress": { // [new]
"fraction_done": 0.15,
"shards_total": 5,
"shards_completed": 1
},
"schema": [], // [existing]
"datarows": [], // [existing]
"total": 0, // [existing name, extended] accumulator size
"size": 0 // [existing] rows in this response
}
Poll
Request
GET /_plugins/_ppl/jobs/{id}
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
wait_for_sequence |
integer | -1 |
Last sequence observed by the client. If the current sequence is greater, return immediately. Otherwise wait for a newer sequence, terminal status, or wait_for_completion_timeout. -1 returns the current snapshot immediately. |
wait_for_completion_timeout |
duration | 0 |
Maximum long-poll duration. ≤ 30s. Effective when wait_for_sequence ≥ 0; 0 always returns immediately. |
keep_alive |
duration | current job lease | Optionally changes the lease interval. Every accepted, authenticated poll renews expiration_time_in_millis before entering the long-poll wait. > 0, ≤ 24h. |
offset |
integer | 0 |
0-based row window start over the current accumulator. |
count |
integer | 1000 |
Max rows to return in this response. Must be between 1 and plugins.ppl.async.max_page_size (default 10000). |
For example, after receiving sequence=4, the next long-poll request is:
GET /_plugins/_ppl/jobs/{id}?wait_for_sequence=4&wait_for_completion_timeout=1s
If sequence 5 already exists, the server returns it immediately. Otherwise the request waits until
sequence 5 is published, the job becomes terminal, or one second elapses.
Response
Same envelope as the async-path submit response. Example mid-run
(field origin: [existing] = today's sync PPL response; [new] = added by this proposal;
[extended] = existing name, meaning extended in the async path):
{
"id": "<opaque-id>", // [new]
"status": "RUNNING", // [new]
"sequence": 4, // [new]
"start_time_in_millis": 1789142700000, // [new]
"expiration_time_in_millis": 1789143000000, // [new]
"progress": { // [new]
"fraction_done": 0.62,
"shards_total": 5,
"shards_completed": 3
},
"update_mode": "APPEND", // [new]
"schema": [ // [existing]
{"name": "event_id", "type": "long"},
{"name": "email", "type": "string"}
],
"window": {"offset": 0, "count": 1000}, // [new] served window
"datarows": [ /* … 1000 rows … */ ], // [existing] rows in this window
"size": 1000, // [existing] datarows.length
"total": 208114 // [extended] accumulator size (Splunk's resultCount role)
}
Terminal (successful, last page) example:
{
"id": "<opaque-id>", // [new]
"status": "SUCCEEDED", // [new]
"sequence": 8, // [new]
"took": 715, // [new]
"start_time_in_millis": 1789142700000, // [new]
"expiration_time_in_millis": 1789143000000, // [new]
"progress": { /* fraction_done=1.0, all counters final */ }, // [new]
"update_mode": "APPEND", // [new]
"schema": [ /* … */ ], // [existing]
"window": {"offset": 273088, "count": 1000},// [new]
"datarows": [ /* 1000 rows */ ], // [existing]
"size": 1000, // [existing]
"total": 274088 // [extended] offset+size == total → last page
}
Cancel and release
Request
DELETE /_plugins/_ppl/jobs/{id}
Response
HTTP 200 OK with lifecycle metadata only:
{
"id": "<opaque-id>",
"status": "CANCELLED"
}
If the job was already terminal, the response contains its existing terminal status. The operation
cancels a running job and releases all retained result data. It does not return result rows because
there may be additional pages that cannot be fetched after release. Subsequent requests for the
same id return HTTP 404.
Submit and poll response envelope
Field summary for submit and poll responses. DELETE intentionally returns only the lifecycle
metadata documented in §Cancel and release. Origin tells you whether the field is already
emitted by today's synchronous PPL response (existing) or introduced by this proposal (new).
| Field | Origin | Type | When | Description |
|---|---|---|---|---|
schema |
existing | array | always | Column definitions. Empty until known. |
datarows |
existing | array | when rows are being returned | Rows in the returned window. |
size |
existing | integer | always | Rows in this response (datarows.length), including 0 when no rows are returned. |
total |
extended | integer | always | Row count represented by the current result state. For RUNNING + APPEND, this is the number of committed stable rows currently available. For RUNNING + REPLACE, this is the current mutable preview size and may increase or decrease between sequences. For SUCCEEDED, this is the exact immutable final result size. On the fast path, total == size. |
status |
new | enum | always | RUNNING, SUCCEEDED, FAILED, CANCELLED. Canonical lifecycle state; carries is_partial/is_running information (see §Status). |
id |
new | string | async path only | Opaque handle. Absent on fast-path success. Present on any async-path response and on DELETE/GET responses. |
sequence |
new | integer | async only | Monotonic result/progress snapshot version. The same (id, sequence, offset, count) returns the same logical schema, rows, and progress. Transport bytes and lease metadata such as expiration_time_in_millis need not be identical. |
start_time_in_millis |
new | integer | submit and poll | Submission time, Unix epoch millis. |
expiration_time_in_millis |
new | integer | async only | Current job expiration, Unix epoch millis. An accepted, authenticated poll renews it before waiting, using the current keep_alive lease interval. |
took |
new | integer | terminal only | Total execution time in millis (OpenSearch convention). |
progress |
new | object | always on async path | Counters (§Progress). Progress counters do not change without a sequence bump. |
update_mode |
new | enum | async after plan classification | APPEND = rows returned are stable relative to the request's offset, and nothing already delivered will be revised; the client advances offset += size while running. REPLACE = rows are the current preview over the requested window and may still change; the client discards the prior preview. Determined by the query plan and fixed within a job. |
window |
new | object | when rows are being returned | {offset, count} echoing the served window. |
Fields dropped from earlier drafts and their equivalents:
is_running— wasstatus == RUNNING; usestatusdirectly.is_partial— wasstatus != SUCCEEDED; usestatusdirectly.has_more— for immutable final paging orRUNNING + APPEND, compute it asoffset + size < total. It is not meaningful for a mutableRUNNING + REPLACEpreview.progress.rows_in_result— redundant with the top-leveltotal.took_millis— renamed totookper OpenSearch convention (implicit millis).
Progress object
| Field | Type | Description |
|---|---|---|
fraction_done |
number | 0.0–1.0. 1.0 only on terminal snapshots. |
shards_total |
integer | Total shards involved. |
shards_completed |
integer | Shards that have finished. |
Accumulator size is the top-level total, not a progress field. Any counter may be -1 when the
engine cannot supply it for a given plan; clients treat -1 as "unknown."
Status enum
| Status | Terminal | Meaning | Rows are authoritative? |
|---|---|---|---|
RUNNING |
no | Execution active. | APPEND rows are an authoritative stable prefix; REPLACE rows are a provisional preview. |
SUCCEEDED |
yes | Final authoritative result. | yes |
FAILED |
yes | Execution failed; response contains error. |
no (partial rows if any) |
CANCELLED |
yes | Cancelled while running. | no (partial rows if any) |
status alone is sufficient for lifecycle decisions:
- "Still running?" —
status == RUNNING. - "Is the result immutable?" —
status == SUCCEEDED. - "Has this client fetched the complete result?" —
status == SUCCEEDEDand the client has
consumedtotalrows across final-result pages. - "Any terminal (stop polling)?" —
status ∈ {SUCCEEDED, FAILED, CANCELLED}.
Expiration is HTTP 404 (no pollable EXPIRED snapshot). Terminal status does not imply success;
clients must check specifically for SUCCEEDED.
Update-mode contract
Per response on the async path:
update_mode |
Client semantics | When produced |
|---|---|---|
APPEND |
datarows are stable rows at the requested offset; nothing already delivered will be revised. Client advances offset += size while the job is running. |
Plan is append-safe: no downstream blocking operator can revise emitted rows. |
REPLACE |
datarows represent the whole current accumulator (over the requested window). Client discards its prior rows and adopts these. |
Plan contains a blocking operator whose partial output can still change. |
update_mode is determined by the query plan and fixed within a job. A fully pushed-down composite
aggregation remains REPLACE for the entire job: an asynchronous-search reduce can revise the
current composite page, even though completed pages may be stable internally. Treating every
running snapshot as REPLACE is conservative and safe.
After status=SUCCEEDED, the result is immutable and can be paginated with offset and count
regardless of update_mode.
Rationale and prior-art background: see
ppl-partial-results-prior-art.md.
Failure response
An execution failure discovered after successful submission is a lifecycle response (HTTP 200):
{
"id": "<opaque-id>",
"status": "FAILED",
"sequence": 1,
"start_time_in_millis": 1789142700000,
"expiration_time_in_millis": 1789143000000,
"progress": {
"fraction_done": 0.0,
"shards_total": 5,
"shards_completed": 0
},
"schema": [],
"datarows": [],
"size": 0,
"total": 0,
"error": {
"type": "IllegalStateException",
"reason": "query execution failed"
}
}
HTTP errors
| HTTP | Condition |
|---|---|
400 Bad Request |
Invalid JSON, invalid id, invalid keep_alive/wait_for_sequence/wait_for_completion_timeout, invalid offset/count, unsupported format or execution mode. |
403 Forbidden |
Authenticated user does not own the job. |
404 Not Found |
Unknown or expired id, or owner node no longer available. |
500 Internal Server Error |
Submission, routing, or execution setup failure. |
Error bodies use the existing PPL error envelope:
{
"status": 400,
"error": {
"type": "IllegalArgumentException",
"reason": "Invalid Query",
"details": "offset must be >= 0"
}
}
Client polling algorithm
Iteration:
POST /_plugins/_ppl {query, wait_for_completion_timeout: "1s", keep_alive: "5m"}
if response has no id: render final; exit
last_sequence = response.sequence
offset = 0
while response.status == RUNNING:
request_offset = offset if response.update_mode == APPEND else 0
response = GET /_plugins/_ppl/jobs/{id}
?offset=<request_offset>
&count=1000
&wait_for_sequence=<last_sequence>
&wait_for_completion_timeout=1s
if response.sequence == last_sequence:
continue
last_sequence = response.sequence
if response.status == RUNNING && response.update_mode == APPEND:
render_append(datarows)
offset += size
else if response.status == RUNNING && response.update_mode == REPLACE:
render_replace(datarows)
if response.status == SUCCEEDED:
if response.update_mode == REPLACE:
clear_provisional_preview()
offset = 0
# The final result is immutable. Page it regardless of update_mode.
while offset < response.total:
page = GET /_plugins/_ppl/jobs/{id}?offset=<offset>&count=1000
render_append(page.datarows)
offset += page.size
if response.status == FAILED or response.status == CANCELLED:
stop polling
Termination rules:
RUNNINGmeans execution is active; continue long-polling.SUCCEEDEDmeans the result is immutable; page untiloffset == total.FAILEDorCANCELLEDmeans stop polling. Any preview already rendered remains
non-authoritative unless it came fromAPPEND.
Clients should ignore a response whose sequence is older than the last one rendered. Two polls
with the same (sequence, offset, count) return the same logical result window, so the client may
short-circuit result processing.
Restrictions
- Calcite PPL execution path only.
- JDBC JSON response format only. CSV, raw, and visualization formats are unsupported for
async-capable requests. _explain,analyze, andprofiledo not create jobs; they retain synchronous behavior.- A running job is not durable across owner-node failure; requests return
HTTP 404if the owner
leaves the cluster. - Job responses use
Cache-Control: no-store.
Multi-node behavior
The id identifies the owner node. GET and DELETE can be sent to any node; the receiving node
routes internally to the owner. The job is bound to the submitting user; the owner validates the
same user on every request. Owner-node state is not replicated.
Resource limits
- Default
keep_alive:5m. Maximum:24h. Each accepted, authenticated poll renews the job
lease before entering any long-poll wait. - Default
wait_for_completion_timeout:1s. Maximum:30s. - Maximum retained jobs per owner node:
10,000. Terminal jobs count toward the limit until they
expire. countmust be between1andplugins.ppl.async.max_page_size(default10,000).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reviewing the existing synchronous PPL endpoint at POST /_plugins/_ppl and its current response envelope. Trace how submit, poll, and cancellation would map to the documented job lifecycle, pagination, sequencing, leases, and update modes; done means the documented async endpoints and status semantics are implemented consistently.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- api, backend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100