diff --git a/common/src/main/java/org/opensearch/sql/common/setting/Settings.java b/common/src/main/java/org/opensearch/sql/common/setting/Settings.java index 67643a80add..fcdf84d9e8f 100644 --- a/common/src/main/java/org/opensearch/sql/common/setting/Settings.java +++ b/common/src/main/java/org/opensearch/sql/common/setting/Settings.java @@ -38,6 +38,18 @@ public enum Key { PPL_JOIN_SUBSEARCH_MAXOUT("plugins.ppl.join.subsearch_maxout"), PPL_REST_ALLOWED_ENDPOINTS("plugins.ppl.rest.allowed_endpoints"), + /** Maximum number of asynchronous PPL queries running concurrently on one node. */ + PPL_ASYNC_NODE_CONCURRENT_RUNNING_QUERIES("plugins.ppl.async.node_concurrent_running_queries"), + + /** Maximum number of asynchronous PPL jobs retained on one node. */ + PPL_ASYNC_MAX_RETAINED_JOBS("plugins.ppl.async.max_retained_jobs"), + + /** Maximum accepted submit wait-for-completion timeout. */ + PPL_ASYNC_MAX_WAIT_FOR_COMPLETION_TIMEOUT("plugins.ppl.async.max_wait_for_completion_timeout"), + + /** Maximum accepted asynchronous PPL job lease. */ + PPL_ASYNC_MAX_KEEP_ALIVE("plugins.ppl.async.max_keep_alive"), + /** Enable Calcite as execution engine */ CALCITE_ENGINE_ENABLED("plugins.calcite.enabled"), CALCITE_FALLBACK_ALLOWED("plugins.calcite.fallback.allowed"), diff --git a/core/src/main/java/org/opensearch/sql/executor/AsyncQueryExecution.java b/core/src/main/java/org/opensearch/sql/executor/AsyncQueryExecution.java new file mode 100644 index 00000000000..c52c124a099 --- /dev/null +++ b/core/src/main/java/org/opensearch/sql/executor/AsyncQueryExecution.java @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.executor; + +import java.util.Optional; +import java.util.concurrent.CompletionStage; +import org.opensearch.sql.executor.ExecutionEngine.QueryResponse; + +/** + * Lifecycle-facing handle for one asynchronous query execution. + * + *

The execution module owns result production and execution-specific resources. The lifecycle + * module owns this handle after submission and uses it to read the current result, observe terminal + * completion, and release those resources. + * + *

On successful completion, the authoritative final result must be visible through {@link + * #currentResult()} before {@link #completion()} completes normally. Implementations must make + * {@link #close()} idempotent and safe to call concurrently with {@link #currentResult()}. + */ +public interface AsyncQueryExecution extends AutoCloseable { + + /** Returns the complete result currently visible, or empty before a result is available. */ + Optional currentResult(); + + /** Completes normally on query success and exceptionally on query failure. */ + CompletionStage completion(); + + /** Releases execution-owned result resources. */ + @Override + void close(); +} diff --git a/docs/dev/query-job-mvp-design.md b/docs/dev/query-job-mvp-design.md new file mode 100644 index 00000000000..0a534014804 --- /dev/null +++ b/docs/dev/query-job-mvp-design.md @@ -0,0 +1,433 @@ +# Query Job Lifecycle — MVP Redesign + +> Response to review of [PR #5809](https://github.com/opensearch-project/sql/pull/5809). +> This document is design-only; no code has been changed. + +## 1. What we heard from review + +Three concerns from @dai-chen shaped this redesign. + +| # | Concern | Where it landed | +|---|---------|-----------------| +| 1 | Make the lifecycle layer language-neutral, similar to Livy's control plane over Spark. | The `job` package no longer names PPL. It talks only to a `QueryRunner` SPI. Language modules (`ppl`, `sql`, `async-query`) implement `QueryRunnerFactory`. | +| 2 | The execution boundary can be engine-neutral too — SQL, PPL, and AE all run behind it. | `QueryRunner` returns a neutral `QueryResult`. It knows nothing about ANTLR, `PPLService`, `SQLService`, or Spark. The same interface fits AE. | +| 3 | Separate execution state from retention. In a BigQuery-style model the job stays `RUNNING` regardless of whether a caller is still waiting. Treat `wait_for_completion_timeout` as submit-response behavior only. | The state machine has five values (`PENDING`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELLED`) and no retention axis. Waiting is a *transport* concern, not a *lifecycle* concern. | +| 3a | Extract internal responsibilities (JobStore, LeaseManager, …) from the service class. | `QueryJobService`, `QueryJobStore`, and `QueryJob` split along Single-Responsibility lines. `LeaseManager` is intentionally out of MVP; it can be added without changing the public interfaces (§7). | + +## 2. MVP scope + +**In** + +- One neutral job model that carries both sync and async queries. +- Submit / get / cancel over a small, engine-agnostic control plane. +- Owner-node routing via an opaque `QueryJobId`. +- Caller identity captured at submit and checked at get / cancel. + +**Out (deferred without breaking the API)** + +- `wait_for_completion_timeout` — MVP treats every submission as `wait_for_completion=forever`. The transport blocks on `QueryJob#completion()` until it resolves. +- `keep_alive` and retention leases — no expiry timer, no keep-alive renewal. Terminal jobs live only long enough for the caller to observe them. +- Admission control (`maxRunningQueries`, `maxRetainedJobs`). +- Persistent job store. +- Partial-result streaming (already deferred by the current PR). + +Everything under **Out** is a store, service, or transport concern. None of it changes the class surface described in §4. + +## 3. Livy inspiration in one paragraph + +Livy is a REST control plane that owns *sessions* and *statements* on top of Spark. Livy has no compiler and no query engine — it accepts a language tag (`scala`, `python`, `sql`, `r`), forwards the statement to the right session, and tracks a state machine on top. The session state machine is small: waiting → running → available | error | cancelled. Language is data; the control plane is code. We adopt the same split: `QueryJob` is language-blind, and `QueryRunner` is the per-engine adapter. + +## 4. Class model + +### 4.1 Package layout + +``` +core/src/main/java/org/opensearch/sql/job/ +├── QueryJob.java // active object (state machine) +├── QueryJobId.java // opaque, node-routable identifier +├── QueryJobState.java // PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED +├── QueryJobStatus.java // immutable snapshot +├── QueryJobStore.java // registry SPI (in-memory MVP impl provided) +├── QueryJobService.java // control plane: submit / get / cancel +├── QueryRunner.java // engine SPI: run() + cancel() +├── QueryRunnerFactory.java // engine SPI: build a runner from a request +├── QueryResult.java // neutral final result +├── QueryFailure.java // neutral failure descriptor +├── Principal.java // caller identity + authorization +└── SubmitRequest.java // neutral submission payload +``` + +`core` already has no dependency on other modules, so `job` is free of language and engine imports. `ppl`, `sql`, `async-query`, and future engines depend on `job` — not the other way around. + +### 4.2 Class diagram + +```mermaid +classDiagram + class QueryJobService { + <> + +submit(SubmitRequest) QueryJob + +get(QueryJobId, Principal) QueryJobStatus + +cancel(QueryJobId, Principal) QueryJobStatus + } + + class QueryJob { + -QueryJobId id + -Principal owner + -QueryRunner runner + -QueryJobState state + -CompletableFuture~QueryResult~ completion + +id() QueryJobId + +owner() Principal + +status() QueryJobStatus + +completion() CompletionStage~QueryResult~ + +cancel() void + } + + class QueryJobStore { + <> + +register(QueryJob) QueryJob + +find(QueryJobId) Optional~QueryJob~ + +remove(QueryJobId, QueryJob) boolean + +jobs() Collection~QueryJob~ + +close() void + } + + class QueryRunner { + <> + +run() CompletionStage~QueryResult~ + +cancel() void + } + + class QueryRunnerFactory { + <> + +create(SubmitRequest) QueryRunner + } + + class QueryJobId { + <> + +ownerNodeId String + +contextId String + +encode() String + +parse(String)$ QueryJobId + } + + class QueryJobStatus { + <> + +id QueryJobId + +state QueryJobState + +submittedAtMillis long + +startedAtMillis OptionalLong + +completedAtMillis OptionalLong + +failure Optional~QueryFailure~ + } + + class Principal { + <> + +name String + +tenant String + +backendRoles List~String~ + +authorize(Principal) void + } + + class QueryResult { + <> + +schema Schema + +rows List~ExprValue~ + +tookMillis long + } + + class QueryFailure { + <> + +type String + +reason String + } + + QueryJobService --> QueryJobStore : uses + QueryJobService --> QueryRunnerFactory : uses + QueryJobService --> QueryJob : creates + QueryJob --> QueryRunner : delegates execution + QueryJob --> Principal : owner + QueryJob --> QueryJobId : id + QueryJob --> QueryJobStatus : projects + QueryJobStore --> QueryJob : holds + QueryJobStatus --> QueryJobId + QueryJobStatus --> QueryJobState + QueryJobStatus --> QueryFailure +``` + +### 4.3 State machine + +Retention is not part of state. Waiting is not part of state. + +``` + submit() + │ + ▼ + ┌───────┐ runner starts ┌────────┐ + │PENDING│─────────────────▶│RUNNING │ + └───────┘ └────┬───┘ + │ + ┌───────────────────────┼───────────────────────┐ + ▼ ▼ ▼ + ┌─────────┐ ┌────────┐ ┌─────────┐ + │SUCCEEDED│ │ FAILED │ │CANCELLED│ + └─────────┘ └────────┘ └─────────┘ + +Terminal transitions are idempotent. cancel() from any non-terminal state +moves to CANCELLED; cancel() from a terminal state is a no-op. +``` + +## 5. Interface contracts + +Signatures below define the contract only. JavaDoc phrased for readers of Effective Java: each interface has one reason to exist (Item 20) and each record documents its invariants (Item 17). + +### 5.1 `QueryJobId` + +```java +public record QueryJobId(String ownerNodeId, String contextId) { + public QueryJobId { + // both fields must be non-blank + } + /** Fresh ID with a random context. */ + public static QueryJobId create(String ownerNodeId) { … } + /** URL-safe, versioned, opaque encoding. */ + public String encode() { … } + /** Uniform error on any parse failure. */ + public static QueryJobId parse(String encoded) { … } +} +``` + +Kept intentionally identical to today's `QueryJobId`. The encoding is a public wire format; a design-level rewrite must not disturb it. + +### 5.2 `Principal` + +```java +public record Principal(String name, String tenant, List backendRoles) { + public static final Principal UNSECURED = new Principal(null, null, List.of()); + public static Principal current(ThreadContext ctx) { … } + public void authorize(Principal caller) { … } +} +``` + +Renamed from `QueryJobOwner` for neutrality — the identity is not job-specific and will be reused by any future admin action. + +### 5.3 `QueryJobState`, `QueryJobStatus`, `QueryFailure` + +```java +public enum QueryJobState { + PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED; + public boolean isTerminal() { … } +} + +public record QueryJobStatus( + QueryJobId id, + QueryJobState state, + long submittedAtMillis, + OptionalLong startedAtMillis, + OptionalLong completedAtMillis, + Optional failure, + Optional result) { + // result present iff state == SUCCEEDED + // failure present iff state == FAILED +} + +public record QueryFailure(String type, String reason) { + public static QueryFailure of(Throwable t) { … } +} +``` + +`QueryJobStatus` collapses the previous `Snapshot` sealed hierarchy. The three record variants (`Running`, `Succeeded`, `Failed`) exist today only because retention returned different shapes; without retention, one record with a state discriminator is smaller and enforces the same invariants via record validation. + +### 5.4 `QueryResult` + +```java +public record QueryResult(Schema schema, List rows, long tookMillis) {} +``` + +Uses the existing `core` types (`ExecutionEngine.Schema`, `ExprValue`) so all engines already implementing them can produce a `QueryResult` without introducing new dependencies. + +### 5.5 `SubmitRequest` + +```java +public record SubmitRequest( + String language, // "ppl" | "sql" | ... + String statement, + Map params, // engine-specific, opaque to core + Principal submitter) {} +``` + +Language is data. The service uses it to pick a `QueryRunnerFactory`. Neither the service nor the job parses `statement` or reads `params`. + +### 5.6 `QueryRunner` and `QueryRunnerFactory` + +```java +public interface QueryRunner { + /** Single-use. Multiple invocations must throw IllegalStateException. */ + CompletionStage run(); + + /** Idempotent cooperative cancel. Safe to call before run() and after completion. */ + void cancel(); +} + +public interface QueryRunnerFactory { + /** Language handled by this factory, matched against SubmitRequest#language. */ + String language(); + QueryRunner create(SubmitRequest request); +} +``` + +Two-method interface (Item 21: interfaces are for use, not for reuse). PPL, SQL, and AE each provide one implementation and register it via Guice. The lifecycle package never imports any of them. + +### 5.7 `QueryJob` + +```java +public final class QueryJob { + + /** Package-private: only QueryJobService instantiates jobs. */ + QueryJob(QueryJobId id, Principal owner, QueryRunner runner, Clock clock) { … } + + public QueryJobId id() { … } + public Principal owner() { … } + public QueryJobStatus status() { … } // snapshot, never throws + public CompletionStage completion() { … } + public void cancel() { … } // idempotent +} +``` + +- Thread safety documented at the class level (Effective Java Item 82). All mutable fields are guarded by `this`; every side effect (runner cancel, store removal, listener notification) happens **after** the monitor is released, exactly as today's `QueryJob` does. +- `completion()` returns a `CompletionStage` view. The internal `CompletableFuture` is never leaked, so callers cannot complete the job externally (Item 15). +- `cancel()` is the *only* public mutator. Retention, expiry, and admission are not job concerns. + +### 5.8 `QueryJobStore` + +```java +public interface QueryJobStore extends Closeable { + QueryJob register(QueryJob job); // returns existing on duplicate ID + Optional find(QueryJobId id); + boolean remove(QueryJobId id, QueryJob job); // conditional + Collection jobs(); + @Override void close(); // drops residual jobs +} +``` + +The MVP ships one implementation: `InMemoryQueryJobStore`, a thin wrapper around `ConcurrentHashMap`. A persistent (system-index) implementation can be added later without touching `QueryJob` or `QueryJobService`. + +### 5.9 `QueryJobService` + +```java +public interface QueryJobService { + QueryJob submit(SubmitRequest request); + QueryJobStatus get(QueryJobId id, Principal caller); + QueryJobStatus cancel(QueryJobId id, Principal caller); +} +``` + +A single production implementation, `LocalQueryJobService`, wires: + +- `QueryJobStore` for the registry, +- a `Map` keyed by language, +- a `Clock` supplier, +- a local-node-id supplier for job ID minting. + +The service is **not** an `AbstractLifecycleComponent`. Lifecycle wiring (start / stop) lives in the plugin module (`AsyncQueryLifecycle`), keeping `core` free of OpenSearch node types. + +## 6. Sync = async with `wait_for_timeout=forever` + +Only the transport layer knows whether a caller is willing to wait. + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Transport as SyncTransport + participant Service as QueryJobService + participant Job as QueryJob + participant Runner as QueryRunner + + Client->>Transport: POST /_plugins/_ppl {query} + Transport->>Service: submit(request) + Service->>Job: new QueryJob(id, owner, runner) + Service->>Runner: run() + Service-->>Transport: QueryJob + Runner-->>Job: result / failure + Transport->>Job: completion().join() + Job-->>Transport: QueryResult + Transport-->>Client: formatted response +``` + +The async transport differs only after step 5: + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Transport as AsyncTransport + participant Service as QueryJobService + participant Job as QueryJob + + Client->>Transport: POST /_plugins/_async_ppl {query} + Transport->>Service: submit(request) + Service-->>Transport: QueryJob + Transport-->>Client: 202 {jobId} + Note over Client,Transport: later … + Client->>Transport: GET /_plugins/_async_ppl/{jobId} + Transport->>Service: get(jobId, caller) + Service-->>Transport: QueryJobStatus + Transport-->>Client: status (+ result if SUCCEEDED) +``` + +Both paths use the same `QueryJob`. Nothing about the job knows which one was chosen. + +## 7. Extensibility without churn + +Every deferred feature lands behind an interface that already exists. + +| Feature | Change surface | +|---------|----------------| +| `wait_for_completion_timeout` | Transport chooses `completion().orTimeout(...)` and returns `QueryJobStatus.RUNNING` on timeout. Job unchanged. | +| `keep_alive` | New `RetentionPolicy` composed into `QueryJobService`. Adds an `AutoCloseable` timer per terminal job; store gains `evict()`. Job unchanged. | +| Admission control | `AdmissionController` interface, invoked before `store.register`. Job unchanged. | +| Persistent job metadata | Alternate `QueryJobStore` implementation. Service and job unchanged. | +| SQL and AE support | New `QueryRunnerFactory` implementations. Zero core changes. | +| Progress / partial results | `QueryRunner` gains a second method (default returning empty). All existing engines compile without change. | + +Because each seam is an interface with one reason to change, the deferrals really are deferrals — not "we'll rewrite later". + +## 8. Effective Java / Clean Code checklist + +- **Item 1** — Static factory methods where they help: `QueryJobId.create`, `QueryFailure.of`, `Principal.current`. Constructors are hidden or package-private. +- **Item 15** — `QueryJob`'s `CompletableFuture` is never exposed; only its `CompletionStage` view is. +- **Item 17** — `QueryJobId`, `QueryJobStatus`, `QueryFailure`, `QueryResult`, `Principal`, `SubmitRequest` are `record`s with compact-constructor validation. +- **Item 18** — `QueryJobService` composes a store, factories, and a clock. No inheritance chain. +- **Item 20** — Every seam is an interface. Alternate engines and stores drop in. +- **Item 22** — Each interface expresses one role; no marker or constant interfaces. +- **Item 24** — `QueryJobStatus` is a top-level record, not a nested type of `QueryJob`. +- **Item 55** — `Optional` on values that legitimately may be absent (`find`, `failure`, `result`, `startedAtMillis`). `OptionalLong` for the primitive fields (Item 55 forbids `Optional`). +- **Item 82** — `QueryJob` documents thread-safety at class level and lists guarded fields. +- **Clean Code, small classes** — the biggest class in the package is `QueryJob`; its public API is four methods. +- **Clean Code, intent-revealing names** — `submit` / `get` / `cancel`, `register` / `find` / `remove`. No `handleXxx`, no `processYyy`. +- **Clean Code, no duplication** — sync and async share one `QueryJob`; only the transport layer differs. + +## 9. What migrates from the current PR + +The existing branch already ships the plumbing this design keeps. The redesign is refactor-shaped, not rewrite-shaped: + +| Today (`feat/query-job-refactor`) | MVP redesign | +|-----------------------------------|--------------| +| `plugin.transport.asyncquery.QueryJob` (983 lines) | `core.job.QueryJob` (~250 lines after retention drops out). | +| `QueryJob.Snapshot` sealed hierarchy (`Running`, `Succeeded`, `Failed`) | `QueryJobStatus` record with a `state` discriminator. | +| `QueryJobRegistry` | `QueryJobStore` interface + `InMemoryQueryJobStore`. | +| `QueryJobOwner` | `Principal`. | +| `AsyncQueryExecution` (in `core.executor`) | `QueryRunner` (in `core.job`). Same shape, engine-neutral name and location. | +| `DefaultAsyncQueryExecution` (in `ppl`) | `PPLQueryRunner` in `ppl` implementing `QueryRunner`; symmetrical `SQLQueryRunner` in `sql`. | +| `PPLAsyncQueryService` (mentioned in dai-chen's comment #3, not yet in-tree) | `LocalQueryJobService` — no retention, no leases, no admission for MVP. | +| `wait_for_completion_timeout` state axis | Transport-level `completion().orTimeout(...)` in a later PR. | +| `keep_alive` expiry timer | Deferred; lands as a `RetentionPolicy` composed into the service. | + +## 10. Open questions + +1. Do we want `QueryResult` to model streaming from day one (a `Publisher` instead of `List`) so partial results are additive later? MVP says no; two engines that already produce final results would need adaptation. Flagging for review. +2. Should `Principal.current` live on `Principal` or in a `SecurityAdapter` interface? Today it reaches into `ThreadContext`; that couples `core.job` to OpenSearch. A `SecurityAdapter` SPI is cleaner and keeps `core` engine-neutral, but adds one more file. Leaning `SecurityAdapter`. +3. Is one job store per node acceptable in MVP, or does the first release need cluster-visible metadata (backed by a system index)? + +Feedback welcome on any of the above. diff --git a/opensearch/src/main/java/org/opensearch/sql/opensearch/setting/OpenSearchSettings.java b/opensearch/src/main/java/org/opensearch/sql/opensearch/setting/OpenSearchSettings.java index ad6bdcb7c9c..97f4c390b74 100644 --- a/opensearch/src/main/java/org/opensearch/sql/opensearch/setting/OpenSearchSettings.java +++ b/opensearch/src/main/java/org/opensearch/sql/opensearch/setting/OpenSearchSettings.java @@ -36,6 +36,8 @@ /** Setting implementation on OpenSearch. */ @Log4j2 public class OpenSearchSettings extends Settings { + private static final TimeValue MAX_PPL_ASYNC_KEEP_ALIVE = TimeValue.timeValueHours(24); + /** Default settings. */ private final Map> defaultSettings; @@ -86,6 +88,42 @@ public class OpenSearchSettings extends Settings { Setting.Property.NodeScope, Setting.Property.Dynamic); + /** Maximum number of asynchronous PPL queries running concurrently on one node. */ + public static final Setting PPL_ASYNC_NODE_CONCURRENT_RUNNING_QUERIES_SETTING = + Setting.intSetting( + Key.PPL_ASYNC_NODE_CONCURRENT_RUNNING_QUERIES.getKeyValue(), + 20, + 1, + Setting.Property.NodeScope, + Setting.Property.Dynamic); + + /** Maximum number of asynchronous PPL jobs retained on one node. */ + public static final Setting PPL_ASYNC_MAX_RETAINED_JOBS_SETTING = + Setting.intSetting( + Key.PPL_ASYNC_MAX_RETAINED_JOBS.getKeyValue(), + 100, + 1, + Setting.Property.NodeScope, + Setting.Property.Dynamic); + + /** Maximum accepted submit wait-for-completion timeout. */ + public static final Setting PPL_ASYNC_MAX_WAIT_FOR_COMPLETION_TIMEOUT_SETTING = + Setting.positiveTimeSetting( + Key.PPL_ASYNC_MAX_WAIT_FOR_COMPLETION_TIMEOUT.getKeyValue(), + TimeValue.timeValueSeconds(60), + Setting.Property.NodeScope, + Setting.Property.Dynamic); + + /** Configurable asynchronous PPL job lease limit, capped at 24 hours. */ + public static final Setting PPL_ASYNC_MAX_KEEP_ALIVE_SETTING = + Setting.timeSetting( + Key.PPL_ASYNC_MAX_KEEP_ALIVE.getKeyValue(), + MAX_PPL_ASYNC_KEEP_ALIVE, + TimeValue.ZERO, + MAX_PPL_ASYNC_KEEP_ALIVE, + Setting.Property.NodeScope, + Setting.Property.Dynamic); + public static final Setting PPL_SYNTAX_LEGACY_PREFERRED_SETTING = Setting.boolSetting( Key.PPL_SYNTAX_LEGACY_PREFERRED.getKeyValue(), @@ -444,6 +482,30 @@ public OpenSearchSettings(ClusterSettings clusterSettings) { Key.PPL_QUERY_TIMEOUT, PPL_QUERY_TIMEOUT_SETTING, new Updater(Key.PPL_QUERY_TIMEOUT)); + register( + settingBuilder, + clusterSettings, + Key.PPL_ASYNC_NODE_CONCURRENT_RUNNING_QUERIES, + PPL_ASYNC_NODE_CONCURRENT_RUNNING_QUERIES_SETTING, + new Updater(Key.PPL_ASYNC_NODE_CONCURRENT_RUNNING_QUERIES)); + register( + settingBuilder, + clusterSettings, + Key.PPL_ASYNC_MAX_RETAINED_JOBS, + PPL_ASYNC_MAX_RETAINED_JOBS_SETTING, + new Updater(Key.PPL_ASYNC_MAX_RETAINED_JOBS)); + register( + settingBuilder, + clusterSettings, + Key.PPL_ASYNC_MAX_WAIT_FOR_COMPLETION_TIMEOUT, + PPL_ASYNC_MAX_WAIT_FOR_COMPLETION_TIMEOUT_SETTING, + new Updater(Key.PPL_ASYNC_MAX_WAIT_FOR_COMPLETION_TIMEOUT)); + register( + settingBuilder, + clusterSettings, + Key.PPL_ASYNC_MAX_KEEP_ALIVE, + PPL_ASYNC_MAX_KEEP_ALIVE_SETTING, + new Updater(Key.PPL_ASYNC_MAX_KEEP_ALIVE)); register( settingBuilder, clusterSettings, @@ -767,6 +829,10 @@ public static List> pluginSettings() { .add(DESERIALIZATION_MAX_BYTES_SETTING) .add(PPL_ENABLED_SETTING) .add(PPL_QUERY_TIMEOUT_SETTING) + .add(PPL_ASYNC_NODE_CONCURRENT_RUNNING_QUERIES_SETTING) + .add(PPL_ASYNC_MAX_RETAINED_JOBS_SETTING) + .add(PPL_ASYNC_MAX_WAIT_FOR_COMPLETION_TIMEOUT_SETTING) + .add(PPL_ASYNC_MAX_KEEP_ALIVE_SETTING) .add(PPL_SYNTAX_LEGACY_PREFERRED_SETTING) .add(CALCITE_ENGINE_ENABLED_SETTING) .add(CALCITE_FALLBACK_ALLOWED_SETTING) diff --git a/opensearch/src/test/java/org/opensearch/sql/opensearch/setting/OpenSearchSettingsTest.java b/opensearch/src/test/java/org/opensearch/sql/opensearch/setting/OpenSearchSettingsTest.java index 63678051d3d..7c9e3cb3f9e 100644 --- a/opensearch/src/test/java/org/opensearch/sql/opensearch/setting/OpenSearchSettingsTest.java +++ b/opensearch/src/test/java/org/opensearch/sql/opensearch/setting/OpenSearchSettingsTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.AdditionalMatchers.not; import static org.mockito.AdditionalMatchers.or; @@ -28,6 +29,7 @@ import org.opensearch.cluster.ClusterName; import org.opensearch.common.settings.ClusterSettings; import org.opensearch.common.settings.Setting; +import org.opensearch.common.unit.TimeValue; import org.opensearch.core.common.unit.ByteSizeValue; import org.opensearch.sql.common.setting.Settings; import org.opensearch.sql.utils.DeserializationFilterUtil; @@ -168,6 +170,50 @@ void deserializationStructuralLimitSettingsAreDynamicAndRegistered() { assertTrue(pluginSettings.contains(OpenSearchSettings.DESERIALIZATION_MAX_BYTES_SETTING)); } + @Test + void pplAsyncSettingsHaveExpectedDefaultsAndAreDynamic() { + org.opensearch.common.settings.Settings empty = org.opensearch.common.settings.Settings.EMPTY; + + assertEquals( + 20, OpenSearchSettings.PPL_ASYNC_NODE_CONCURRENT_RUNNING_QUERIES_SETTING.get(empty)); + assertEquals(100, OpenSearchSettings.PPL_ASYNC_MAX_RETAINED_JOBS_SETTING.get(empty)); + assertEquals( + TimeValue.timeValueSeconds(60), + OpenSearchSettings.PPL_ASYNC_MAX_WAIT_FOR_COMPLETION_TIMEOUT_SETTING.get(empty)); + assertEquals( + TimeValue.timeValueHours(24), + OpenSearchSettings.PPL_ASYNC_MAX_KEEP_ALIVE_SETTING.get(empty)); + + List> pluginSettings = OpenSearchSettings.pluginSettings(); + assertTrue( + pluginSettings.contains( + OpenSearchSettings.PPL_ASYNC_NODE_CONCURRENT_RUNNING_QUERIES_SETTING)); + assertTrue(pluginSettings.contains(OpenSearchSettings.PPL_ASYNC_MAX_RETAINED_JOBS_SETTING)); + assertTrue( + pluginSettings.contains( + OpenSearchSettings.PPL_ASYNC_MAX_WAIT_FOR_COMPLETION_TIMEOUT_SETTING)); + assertTrue(pluginSettings.contains(OpenSearchSettings.PPL_ASYNC_MAX_KEEP_ALIVE_SETTING)); + assertTrue(OpenSearchSettings.PPL_ASYNC_NODE_CONCURRENT_RUNNING_QUERIES_SETTING.isDynamic()); + assertTrue(OpenSearchSettings.PPL_ASYNC_MAX_RETAINED_JOBS_SETTING.isDynamic()); + assertTrue(OpenSearchSettings.PPL_ASYNC_MAX_WAIT_FOR_COMPLETION_TIMEOUT_SETTING.isDynamic()); + assertTrue(OpenSearchSettings.PPL_ASYNC_MAX_KEEP_ALIVE_SETTING.isDynamic()); + } + + @Test + void pplAsyncMaxKeepAliveIsCappedAt24Hours() { + String key = Settings.Key.PPL_ASYNC_MAX_KEEP_ALIVE.getKeyValue(); + + assertEquals( + TimeValue.timeValueHours(24), + OpenSearchSettings.PPL_ASYNC_MAX_KEEP_ALIVE_SETTING.get( + org.opensearch.common.settings.Settings.builder().put(key, "24h").build())); + assertThrows( + IllegalArgumentException.class, + () -> + OpenSearchSettings.PPL_ASYNC_MAX_KEEP_ALIVE_SETTING.get( + org.opensearch.common.settings.Settings.builder().put(key, "25h").build())); + } + @Test void getSparkExecutionEngineConfigSetting() { // Default is empty string diff --git a/plugin/build.gradle b/plugin/build.gradle index d0c825424be..64ac97f561a 100644 --- a/plugin/build.gradle +++ b/plugin/build.gradle @@ -168,6 +168,7 @@ dependencies { api project(':datasources') api project(':async-query') api project(':direct-query') + implementation "org.opensearch:common-utils:${opensearch_build}" testImplementation group: 'net.bytebuddy', name: 'byte-buddy-agent', version: '1.15.11' testImplementation group: 'org.hamcrest', name: 'hamcrest-library', version: "${hamcrest_version}" diff --git a/plugin/src/main/java/org/opensearch/sql/plugin/PPLQueryErrorHandler.java b/plugin/src/main/java/org/opensearch/sql/plugin/PPLQueryErrorHandler.java new file mode 100644 index 00000000000..9d690dd3e67 --- /dev/null +++ b/plugin/src/main/java/org/opensearch/sql/plugin/PPLQueryErrorHandler.java @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.plugin; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.OpenSearchException; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.index.IndexNotFoundException; +import org.opensearch.sql.common.antlr.SyntaxCheckException; +import org.opensearch.sql.common.error.ErrorReport; +import org.opensearch.sql.datasources.exceptions.DataSourceClientException; +import org.opensearch.sql.exception.QueryEngineException; +import org.opensearch.sql.legacy.metrics.MetricName; +import org.opensearch.sql.legacy.metrics.Metrics; + +/** Classifies PPL failures and records the corresponding customer or system error metric. */ +public final class PPLQueryErrorHandler { + private static final Logger LOG = LogManager.getLogger(PPLQueryErrorHandler.class); + + private PPLQueryErrorHandler() {} + + /** + * Records a PPL failure and returns the HTTP status associated with it. + * + * @param exception query failure + * @return client or system error status + */ + public static RestStatus recordFailure(Exception exception) { + int code = rawStatusCode(exception); + if (400 <= code && code < 500) { + increment(MetricName.PPL_FAILED_REQ_COUNT_CUS); + } else if (500 <= code && code < 600) { + increment(MetricName.PPL_FAILED_REQ_COUNT_SYS); + } else { + LOG.warn( + "Got an exception returning non-error status {}", RestStatus.fromCode(code), exception); + } + return RestStatus.fromCode(code); + } + + private static int rawStatusCode(Exception exception) { + if (exception instanceof ErrorReport errorReport) { + return rawStatusCode(errorReport.getCause()); + } + if (exception instanceof OpenSearchException openSearchException) { + return openSearchException.status().getStatus(); + } + return isClientError(exception) ? 400 : 500; + } + + private static boolean isClientError(Exception exception) { + return exception instanceof IllegalArgumentException + || exception instanceof IndexNotFoundException + || exception instanceof QueryEngineException + || exception instanceof SyntaxCheckException + || exception instanceof DataSourceClientException + || exception instanceof IllegalAccessException; + } + + private static void increment(MetricName metricName) { + Metrics.getInstance().getNumericalMetric(metricName).increment(); + } +} diff --git a/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJob.java b/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJob.java new file mode 100644 index 00000000000..f105047ebe0 --- /dev/null +++ b/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJob.java @@ -0,0 +1,983 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.plugin.transport.asyncquery; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.function.Function; +import java.util.function.LongSupplier; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.ResourceNotFoundException; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.common.lease.Releasable; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.tasks.TaskId; +import org.opensearch.sql.executor.AsyncQueryExecution; +import org.opensearch.sql.executor.ExecutionEngine.QueryResponse; +import org.opensearch.sql.executor.ExecutionEngine.Schema; +import org.opensearch.sql.plugin.PPLQueryErrorHandler; +import org.opensearch.sql.plugin.transport.PPLQueryAction; +import org.opensearch.sql.plugin.transport.PPLQueryTask; +import org.opensearch.sql.plugin.transport.TransportPPLQueryRequest; +import org.opensearch.tasks.CancellableTask; +import org.opensearch.tasks.Task; +import org.opensearch.tasks.TaskManager; +import org.opensearch.threadpool.Scheduler.ScheduledCancellable; +import org.opensearch.threadpool.ThreadPool; + +/** + * Active object representing one asynchronous PPL query on the owner node. + * + *

Each job owns its execution handle, cancellable task, retention deadline, and lease timer. It + * publishes itself to a {@link QueryJobRegistry} at creation and removes itself when it becomes + * unreachable through DELETE, expiry, direct completion, or shutdown. All state transitions are + * synchronized on the job; every observable side effect (registry mutation, listener callback, task + * cancellation, execution close) happens after the lock has been released. + * + *

The state machine tracks two axes: retention and completion. + * + *

+ * Current state       Event               Next state             Response
+ * RUNNING             direct success      REMOVED                final snapshot without ID
+ * RUNNING             direct failure      REMOVED                failure without ID
+ * RUNNING             retain deadline     RETAINED_RUNNING       running snapshot with ID
+ * RETAINED_RUNNING    success             RETAINED_SUCCEEDED     none (final result kept)
+ * RETAINED_RUNNING    failure             RETAINED_FAILED        none (failure kept)
+ * *                   DELETE              REMOVED                status observed at DELETE
+ * RETAINED_*          expire / shutdown   REMOVED                none
+ * 
+ */ +public final class QueryJob { + private static final Logger LOG = LogManager.getLogger(QueryJob.class); + + private static final Cancellable NO_TIMEOUT = () -> {}; + + /** Lifecycle state exposed in asynchronous PPL responses. */ + public enum Status { + /** Query execution is still running. */ + RUNNING, + + /** Query execution completed successfully. */ + SUCCEEDED, + + /** Query execution failed. */ + FAILED, + + /** Query execution was cancelled by DELETE. */ + CANCELLED + } + + /** + * Immutable point-in-time response for a job. + * + *

Each variant exposes only the data valid for its lifecycle state. + */ + public sealed interface Snapshot { + /** + * Returns the retained job ID. + * + * @return job ID, or empty for a final response returned directly by POST + */ + Optional id(); + + /** + * Returns the public lifecycle state. + * + * @return response status + */ + Status status(); + + /** + * Snapshot of a query that is still running. + * + * @param jobId retained job ID + * @param response current result, or empty before a result is available + */ + record Running(QueryJobId jobId, Optional response) implements Snapshot { + @Override + public Optional id() { + return Optional.of(jobId); + } + + @Override + public Status status() { + return Status.RUNNING; + } + } + + /** + * Snapshot of a successfully completed query. + * + * @param id retained job ID, or empty for a direct POST response + * @param response final query result + * @param tookMillis elapsed execution time + */ + record Succeeded(Optional id, QueryResponse response, long tookMillis) + implements Snapshot { + @Override + public Status status() { + return Status.SUCCEEDED; + } + } + + /** + * Snapshot of a failed query. + * + * @param id retained job ID, or empty for a direct POST response + * @param failure client-visible failure + * @param tookMillis elapsed execution time + */ + record Failed(Optional id, Failure failure, long tookMillis) implements Snapshot { + @Override + public Status status() { + return Status.FAILED; + } + } + } + + /** + * Client-visible failure retained with a failed job. + * + * @param type exception type + * @param reason client-facing failure reason + */ + public record Failure(String type, String reason) { + /** + * Derives a client-visible failure from an exception. + * + * @param exception exception raised by execution + * @return failure with the exception's simple type and message + */ + public static Failure from(Exception exception) { + String type = + exception.getClass().getSimpleName().isBlank() + ? exception.getClass().getName() + : exception.getClass().getSimpleName(); + String reason = + exception.getMessage() == null || exception.getMessage().isBlank() + ? "query execution failed" + : exception.getMessage(); + return new Failure(type, reason); + } + } + + /** Cancels a previously scheduled timer. Multiple cancellations are safe. */ + @FunctionalInterface + public interface Cancellable { + /** Cancels the timer if it has not already fired. */ + void cancel(); + } + + /** Schedules a one-shot runnable. Production wraps {@link ThreadPool}; tests use a fake. */ + @FunctionalInterface + public interface Scheduler { + /** + * Schedules {@code task} to run once after {@code delayMillis} milliseconds. + * + * @param delayMillis delay before firing + * @param task action to run + * @return handle used to cancel the scheduled task + */ + Cancellable schedule(long delayMillis, Runnable task); + } + + /** Owned cancellable task and its registration cleanup. Package-private for tests. */ + record JobTask(CancellableTask task, Runnable release) { + /** Releases task-manager registrations owned by this wrapper. Safe to call once. */ + void close() { + release.run(); + } + } + + /** Internal lifecycle; unlike {@link Status}, this includes retention and removal. */ + private enum State { + RUNNING, + RETAINED_RUNNING, + RETAINED_SUCCEEDED, + RETAINED_FAILED, + REMOVED + } + + private final QueryJobId id; + private final QueryJobRegistry registry; + private final LongSupplier clock; + private final Scheduler scheduler; + private final TaskManager taskManager; + private final QueryJobOwner owner; + private final long startTimeMillis; + private final ActionListener responseListener; + + private State state = State.RUNNING; + private long keepAliveMillis; + private long expirationTimeMillis; + private long completionTimeMillis = -1L; + private JobTask task; + private AsyncQueryExecution execution; + private Failure failure; + private Cancellable retentionTimer = NO_TIMEOUT; + private Cancellable expiryTimer = NO_TIMEOUT; + + private QueryJob( + QueryJobId id, + QueryJobRegistry registry, + LongSupplier clock, + Scheduler scheduler, + TaskManager taskManager, + QueryJobOwner owner, + long keepAliveMillis, + JobTask task, + ActionListener responseListener) { + this.id = id; + this.registry = registry; + this.clock = clock; + this.scheduler = scheduler; + this.taskManager = taskManager; + this.owner = owner; + this.startTimeMillis = clock.getAsLong(); + this.keepAliveMillis = keepAliveMillis; + this.expirationTimeMillis = startTimeMillis + keepAliveMillis; + this.task = task; + this.responseListener = responseListener; + } + + /** + * Starts an asynchronous PPL query in production wiring. + * + *

{@link TaskManager} registers the job's cancellable task as a child of {@code parentTask} so + * cancellation of the POST request cascades. The job schedules its own retention deadline on + * {@code threadPool} and calls {@code executionStarter} inline. If execution finishes before the + * deadline, {@code responseListener} receives the final result without a job ID. Otherwise the + * job becomes retained and the listener receives a running snapshot with the ID. + * + * @param registry registry that will hold the job while it is discoverable + * @param threadPool schedules the retention deadline and keep-alive expiry + * @param taskManager registers and cancels the job's own task + * @param parentTask POST request task; the job task becomes its child + * @param request transport request used to register the job task + * @param owner caller retained with the job for later authorization + * @param keepAlive lease duration applied when the job is retained + * @param waitForCompletion maximum time to wait for a direct result + * @param executionStarter starts execution using the job-owned cancellable task + * @param responseListener receives either the direct result or the retained running snapshot + * @return newly created job, already published to {@code registry} + * @throws NullPointerException if any required argument is null + */ + public static QueryJob create( + QueryJobRegistry registry, + ThreadPool threadPool, + TaskManager taskManager, + PPLQueryTask parentTask, + TransportPPLQueryRequest request, + QueryJobOwner owner, + TimeValue keepAlive, + TimeValue waitForCompletion, + Function executionStarter, + ActionListener responseListener) { + Objects.requireNonNull(threadPool); + Objects.requireNonNull(taskManager); + JobTask jobTask = registerJobTask(taskManager, parentTask, request); + try { + return create( + taskManager.localNode().getId(), + registry, + System::currentTimeMillis, + wrapScheduler(threadPool), + taskManager, + jobTask, + owner, + keepAlive, + waitForCompletion, + executionStarter, + responseListener); + } catch (RuntimeException | Error e) { + jobTask.close(); + throw e; + } + } + + /** + * Test entry point that accepts injectable clock, scheduler, and pre-registered task. + * + * @param localNodeId local node ID used to route the ID back on future requests + * @param registry registry that will hold the job while it is discoverable + * @param clock time source + * @param scheduler timer used for retention deadline and keep-alive expiry + * @param taskManager cancels the job task with descendant cascade; may be {@code null} when the + * job task should be cancelled directly + * @param task pre-registered cancellable task owned by the job + * @param owner caller retained with the job for later authorization + * @param keepAlive lease duration applied when the job is retained + * @param waitForCompletion maximum time to wait for a direct result + * @param executionStarter starts execution using the job-owned cancellable task + * @param responseListener receives either the direct result or the retained running snapshot + * @return newly created job, already published to {@code registry} + */ + static QueryJob create( + String localNodeId, + QueryJobRegistry registry, + LongSupplier clock, + Scheduler scheduler, + TaskManager taskManager, + JobTask task, + QueryJobOwner owner, + TimeValue keepAlive, + TimeValue waitForCompletion, + Function executionStarter, + ActionListener responseListener) { + Objects.requireNonNull(localNodeId); + Objects.requireNonNull(registry); + Objects.requireNonNull(clock); + Objects.requireNonNull(scheduler); + Objects.requireNonNull(task); + Objects.requireNonNull(owner); + Objects.requireNonNull(keepAlive); + Objects.requireNonNull(waitForCompletion); + Objects.requireNonNull(executionStarter); + Objects.requireNonNull(responseListener); + + QueryJob job = + publish( + localNodeId, + registry, + clock, + scheduler, + taskManager, + owner, + keepAlive.millis(), + task, + responseListener); + try { + job.scheduleRetention(waitForCompletion); + } catch (RuntimeException | Error e) { + job.discard("PPL asynchronous query startup failed"); + throw e; + } + job.startExecution(executionStarter); + return job; + } + + private static QueryJob publish( + String localNodeId, + QueryJobRegistry registry, + LongSupplier clock, + Scheduler scheduler, + TaskManager taskManager, + QueryJobOwner owner, + long keepAliveMillis, + JobTask task, + ActionListener responseListener) { + while (true) { + QueryJobId id = QueryJobId.create(localNodeId); + QueryJob job = + new QueryJob( + id, + registry, + clock, + scheduler, + taskManager, + owner, + keepAliveMillis, + task, + responseListener); + if (registry.add(job) == null) { + return job; + } + } + } + + /** + * Returns the opaque ID assigned to this job. + * + * @return job ID used as the registry key + */ + public QueryJobId getJobId() { + return id; + } + + /** + * Returns the immutable owner identity captured when this job was created. + * + * @return job owner used for GET and DELETE authorization + */ + public QueryJobOwner getOwner() { + return owner; + } + + /** + * Returns the current snapshot of the job. + * + *

A non-null {@code keepAlive} starts a new lease from the current time. A call at or after + * the current expiration removes the job and reports it as not found. + * + * @param keepAlive new lease duration, or {@code null} to leave the current lease unchanged + * @return immutable snapshot of the current state + * @throws ResourceNotFoundException if the job has been removed or has just expired + */ + public Snapshot get(TimeValue keepAlive) { + long now = clock.getAsLong(); + AsyncQueryExecution executionForResult; + State stateForResult; + long tookMillis; + Failure failureForResult; + boolean expired = false; + Cancellable retentionToCancel = NO_TIMEOUT; + Cancellable expiryToCancel = NO_TIMEOUT; + Cancellable rescheduledExpiry = NO_TIMEOUT; + JobTask taskToClose = null; + AsyncQueryExecution executionToClose = null; + + synchronized (this) { + ensurePresent(); + if (state != State.RUNNING && now >= expirationTimeMillis) { + expired = true; + retentionToCancel = retentionTimer; + expiryToCancel = expiryTimer; + retentionTimer = NO_TIMEOUT; + expiryTimer = NO_TIMEOUT; + taskToClose = detachTask(); + executionToClose = detachExecution(); + state = State.REMOVED; + executionForResult = null; + stateForResult = null; + tookMillis = 0L; + failureForResult = null; + } else { + if (keepAlive != null && state != State.RUNNING) { + keepAliveMillis = keepAlive.millis(); + expirationTimeMillis = now + keepAliveMillis; + expiryToCancel = expiryTimer; + expiryTimer = NO_TIMEOUT; + rescheduledExpiry = scheduler.schedule(keepAliveMillis, this::expire); + expiryTimer = rescheduledExpiry; + } + executionForResult = execution; + stateForResult = state; + tookMillis = + completionTimeMillis < 0 ? -1L : Math.max(0L, completionTimeMillis - startTimeMillis); + failureForResult = failure; + } + } + + if (expired) { + retentionToCancel.cancel(); + expiryToCancel.cancel(); + registry.remove(id, this); + cancelTaskAsync(taskToClose, "PPL asynchronous query expired"); + closeExecution(executionToClose); + throw notFound(); + } + + expiryToCancel.cancel(); + return buildSnapshot(stateForResult, executionForResult, failureForResult, tookMillis, true); + } + + /** + * Cancels this job and returns the status observed at cancellation. + * + * @param reason human-readable reason recorded on the underlying task + * @return the {@link Status} observed when the job was removed + * @throws ResourceNotFoundException if the job has been removed or has just expired + */ + public Status cancel(String reason) { + Objects.requireNonNull(reason); + Status observed; + Cancellable retentionToCancel; + Cancellable expiryToCancel; + JobTask taskToClose; + AsyncQueryExecution executionToClose; + String cancelReason; + boolean cancelRunning; + + synchronized (this) { + ensurePresent(); + if (state != State.RUNNING && clock.getAsLong() >= expirationTimeMillis) { + // Late DELETE races expiry; the job is gone. + retentionToCancel = retentionTimer; + expiryToCancel = expiryTimer; + retentionTimer = NO_TIMEOUT; + expiryTimer = NO_TIMEOUT; + taskToClose = detachTask(); + executionToClose = detachExecution(); + state = State.REMOVED; + cancelReason = "PPL asynchronous query expired"; + cancelRunning = true; + observed = null; + } else { + observed = + switch (state) { + case RUNNING, RETAINED_RUNNING -> Status.CANCELLED; + case RETAINED_SUCCEEDED -> Status.SUCCEEDED; + case RETAINED_FAILED -> Status.FAILED; + case REMOVED -> throw new IllegalStateException("PPL asynchronous query was removed"); + }; + cancelRunning = (state == State.RUNNING || state == State.RETAINED_RUNNING); + retentionToCancel = retentionTimer; + expiryToCancel = expiryTimer; + retentionTimer = NO_TIMEOUT; + expiryTimer = NO_TIMEOUT; + taskToClose = detachTask(); + executionToClose = detachExecution(); + state = State.REMOVED; + cancelReason = reason; + } + } + + retentionToCancel.cancel(); + expiryToCancel.cancel(); + registry.remove(id, this); + if (cancelRunning) { + cancelTaskAsync(taskToClose, cancelReason); + } else { + closeTask(taskToClose); + } + closeExecution(executionToClose); + + if (observed == null) { + throw notFound(); + } + return observed; + } + + /** + * Removes the job as part of an internal cleanup (shutdown or startup abort). Idempotent. + * + * @param reason reason recorded on the underlying task + */ + void discard(String reason) { + Objects.requireNonNull(reason); + Cancellable retentionToCancel; + Cancellable expiryToCancel; + JobTask taskToClose; + AsyncQueryExecution executionToClose; + boolean wasExecuting; + + synchronized (this) { + if (state == State.REMOVED) { + return; + } + wasExecuting = (state == State.RUNNING || state == State.RETAINED_RUNNING); + retentionToCancel = retentionTimer; + expiryToCancel = expiryTimer; + retentionTimer = NO_TIMEOUT; + expiryTimer = NO_TIMEOUT; + taskToClose = detachTask(); + executionToClose = detachExecution(); + state = State.REMOVED; + } + + retentionToCancel.cancel(); + expiryToCancel.cancel(); + registry.remove(id, this); + if (wasExecuting) { + cancelTaskAsync(taskToClose, reason); + } else { + closeTask(taskToClose); + } + closeExecution(executionToClose); + } + + // ---------- Retention deadline ---------- + + private void scheduleRetention(TimeValue waitForCompletion) { + if (waitForCompletion.millis() == 0) { + onRetentionDeadline(); + return; + } + Cancellable timer = scheduler.schedule(waitForCompletion.millis(), this::onRetentionDeadline); + synchronized (this) { + if (state == State.RUNNING) { + retentionTimer = timer; + return; + } + } + // Retention state already changed while the timer was being wired up. Cancel it. + timer.cancel(); + } + + private void onRetentionDeadline() { + Snapshot snapshotToDeliver; + Cancellable rescheduledExpiry; + + synchronized (this) { + if (state != State.RUNNING) { + return; + } + state = State.RETAINED_RUNNING; + long now = clock.getAsLong(); + expirationTimeMillis = now + keepAliveMillis; + snapshotToDeliver = new Snapshot.Running(id, currentResultCopy()); + retentionTimer = NO_TIMEOUT; + rescheduledExpiry = scheduler.schedule(keepAliveMillis, this::expire); + expiryTimer = rescheduledExpiry; + } + + try { + responseListener.onResponse(snapshotToDeliver); + } catch (RuntimeException e) { + // Cannot deliver retained ID; discard the job so no state leaks. + discard("PPL asynchronous query startup failed"); + safeNotifyFailure(e); + } + } + + // ---------- Execution attachment and completion ---------- + + private void startExecution(Function executionStarter) { + AsyncQueryExecution started; + try { + started = Objects.requireNonNull(executionStarter.apply(task().task())); + } catch (RuntimeException e) { + fail(e); + return; + } + if (!tryAttachExecution(started)) { + closeExecution(started); + return; + } + started + .completion() + .whenComplete( + (ignored, err) -> { + if (err == null) { + complete(); + } else { + fail(asException(err)); + } + }); + } + + private synchronized JobTask task() { + return task; + } + + private synchronized boolean tryAttachExecution(AsyncQueryExecution incoming) { + if (state != State.RUNNING && state != State.RETAINED_RUNNING) { + return false; + } + if (execution != null) { + return false; + } + execution = incoming; + return true; + } + + private void complete() { + long now = clock.getAsLong(); + boolean directResponse; + JobTask taskToClose; + AsyncQueryExecution executionForSnapshot; + Cancellable retentionToCancel; + + synchronized (this) { + if (state != State.RUNNING && state != State.RETAINED_RUNNING) { + return; + } + if (execution == null) { + throw new IllegalStateException( + "PPL asynchronous execution must be attached before successful completion"); + } + completionTimeMillis = now; + directResponse = (state == State.RUNNING); + retentionToCancel = retentionTimer; + retentionTimer = NO_TIMEOUT; + taskToClose = detachTask(); + if (directResponse) { + executionForSnapshot = detachExecution(); + state = State.REMOVED; + } else { + executionForSnapshot = null; + state = State.RETAINED_SUCCEEDED; + } + } + + retentionToCancel.cancel(); + if (directResponse) { + registry.remove(id, this); + Snapshot snapshot; + try { + QueryResponse result = + executionForSnapshot + .currentResult() + .orElseThrow( + () -> + new IllegalStateException( + "Successful PPL asynchronous execution completed without a final" + + " result")); + snapshot = + new Snapshot.Succeeded(Optional.empty(), defensiveCopy(result), now - startTimeMillis); + } catch (RuntimeException e) { + safeNotifyFailure(e); + closeTask(taskToClose); + closeExecution(executionForSnapshot); + return; + } + try { + responseListener.onResponse(snapshot); + } catch (RuntimeException e) { + safeNotifyFailure(e); + } finally { + closeTask(taskToClose); + closeExecution(executionForSnapshot); + } + } else { + closeTask(taskToClose); + } + } + + private void fail(Exception cause) { + Objects.requireNonNull(cause); + long now = clock.getAsLong(); + boolean directResponse; + JobTask taskToClose; + AsyncQueryExecution executionToClose; + Cancellable retentionToCancel; + Failure captured = Failure.from(cause); + + synchronized (this) { + if (state != State.RUNNING && state != State.RETAINED_RUNNING) { + return; + } + completionTimeMillis = now; + failure = captured; + directResponse = (state == State.RUNNING); + retentionToCancel = retentionTimer; + retentionTimer = NO_TIMEOUT; + taskToClose = detachTask(); + executionToClose = detachExecution(); + state = directResponse ? State.REMOVED : State.RETAINED_FAILED; + } + + retentionToCancel.cancel(); + if (directResponse) { + registry.remove(id, this); + try { + responseListener.onFailure(cause); + } catch (RuntimeException e) { + LOG.warn("PPL asynchronous listener rejected failure ({})", e.getClass().getSimpleName()); + } finally { + closeTask(taskToClose); + closeExecution(executionToClose); + } + } else { + PPLQueryErrorHandler.recordFailure(cause); + closeTask(taskToClose); + closeExecution(executionToClose); + } + } + + private void expire() { + long now = clock.getAsLong(); + Cancellable retentionToCancel; + JobTask taskToClose; + AsyncQueryExecution executionToClose; + + synchronized (this) { + if (state == State.REMOVED || state == State.RUNNING || now < expirationTimeMillis) { + return; + } + retentionToCancel = retentionTimer; + retentionTimer = NO_TIMEOUT; + expiryTimer = NO_TIMEOUT; + taskToClose = detachTask(); + executionToClose = detachExecution(); + state = State.REMOVED; + } + + retentionToCancel.cancel(); + registry.remove(id, this); + cancelTaskAsync(taskToClose, "PPL asynchronous query expired"); + closeExecution(executionToClose); + } + + // ---------- Helpers ---------- + + private void ensurePresent() { + if (state == State.REMOVED) { + throw notFound(); + } + } + + private JobTask detachTask() { + JobTask detached = task; + task = null; + return detached; + } + + private AsyncQueryExecution detachExecution() { + AsyncQueryExecution detached = execution; + execution = null; + return detached; + } + + private Optional currentResultCopy() { + if (execution == null) { + return Optional.empty(); + } + return execution.currentResult().map(QueryJob::defensiveCopy); + } + + private Snapshot buildSnapshot( + State from, + AsyncQueryExecution execForResult, + Failure failureForResult, + long tookMillis, + boolean withId) { + Optional snapshotId = withId ? Optional.of(id) : Optional.empty(); + return switch (from) { + case RUNNING, RETAINED_RUNNING -> { + Optional current = + execForResult == null + ? Optional.empty() + : execForResult.currentResult().map(QueryJob::defensiveCopy); + yield new Snapshot.Running(id, current); + } + case RETAINED_SUCCEEDED -> { + QueryResponse result = + Objects.requireNonNull(execForResult, "attached execution required") + .currentResult() + .map(QueryJob::defensiveCopy) + .orElseThrow( + () -> + new IllegalStateException( + "Successful PPL asynchronous execution completed without a final" + + " result")); + yield new Snapshot.Succeeded(snapshotId, result, tookMillis); + } + case RETAINED_FAILED -> + new Snapshot.Failed(snapshotId, Objects.requireNonNull(failureForResult), tookMillis); + case REMOVED -> throw new IllegalStateException("PPL asynchronous query was removed"); + }; + } + + private void safeNotifyFailure(Exception cause) { + try { + responseListener.onFailure(cause); + } catch (RuntimeException e) { + LOG.warn("PPL asynchronous listener rejected failure ({})", e.getClass().getSimpleName()); + } + } + + private static QueryResponse defensiveCopy(QueryResponse response) { + Schema schema = new Schema(List.copyOf(response.getSchema().getColumns())); + QueryResponse copy = + new QueryResponse(schema, List.copyOf(response.getResults()), response.getCursor()); + copy.setWarnings(List.copyOf(response.getWarnings())); + return copy; + } + + private static void closeTask(JobTask task) { + if (task != null) { + task.close(); + } + } + + private void cancelTaskAsync(JobTask jobTask, String reason) { + if (jobTask == null) { + return; + } + CancellableTask cancellable = jobTask.task(); + if (cancellable == null || cancellable.isCancelled()) { + jobTask.close(); + return; + } + try { + if (taskManager == null) { + cancellable.cancel(reason); + jobTask.close(); + } else { + taskManager.cancelTaskAndDescendants( + cancellable, + reason, + false, + ActionListener.wrap( + ignored -> jobTask.close(), + error -> { + jobTask.close(); + LOG.warn( + "Failed to cancel descendants of PPL asynchronous query task ({})", + error.getClass().getSimpleName()); + })); + } + } catch (RuntimeException e) { + jobTask.close(); + LOG.warn("Failed to cancel PPL asynchronous query task ({})", e.getClass().getSimpleName()); + } + } + + private static void closeExecution(AsyncQueryExecution execution) { + if (execution == null) { + return; + } + try { + execution.close(); + } catch (RuntimeException e) { + LOG.warn( + "Failed to close PPL asynchronous query execution ({})", e.getClass().getSimpleName()); + } + } + + private static Exception asException(Throwable failure) { + Throwable cause = + failure instanceof CompletionException && failure.getCause() != null + ? failure.getCause() + : failure; + return cause instanceof Exception exception ? exception : new RuntimeException(cause); + } + + private static ResourceNotFoundException notFound() { + return new ResourceNotFoundException("PPL asynchronous query not found"); + } + + private static Scheduler wrapScheduler(ThreadPool threadPool) { + return (delayMillis, task) -> { + ScheduledCancellable cancellable = + threadPool.schedule( + task, TimeValue.timeValueMillis(delayMillis), ThreadPool.Names.GENERIC); + return cancellable::cancel; + }; + } + + private static JobTask registerJobTask( + TaskManager taskManager, PPLQueryTask parentTask, TransportPPLQueryRequest request) { + Objects.requireNonNull(parentTask, "PPL asynchronous query request task is not initialized"); + DiscoveryNode localNode = + Objects.requireNonNull(taskManager.localNode(), "Local node is not initialized"); + + // Registered directly rather than through TransportAction.execute(); reproduce the two child- + // task bookkeeping steps TransportAction would perform. childNode registration lets parent + // cancellation ban this node; parentTaskId lets the ban find and cancel the retained task. + Releasable childNodeRegistration = taskManager.registerChildNode(parentTask.getId(), localNode); + TaskId originalParent = request.getParentTask(); + boolean registered = false; + try { + request.setParentTask(localNode.getId(), parentTask.getId()); + Task task = taskManager.register("transport", PPLQueryAction.NAME, request); + if (!(task instanceof PPLQueryTask pplQueryTask)) { + taskManager.unregister(task); + throw new IllegalStateException("Failed to create PPL asynchronous query task"); + } + registered = true; + return new JobTask( + pplQueryTask, + () -> { + try { + taskManager.unregister(pplQueryTask); + } finally { + childNodeRegistration.close(); + } + }); + } finally { + request.setParentTask(originalParent); + if (!registered) { + childNodeRegistration.close(); + } + } + } +} diff --git a/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobId.java b/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobId.java new file mode 100644 index 00000000000..05e4c97c7cd --- /dev/null +++ b/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobId.java @@ -0,0 +1,146 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.plugin.transport.asyncquery; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.UUID; + +/** + * Opaque, URL-safe identifier for an asynchronous PPL job. + * + *

The encoded value contains a format version, the owner node ID used for request routing, and a + * random per-job context ID. The complete encoded value is the key in the owner node's job + * registry. It contains no query text, user identity, or result data, and clients must treat it as + * opaque. + * + *

Before Base64 URL encoding, the binary layout is: + * + *

+ * int formatVersion
+ * int ownerNodeIdLength + UTF-8 ownerNodeId
+ * int contextIdLength   + UTF-8 contextId
+ * 
+ * + *

The ID provides routing, not authorization. GET and DELETE still authorize the current caller + * against the owner stored in the job. + * + * @param ownerNodeId node that owns the in-memory job + * @param contextId random identifier for one job on the owner node + */ +public record QueryJobId(String ownerNodeId, String contextId) { + private static final int FORMAT_VERSION = 1; + private static final int MAX_ENCODED_LENGTH = 2_048; + private static final int MAX_OWNER_NODE_ID_BYTES = 1_024; + private static final int MAX_CONTEXT_ID_BYTES = 128; + + /** + * Validates the component strings. + * + * @throws IllegalArgumentException when either component is null or blank + */ + public QueryJobId { + if (ownerNodeId == null || ownerNodeId.isBlank()) { + throw new IllegalArgumentException("PPL asynchronous query owner node must not be empty"); + } + if (contextId == null || contextId.isBlank()) { + throw new IllegalArgumentException("PPL asynchronous query context must not be empty"); + } + } + + /** + * Creates a new job ID for the given owner node. + * + * @param ownerNodeId node that will own the job + * @return unencoded job ID with a random context ID + */ + public static QueryJobId create(String ownerNodeId) { + return new QueryJobId(ownerNodeId, UUID.randomUUID().toString()); + } + + /** + * Serializes this ID using the versioned binary format and URL-safe Base64 without padding. + * + * @return opaque value returned by the asynchronous PPL API + */ + public String encode() { + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(FORMAT_VERSION); + writeString(output, ownerNodeId, MAX_OWNER_NODE_ID_BYTES); + writeString(output, contextId, MAX_CONTEXT_ID_BYTES); + output.flush(); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes.toByteArray()); + } catch (IOException e) { + throw new IllegalStateException("Failed to encode PPL asynchronous query ID", e); + } + } + + /** + * Decodes and validates an opaque job ID. + * + *

Parsing rejects empty or oversized input, unsupported versions, invalid component lengths, + * truncated data, and trailing bytes. All malformed input is reported uniformly to avoid exposing + * details of the internal encoding. + * + * @param encoded opaque value supplied by the client + * @return decoded owner node and context IDs + * @throws IllegalArgumentException when the value is not a valid job ID + */ + public static QueryJobId parse(String encoded) { + try { + if (encoded == null || encoded.isBlank() || encoded.length() > MAX_ENCODED_LENGTH) { + throw new IllegalArgumentException("Invalid PPL asynchronous query ID length"); + } + byte[] bytes = Base64.getUrlDecoder().decode(encoded); + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes))) { + int version = input.readInt(); + if (version != FORMAT_VERSION) { + throw new IllegalArgumentException( + "Unsupported PPL asynchronous query ID version [" + version + "]"); + } + QueryJobId id = + new QueryJobId( + readString(input, MAX_OWNER_NODE_ID_BYTES), + readString(input, MAX_CONTEXT_ID_BYTES)); + if (input.available() != 0) { + throw new IllegalArgumentException( + "Unexpected trailing bytes in PPL asynchronous query ID"); + } + return id; + } + } catch (Exception e) { + throw new IllegalArgumentException("Invalid PPL asynchronous query ID", e); + } + } + + private static void writeString(DataOutputStream output, String value, int maxLength) + throws IOException { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + if (bytes.length > maxLength) { + throw new IllegalArgumentException("PPL asynchronous query ID component is too long"); + } + output.writeInt(bytes.length); + output.write(bytes); + } + + private static String readString(DataInputStream input, int maxLength) throws IOException { + int length = input.readInt(); + if (length < 0 || length > maxLength) { + throw new IllegalArgumentException("Invalid PPL asynchronous query ID component length"); + } + byte[] bytes = input.readNBytes(length); + if (bytes.length != length) { + throw new IllegalArgumentException("Truncated PPL asynchronous query ID"); + } + return new String(bytes, StandardCharsets.UTF_8); + } +} diff --git a/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobOwner.java b/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobOwner.java new file mode 100644 index 00000000000..243d5d6f602 --- /dev/null +++ b/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobOwner.java @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.plugin.transport.asyncquery; + +import java.util.List; +import java.util.Objects; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.commons.ConfigConstants; +import org.opensearch.commons.authuser.User; +import org.opensearch.core.rest.RestStatus; + +/** + * Immutable owner identity used to authorize retained asynchronous query state. + * + * @param name authenticated principal, or {@code null} when no identity was supplied + * @param requestedTenant requested security tenant + * @param backendRoles backend roles captured when the job starts + */ +public record QueryJobOwner(String name, String requestedTenant, List backendRoles) { + + /** + * Identity used when OpenSearch Security does not provide caller information. + * + *

This identity does not bypass job ownership checks. A job owned by {@code UNSECURED} can be + * accessed only by a caller represented by the same identity. + */ + public static final QueryJobOwner UNSECURED = new QueryJobOwner(null, null, List.of()); + + /** + * Creates an immutable asynchronous query identity. + * + * @param name authenticated principal, or {@code null} when no identity was supplied + * @param requestedTenant requested security tenant + * @param backendRoles backend roles captured when the job starts + * @throws IllegalArgumentException if {@code name} is blank + */ + public QueryJobOwner { + backendRoles = backendRoles == null ? List.of() : List.copyOf(backendRoles); + if (name != null && name.isBlank()) { + throw new IllegalArgumentException("PPL asynchronous query user must not be blank"); + } + } + + /** + * Captures the current caller from the OpenSearch thread context. + * + * @param threadContext current request thread context + * @return immutable caller identity + * @throws OpenSearchSecurityException if the security identity cannot be parsed + */ + public static QueryJobOwner current(ThreadContext threadContext) { + try { + Object serialized = + threadContext.getTransient(ConfigConstants.OPENSEARCH_SECURITY_USER_INFO_THREAD_CONTEXT); + if (serialized == null) { + return UNSECURED; + } + User user = + serialized instanceof User currentUser + ? currentUser + : serialized instanceof String value ? User.parse(value) : null; + if (user == null) { + throw forbidden(); + } + return new QueryJobOwner(user.getName(), user.getRequestedTenant(), user.getBackendRoles()); + } catch (RuntimeException e) { + throw forbidden(); + } + } + + /** + * Verifies that a caller may access asynchronous query state owned by this identity. + * + * @param caller identity of the caller requesting access + * @throws OpenSearchSecurityException if the caller does not match the owner identity + */ + public void authorize(QueryJobOwner caller) { + if (!Objects.equals(name, caller.name) + || !Objects.equals(requestedTenant, caller.requestedTenant) + || !caller.backendRoles.containsAll(backendRoles)) { + throw forbidden(); + } + } + + private static OpenSearchSecurityException forbidden() { + return new OpenSearchSecurityException( + "Not authorized to access PPL asynchronous query", RestStatus.FORBIDDEN); + } +} diff --git a/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobRegistry.java b/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobRegistry.java new file mode 100644 index 00000000000..ac8cfe010d6 --- /dev/null +++ b/plugin/src/main/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobRegistry.java @@ -0,0 +1,103 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.plugin.transport.asyncquery; + +import java.io.Closeable; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Retained asynchronous PPL jobs on the owner node. + * + *

The registry is a thin wrapper around a concurrent map. It exposes the four operations {@link + * QueryJob} needs to make itself discoverable, and no more: capacity accounting, listener + * notification, and task management remain inside {@link QueryJob}. All methods are safe for + * concurrent use. + * + *

Only running jobs are registered. A job becomes visible when {@link QueryJob#create} publishes + * it and is removed on completion, DELETE, expiry, or shutdown. On {@link #close}, the registry + * stops accepting new jobs and asks each remaining job to release its resources. + */ +public final class QueryJobRegistry implements Closeable { + private final ConcurrentMap jobs = new ConcurrentHashMap<>(); + private volatile boolean closed; + + /** + * Publishes a job under its opaque ID. + * + *

If a job with the same ID is already registered, the existing job is returned and the caller + * is expected to abort the incoming duplicate. In practice, {@link QueryJobId#create} draws its + * context ID from {@link java.util.UUID#randomUUID} and collisions are not observed. + * + * @param job job to publish; its ID is used as the registry key + * @return existing job if the ID was already registered, {@code null} on successful insertion + * @throws IllegalStateException when the registry has been closed + */ + public QueryJob add(QueryJob job) { + Objects.requireNonNull(job); + if (closed) { + throw new IllegalStateException("PPL asynchronous query registry is closed"); + } + return jobs.putIfAbsent(job.getJobId(), job); + } + + /** + * Looks up a job by opaque ID. + * + * @param id opaque ID assigned by this node + * @return job when the ID resolves locally, or empty when the ID is unknown + */ + public Optional get(QueryJobId id) { + Objects.requireNonNull(id); + return Optional.ofNullable(jobs.get(id)); + } + + /** + * Removes a job only when the given ID still resolves to the given job instance. + * + * @param id opaque ID assigned by this node + * @param job job that expects to still hold the registration + * @return {@code true} when the mapping was removed + */ + public boolean remove(QueryJobId id, QueryJob job) { + Objects.requireNonNull(id); + Objects.requireNonNull(job); + return jobs.remove(id, job); + } + + /** + * Snapshot of every job currently registered. + * + *

Iteration order is unspecified. The returned collection is a defensive copy and does not + * reflect later registry mutations. + * + * @return jobs registered at the moment of the call + */ + public Collection jobs() { + return List.copyOf(jobs.values()); + } + + /** + * Stops accepting new jobs and asks every registered job to release its resources. + * + *

Each surviving job receives {@link QueryJob#discard} with a shutdown reason. The registry + * itself becomes idempotent: subsequent calls are no-ops and any future {@link #add} throws. + */ + @Override + public void close() { + if (closed) { + return; + } + closed = true; + for (QueryJob job : jobs.values()) { + job.discard("PPL asynchronous query service is closing"); + } + } +} diff --git a/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobIdTest.java b/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobIdTest.java new file mode 100644 index 00000000000..d3cb6fd5e8b --- /dev/null +++ b/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobIdTest.java @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.plugin.transport.asyncquery; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +public class QueryJobIdTest { + + @Test + public void roundTripsOwnerAndContext() { + QueryJobId id = QueryJobId.create("node-a"); + + assertEquals(id, QueryJobId.parse(id.encode())); + } + + @Test + public void rejectsMalformedIds() { + assertThrows(IllegalArgumentException.class, () -> QueryJobId.parse("")); + assertThrows(IllegalArgumentException.class, () -> QueryJobId.parse("not-an-id")); + assertThrows(IllegalArgumentException.class, () -> new QueryJobId("", "context-id")); + } +} diff --git a/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobOwnerTest.java b/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobOwnerTest.java new file mode 100644 index 00000000000..aa614bf7ac7 --- /dev/null +++ b/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobOwnerTest.java @@ -0,0 +1,85 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.plugin.transport.asyncquery; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.util.List; +import org.junit.Test; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.commons.ConfigConstants; +import org.opensearch.commons.authuser.User; + +public class QueryJobOwnerTest { + + @Test + public void capturesSecurityIdentity() { + ThreadContext context = contextWith("alice|backend-a|ppl-role|tenant-a"); + + assertEquals( + new QueryJobOwner("alice", "tenant-a", List.of("backend-a")), + QueryJobOwner.current(context)); + } + + @Test + public void acceptsUserObject() { + ThreadContext context = + contextWith(new User("alice", List.of("backend-a"), List.of("ppl-role"), null, "tenant-a")); + + assertEquals( + new QueryJobOwner("alice", "tenant-a", List.of("backend-a")), + QueryJobOwner.current(context)); + } + + @Test + public void rejectsUnknownSecurityContext() { + assertThrows( + OpenSearchSecurityException.class, () -> QueryJobOwner.current(contextWith(new Object()))); + } + + @Test + public void missingIdentityRepresentsAnUnsecuredCaller() { + ThreadContext context = new ThreadContext(Settings.EMPTY); + + assertEquals(QueryJobOwner.UNSECURED, QueryJobOwner.current(context)); + } + + @Test + public void requiresSamePrincipalTenantAndOriginalBackendRoles() { + QueryJobOwner owner = new QueryJobOwner("alice", "tenant-a", List.of("role-a")); + + owner.authorize(new QueryJobOwner("alice", "tenant-a", List.of("role-a", "newly-added-role"))); + + assertThrows( + OpenSearchSecurityException.class, + () -> owner.authorize(new QueryJobOwner("bob", "tenant-a", List.of("role-a")))); + assertThrows( + OpenSearchSecurityException.class, + () -> owner.authorize(new QueryJobOwner("alice", "tenant-b", List.of("role-a")))); + assertThrows( + OpenSearchSecurityException.class, + () -> owner.authorize(new QueryJobOwner("alice", "tenant-a", List.of()))); + } + + @Test + public void unsecuredModeRequiresAnUnsecuredCaller() { + QueryJobOwner.UNSECURED.authorize(QueryJobOwner.UNSECURED); + + assertThrows( + OpenSearchSecurityException.class, + () -> + QueryJobOwner.UNSECURED.authorize(new QueryJobOwner("alice", null, List.of("role-a")))); + } + + private static ThreadContext contextWith(Object userInfo) { + ThreadContext context = new ThreadContext(Settings.EMPTY); + context.putTransient(ConfigConstants.OPENSEARCH_SECURITY_USER_INFO_THREAD_CONTEXT, userInfo); + return context; + } +} diff --git a/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobRegistryTest.java b/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobRegistryTest.java new file mode 100644 index 00000000000..e0aeec06bb4 --- /dev/null +++ b/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobRegistryTest.java @@ -0,0 +1,99 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.plugin.transport.asyncquery; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import org.mockito.Mockito; + +public class QueryJobRegistryTest { + + @Test + public void addPublishesJobAndReturnsExistingOnCollision() { + QueryJobRegistry registry = new QueryJobRegistry(); + QueryJobId sharedId = new QueryJobId("node-a", "context-1"); + QueryJob first = jobWithId(sharedId); + QueryJob second = jobWithId(sharedId); + + assertNull(registry.add(first)); + assertSame(first, registry.add(second)); + assertSame(first, registry.get(sharedId).orElseThrow()); + } + + @Test + public void removeOnlyDropsGivenJobInstance() { + QueryJobRegistry registry = new QueryJobRegistry(); + QueryJobId sharedId = new QueryJobId("node-a", "context-1"); + QueryJob first = jobWithId(sharedId); + QueryJob replacement = jobWithId(sharedId); + + registry.add(first); + assertFalse(registry.remove(sharedId, replacement)); + assertSame(first, registry.get(sharedId).orElseThrow()); + assertTrue(registry.remove(sharedId, first)); + assertEquals(Optional.empty(), registry.get(sharedId)); + } + + @Test + public void closeDiscardsEveryJobAndRejectsFurtherAdds() { + QueryJobRegistry registry = new QueryJobRegistry(); + QueryJob first = jobWithId(newId()); + QueryJob second = jobWithId(newId()); + registry.add(first); + registry.add(second); + + registry.close(); + + Mockito.verify(first).discard("PPL asynchronous query service is closing"); + Mockito.verify(second).discard("PPL asynchronous query service is closing"); + assertThrows(IllegalStateException.class, () -> registry.add(jobWithId(newId()))); + } + + @Test + public void closeIsIdempotent() { + QueryJobRegistry registry = new QueryJobRegistry(); + QueryJob job = jobWithId(newId()); + registry.add(job); + + registry.close(); + registry.close(); + + Mockito.verify(job, Mockito.times(1)).discard(Mockito.anyString()); + } + + @Test + public void jobsReturnsDefensiveSnapshot() { + QueryJobRegistry registry = new QueryJobRegistry(); + QueryJob job = jobWithId(newId()); + registry.add(job); + + var snapshot = registry.jobs(); + registry.remove(job.getJobId(), job); + + assertTrue(snapshot.contains(job)); + assertEquals(0, registry.jobs().size()); + } + + private static final AtomicInteger COUNTER = new AtomicInteger(); + + private static QueryJobId newId() { + return new QueryJobId("node-a", "context-" + COUNTER.incrementAndGet()); + } + + private static QueryJob jobWithId(QueryJobId id) { + QueryJob job = Mockito.mock(QueryJob.class); + Mockito.when(job.getJobId()).thenReturn(id); + return job; + } +} diff --git a/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobTest.java b/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobTest.java new file mode 100644 index 00000000000..28d317b4da8 --- /dev/null +++ b/plugin/src/test/java/org/opensearch/sql/plugin/transport/asyncquery/QueryJobTest.java @@ -0,0 +1,655 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.plugin.transport.asyncquery; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.LongSupplier; +import org.junit.Test; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.ResourceNotFoundException; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.core.action.ActionListener; +import org.opensearch.sql.data.model.ExprValue; +import org.opensearch.sql.data.model.ExprValueUtils; +import org.opensearch.sql.data.type.ExprCoreType; +import org.opensearch.sql.executor.AsyncQueryExecution; +import org.opensearch.sql.executor.ExecutionEngine.QueryResponse; +import org.opensearch.sql.executor.ExecutionEngine.Schema; +import org.opensearch.sql.executor.ExecutionEngine.Schema.Column; +import org.opensearch.sql.legacy.metrics.BasicCounter; +import org.opensearch.sql.legacy.metrics.MetricName; +import org.opensearch.sql.legacy.metrics.Metrics; +import org.opensearch.sql.legacy.metrics.NumericMetric; +import org.opensearch.tasks.CancellableTask; +import org.opensearch.tasks.TaskManager; + +public class QueryJobTest { + private static final String LOCAL_NODE = "node-a"; + private static final QueryJobOwner OWNER = QueryJobOwner.UNSECURED; + private static final TimeValue KEEP_ALIVE = TimeValue.timeValueMinutes(5); + private static final TimeValue DEFAULT_WAIT = TimeValue.timeValueSeconds(5); + + @Test + public void fastSuccessReturnsDirectResultWithoutRetention() { + scenario() + .withWaitForCompletion(TimeValue.timeValueSeconds(5)) + .withCurrentResult(response(2)) + .start() + .completeAfterMillis(25) + .assertDirectSuccess(response(2), 25) + .assertNotRegistered() + .assertExecutionClosedOnce(); + } + + @Test + public void completedExecutionCanBeAttachedBeforeCompletionIsObserved() { + scenario() + .succeed(response(2)) + .withWaitForCompletion(TimeValue.timeValueSeconds(5)) + .start() + .assertDirectSuccess(response(2), 0) + .assertExecutionClosedOnce(); + } + + @Test + public void timeoutReturnsIdAndLaterGetReturnsCompleteResult() { + QueryJobScenario scenario = + scenario() + .withWaitForCompletion(TimeValue.timeValueSeconds(5)) + .start() + .fireRetention() + .assertRetainedRunning(Optional.empty()); + + scenario + .succeedAfterMillis(25, response(2)) + .get() + .assertGetResponse( + new QueryJob.Snapshot.Succeeded(Optional.of(scenario.id()), response(2), 25)); + } + + @Test + public void fastFailureReturnsDirectFailureWithoutId() { + scenario() + .withWaitForCompletion(TimeValue.timeValueSeconds(5)) + .start() + .fail(new IllegalStateException("boom")) + .assertFailure(IllegalStateException.class, "boom") + .assertNotRegistered() + .assertExecutionClosedOnce(); + } + + @Test + public void executionStartFailureCompletesJobAndReleasesTask() { + QueryJobScenario scenario = + scenario() + .withWaitForCompletion(TimeValue.timeValueSeconds(5)) + .withExecutionStarter( + ignored -> { + throw new IllegalStateException("execution did not start"); + }); + scenario.start(); + scenario + .assertFailure(IllegalStateException.class, "execution did not start") + .assertNotRegistered() + .assertTaskClosed(); + } + + @Test + public void retainedFailureRetainsFailureSnapshotAndRecordsMetric() { + NumericMetric customerFailures = + new NumericMetric<>(MetricName.PPL_FAILED_REQ_COUNT_CUS.getName(), new BasicCounter()); + Metrics.getInstance().registerMetric(customerFailures); + try { + QueryJobScenario scenario = + scenario() + .withWaitForCompletion(TimeValue.timeValueSeconds(5)) + .start() + .fireRetention() + .fail(new IllegalArgumentException("invalid query")) + .get(); + + scenario.assertGetResponse( + new QueryJob.Snapshot.Failed( + Optional.of(scenario.id()), + new QueryJob.Failure("IllegalArgumentException", "invalid query"), + 0)); + assertEquals(Long.valueOf(1), customerFailures.getValue()); + } finally { + Metrics.getInstance().unregisterMetric(customerFailures.getName()); + } + } + + @Test + public void expiredGetRemovesJobAndCancelsTask() { + CancellableTask task = runningTask(); + QueryJobScenario scenario = + scenario().withTask(task).start().fireRetention().advanceMillis(KEEP_ALIVE.millis()); + + ResourceNotFoundException error = assertThrows(ResourceNotFoundException.class, scenario::get); + + assertEquals("PPL asynchronous query not found", error.getMessage()); + scenario.assertNotRegistered(); + verify(task).cancel("PPL asynchronous query expired"); + } + + @Test + public void expiryTimerRemovesJobAfterKeepAliveElapses() { + CancellableTask task = runningTask(); + QueryJobScenario scenario = + scenario().withTask(task).start().fireRetention().assertRegistered(); + + scenario.advanceMillis(KEEP_ALIVE.millis()).fireExpiry(); + + scenario.assertNotRegistered(); + verify(task).cancel("PPL asynchronous query expired"); + } + + @Test + public void deleteCancelsRunningJobAndReturnsCancelledStatus() { + CancellableTask task = runningTask(); + QueryJobScenario scenario = scenario().withTask(task).start().fireRetention().delete(); + + scenario.assertDeleteStatus(QueryJob.Status.CANCELLED); + verify(task).cancel("PPL asynchronous query cancelled by user"); + scenario.assertNotRegistered().assertExecutionClosedOnce(); + assertThrows(ResourceNotFoundException.class, scenario::get); + } + + @Test + public void deleteReturnsRetainedTerminalStatus() { + CancellableTask task = runningTask(); + QueryJobScenario scenario = + scenario() + .withTask(task) + .withWaitForCompletion(TimeValue.timeValueSeconds(5)) + .withCurrentResult(response(1)) + .start() + .fireRetention() + .complete() + .delete(); + + scenario.assertDeleteStatus(QueryJob.Status.SUCCEEDED); + verify(task, never()).cancel(org.mockito.ArgumentMatchers.anyString()); + scenario.assertNotRegistered().assertExecutionClosedOnce(); + } + + @Test + public void deleteReturnsNotFoundAfterExpirationBoundary() { + QueryJobScenario scenario = scenario().start().fireRetention(); + + scenario.advanceMillis(KEEP_ALIVE.millis()); + + assertThrows(ResourceNotFoundException.class, scenario::delete); + scenario.assertNotRegistered(); + } + + @Test + public void getRenewsLeaseAndResetsExpiryTimer() { + QueryJobScenario scenario = + scenario() + .withKeepAlive(TimeValue.timeValueSeconds(1)) + .start() + .fireRetention() + .advanceMillis(500) + .getWithKeepAlive(TimeValue.timeValueSeconds(2)) + .advanceMillis(1_999) + .assertRegistered(); + + // Original lease would have expired at 1000ms; renewed lease survives to 2499ms. + scenario.advanceMillis(1).fireExpiry().assertNotRegistered(); + } + + @Test + public void cancellationUsesTaskManagerWhenAttached() { + TaskManager taskManager = mock(TaskManager.class); + CancellableTask task = runningTask(); + scenario().withTaskManager(taskManager).withTask(task).start().fireRetention().delete(); + + verify(taskManager) + .cancelTaskAndDescendants( + org.mockito.ArgumentMatchers.eq(task), + org.mockito.ArgumentMatchers.eq("PPL asynchronous query cancelled by user"), + org.mockito.ArgumentMatchers.eq(false), + org.mockito.ArgumentMatchers.any()); + } + + @Test + public void shutdownDiscardsRunningJobAndClosesResources() { + CancellableTask task = runningTask(); + QueryJobScenario scenario = + scenario().withTask(task).withWaitForCompletion(TimeValue.timeValueSeconds(5)).start(); + + scenario.closeRegistry(); + + scenario.assertNotRegistered().assertExecutionClosedOnce(); + verify(task).cancel("PPL asynchronous query service is closing"); + } + + @Test + public void unauthorizedCallerCannotAccessOwnedJob() { + QueryJobOwner alice = new QueryJobOwner("alice", "tenant-a", List.of("role-a")); + QueryJobOwner bob = new QueryJobOwner("bob", "tenant-a", List.of("role-a")); + QueryJobScenario scenario = scenario().withOwner(alice).start().fireRetention(); + + QueryJob job = scenario.job(); + assertThrows(OpenSearchSecurityException.class, () -> job.getOwner().authorize(bob)); + job.getOwner().authorize(alice); + } + + @Test + public void lateExecutionAttachmentAfterDeleteIsClosed() { + TrackingExecution execution = new TrackingExecution(response(1)); + QueryJobScenario scenario = scenario().withExecution(execution); + scenario + .withExecutionStarter( + ignored -> { + // Retention fires and delete removes the job before the execution attaches. + scenario.fireRetention(); + QueryJobId retainedId = + ((QueryJob.Snapshot.Running) scenario.initialResponse.get()).jobId(); + scenario.registry.get(retainedId).orElseThrow().cancel("test delete"); + return execution; + }) + .start(); + + assertEquals(1, execution.closes.get()); + assertTrue(scenario.registry.jobs().isEmpty()); + } + + @Test + public void finalSnapshotDefensivelyCopiesRows() { + List rows = new ArrayList<>(); + rows.add(ExprValueUtils.stringValue("first")); + QueryResponse response = + new QueryResponse( + new Schema(List.of(new Column("state", null, ExprCoreType.STRING))), rows, null); + + QueryJobScenario scenario = + scenario() + .withCurrentResult(response) + .withWaitForCompletion(TimeValue.timeValueSeconds(5)) + .start() + .complete(); + rows.add(ExprValueUtils.stringValue("second")); + + scenario.assertSucceededRowCount(1); + } + + @Test + public void successfulCompletionRequiresFinalResultToBeVisible() { + scenario() + .withWaitForCompletion(TimeValue.timeValueSeconds(5)) + .start() + .complete() + .assertFailure(IllegalStateException.class) + .assertExecutionClosedOnce(); + } + + @Test + public void retentionListenerFailureAbortsUndeliverableJob() { + CancellableTask task = runningTask(); + QueryJobScenario scenario = + scenario() + .withTask(task) + .withResponseListener(new FailingListener()) + .withWaitForCompletion(TimeValue.timeValueSeconds(5)) + .start(); + + scenario.fireRetention(); + + scenario.assertNotRegistered().assertExecutionClosedOnce(); + verify(task).cancel("PPL asynchronous query startup failed"); + } + + private QueryJobScenario scenario() { + return new QueryJobScenario(); + } + + private static CancellableTask runningTask() { + CancellableTask task = mock(CancellableTask.class); + when(task.isCancelled()).thenReturn(false); + return task; + } + + private static QueryResponse response(int rowCount) { + Schema schema = new Schema(List.of(new Column("state", null, ExprCoreType.STRING))); + return new QueryResponse( + schema, + java.util.stream.IntStream.range(0, rowCount) + .mapToObj(i -> ExprValueUtils.stringValue("state-" + i)) + .toList(), + null); + } + + /** Deterministic fluent scenario driving one {@link QueryJob} through its lifecycle. */ + private final class QueryJobScenario { + private final AtomicLong clock = new AtomicLong(1_000); + private final QueryJobRegistry registry = new QueryJobRegistry(); + private final FakeScheduler scheduler = new FakeScheduler(); + private QueryJobOwner owner = OWNER; + private TimeValue keepAlive = KEEP_ALIVE; + private TimeValue waitForCompletion = DEFAULT_WAIT; + private CancellableTask task = mock(CancellableTask.class); + private final AtomicInteger taskReleaseCount = new AtomicInteger(); + private TaskManager taskManager; + private TrackingExecution trackingExecution = new TrackingExecution(null); + private AsyncQueryExecution execution = trackingExecution; + private Function executionStarter = ignored -> execution; + private ActionListener responseListener; + private final AtomicReference initialResponse = new AtomicReference<>(); + private final AtomicReference failure = new AtomicReference<>(); + private final AtomicInteger responseCount = new AtomicInteger(); + private QueryJob.Snapshot getResponse; + private QueryJob.Status deleteStatus; + private QueryJob job; + + private QueryJobScenario withOwner(QueryJobOwner owner) { + this.owner = owner; + return this; + } + + private QueryJobScenario withKeepAlive(TimeValue keepAlive) { + this.keepAlive = keepAlive; + return this; + } + + private QueryJobScenario withWaitForCompletion(TimeValue wait) { + this.waitForCompletion = wait; + return this; + } + + private QueryJobScenario withTask(CancellableTask task) { + this.task = task; + return this; + } + + private QueryJobScenario withTaskManager(TaskManager taskManager) { + this.taskManager = taskManager; + return this; + } + + private QueryJobScenario withCurrentResult(QueryResponse response) { + trackingExecution.setCurrent(response); + return this; + } + + private QueryJobScenario withExecution(AsyncQueryExecution execution) { + this.execution = execution; + trackingExecution = execution instanceof TrackingExecution tracking ? tracking : null; + executionStarter = ignored -> this.execution; + return this; + } + + private QueryJobScenario withExecutionStarter( + Function starter) { + this.executionStarter = starter; + return this; + } + + private QueryJobScenario withResponseListener(ActionListener listener) { + this.responseListener = listener; + return this; + } + + private QueryJobScenario start() { + QueryJob.JobTask jobTask = new QueryJob.JobTask(task, taskReleaseCount::incrementAndGet); + ActionListener listener = + responseListener != null ? responseListener : defaultListener(); + job = + QueryJob.create( + LOCAL_NODE, + registry, + (LongSupplier) clock::get, + scheduler, + taskManager, + jobTask, + owner, + keepAlive, + waitForCompletion, + executionStarter, + listener); + return this; + } + + private ActionListener defaultListener() { + return ActionListener.wrap( + snapshot -> { + initialResponse.set(snapshot); + responseCount.incrementAndGet(); + }, + failure::set); + } + + private QueryJobScenario succeed(QueryResponse response) { + trackingExecution.succeed(response); + return this; + } + + private QueryJobScenario complete() { + trackingExecution.complete(); + return this; + } + + private QueryJobScenario completeAfterMillis(long elapsedMillis) { + clock.addAndGet(elapsedMillis); + trackingExecution.complete(); + return this; + } + + private QueryJobScenario succeedAfterMillis(long elapsedMillis, QueryResponse response) { + clock.addAndGet(elapsedMillis); + trackingExecution.succeed(response); + return this; + } + + private QueryJobScenario fail(Exception exception) { + trackingExecution.fail(exception); + return this; + } + + private QueryJobScenario advanceMillis(long delta) { + clock.addAndGet(delta); + return this; + } + + private QueryJobScenario fireRetention() { + scheduler.fireNext(); + return this; + } + + private QueryJobScenario fireExpiry() { + scheduler.fireNext(); + return this; + } + + private QueryJobScenario get() { + getResponse = job.get(null); + return this; + } + + private QueryJobScenario getWithKeepAlive(TimeValue newKeepAlive) { + getResponse = job.get(newKeepAlive); + return this; + } + + private QueryJobScenario delete() { + deleteStatus = job.cancel("PPL asynchronous query cancelled by user"); + return this; + } + + private QueryJobScenario closeRegistry() { + registry.close(); + return this; + } + + private QueryJobScenario assertDirectSuccess(QueryResponse expected, long tookMillis) { + assertEquals( + new QueryJob.Snapshot.Succeeded(Optional.empty(), expected, tookMillis), + initialResponse.get()); + return this; + } + + private QueryJobScenario assertRetainedRunning(Optional expected) { + assertEquals(new QueryJob.Snapshot.Running(id(), expected), initialResponse.get()); + return this; + } + + private QueryJobScenario assertGetResponse(QueryJob.Snapshot expected) { + assertEquals(expected, getResponse); + return this; + } + + private QueryJobScenario assertDeleteStatus(QueryJob.Status expected) { + assertEquals(expected, deleteStatus); + return this; + } + + private QueryJobScenario assertSucceededRowCount(int expected) { + assertTrue(initialResponse.get() instanceof QueryJob.Snapshot.Succeeded); + QueryJob.Snapshot.Succeeded succeeded = (QueryJob.Snapshot.Succeeded) initialResponse.get(); + assertEquals(expected, succeeded.response().getResults().size()); + return this; + } + + private QueryJobScenario assertFailure(Class type) { + assertTrue(type.isInstance(failure.get())); + return this; + } + + private QueryJobScenario assertFailure(Class type, String message) { + assertTrue(type.isInstance(failure.get())); + assertEquals(message, failure.get().getMessage()); + return this; + } + + private QueryJobScenario assertRegistered() { + assertTrue(registry.get(job.getJobId()).isPresent()); + return this; + } + + private QueryJobScenario assertNotRegistered() { + assertTrue(registry.get(job.getJobId()).isEmpty()); + return this; + } + + private QueryJobScenario assertExecutionClosedOnce() { + if (trackingExecution != null) { + assertEquals(1, trackingExecution.closes.get()); + } + return this; + } + + private QueryJobScenario assertTaskClosed() { + assertEquals(1, taskReleaseCount.get()); + return this; + } + + private QueryJobId id() { + return job.getJobId(); + } + + private QueryJob job() { + return job; + } + } + + private static final class FakeScheduler implements QueryJob.Scheduler { + private final Deque pending = new ArrayDeque<>(); + + @Override + public QueryJob.Cancellable schedule(long delayMillis, Runnable task) { + Pending entry = new Pending(delayMillis, task); + pending.addLast(entry); + return () -> pending.remove(entry); + } + + void fireNext() { + Pending entry = pending.pollFirst(); + assertTrue("No scheduled task", entry != null); + entry.task.run(); + } + + private record Pending(long delayMillis, Runnable task) {} + } + + private static final class TrackingExecution implements AsyncQueryExecution { + private final AtomicReference current; + private final CompletableFuture completion = new CompletableFuture<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger closes = new AtomicInteger(); + + private TrackingExecution(QueryResponse current) { + this.current = new AtomicReference<>(current); + } + + private void setCurrent(QueryResponse response) { + current.set(response); + } + + private void succeed(QueryResponse response) { + current.set(response); + completion.complete(null); + } + + private void complete() { + completion.complete(null); + } + + private void fail(Exception failure) { + completion.completeExceptionally(failure); + } + + @Override + public Optional currentResult() { + reads.incrementAndGet(); + return Optional.ofNullable(current.get()); + } + + @Override + public CompletionStage completion() { + return completion; + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + closes.incrementAndGet(); + } + } + } + + private static final class FailingListener implements ActionListener { + @Override + public void onResponse(QueryJob.Snapshot snapshot) { + throw new IllegalStateException("listener rejected snapshot"); + } + + @Override + public void onFailure(Exception e) { + // no-op; QueryJob may deliver a subsequent failure once discard runs. + } + } +} diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/DefaultAsyncQueryExecution.java b/ppl/src/main/java/org/opensearch/sql/ppl/DefaultAsyncQueryExecution.java new file mode 100644 index 00000000000..d0c1e5b3921 --- /dev/null +++ b/ppl/src/main/java/org/opensearch/sql/ppl/DefaultAsyncQueryExecution.java @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.ppl; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import org.opensearch.sql.common.response.ResponseListener; +import org.opensearch.sql.executor.AsyncQueryExecution; +import org.opensearch.sql.executor.ExecutionEngine.QueryResponse; + +/** + * Final-result implementation of the asynchronous execution contract. + * + *

This adapter keeps the existing callback-based query execution unchanged. Until partial-result + * producers are added, {@link #currentResult()} is empty while execution is running and exposes the + * final response immediately before successful completion is published. + */ +final class DefaultAsyncQueryExecution + implements AsyncQueryExecution, ResponseListener { + private final CompletableFuture completion = new CompletableFuture<>(); + private volatile QueryResponse finalResult; + + @Override + public void onResponse(QueryResponse response) { + finalResult = Objects.requireNonNull(response); + completion.complete(null); + } + + @Override + public void onFailure(Exception failure) { + completion.completeExceptionally(Objects.requireNonNull(failure)); + } + + @Override + public Optional currentResult() { + return Optional.ofNullable(finalResult); + } + + @Override + public CompletionStage completion() { + return completion; + } + + @Override + public void close() {} +} diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/PPLService.java b/ppl/src/main/java/org/opensearch/sql/ppl/PPLService.java index e2572b5f0da..b8ac519fbbe 100644 --- a/ppl/src/main/java/org/opensearch/sql/ppl/PPLService.java +++ b/ppl/src/main/java/org/opensearch/sql/ppl/PPLService.java @@ -18,6 +18,7 @@ import org.opensearch.sql.common.setting.Settings; import org.opensearch.sql.common.utils.QueryContext; import org.opensearch.sql.executor.AnalyzeResponse; +import org.opensearch.sql.executor.AsyncQueryExecution; import org.opensearch.sql.executor.ExecutionEngine.ExplainResponse; import org.opensearch.sql.executor.QueryManager; import org.opensearch.sql.executor.QueryType; @@ -88,6 +89,40 @@ public void execute( } } + /** + * Starts query execution and immediately returns its lifecycle-facing handle. + * + *

The existing callback execution remains internal to the PPL execution module. This + * final-only implementation exposes no current result until the callback publishes the + * authoritative response. + * + * @param request PPL query request + * @param anonymizedQuerySink receives anonymized query text for metrics + * @return lifecycle-facing asynchronous execution handle + */ + public AsyncQueryExecution executeAsync( + PPLQueryRequest request, Consumer anonymizedQuerySink) { + DefaultAsyncQueryExecution execution = new DefaultAsyncQueryExecution(); + execute( + request, + execution, + new ResponseListener<>() { + @Override + public void onResponse(ExplainResponse response) { + execution.onFailure( + new IllegalStateException( + "Asynchronous query execution received an explain response")); + } + + @Override + public void onFailure(Exception e) { + execution.onFailure(e); + } + }, + anonymizedQuerySink); + return execution; + } + /** * Explain the query in {@link PPLQueryRequest} using {@link ResponseListener} to get and format * explain response. diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/DefaultAsyncQueryExecutionTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/DefaultAsyncQueryExecutionTest.java new file mode 100644 index 00000000000..f4a93f7fc05 --- /dev/null +++ b/ppl/src/test/java/org/opensearch/sql/ppl/DefaultAsyncQueryExecutionTest.java @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.ppl; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.opensearch.sql.data.model.ExprValueUtils; +import org.opensearch.sql.data.type.ExprCoreType; +import org.opensearch.sql.executor.ExecutionEngine.QueryResponse; +import org.opensearch.sql.executor.ExecutionEngine.Schema; +import org.opensearch.sql.executor.ExecutionEngine.Schema.Column; + +public class DefaultAsyncQueryExecutionTest { + + @Test + public void finalResultIsVisibleBeforeSuccessfulCompletionNotification() { + DefaultAsyncQueryExecution execution = new DefaultAsyncQueryExecution(); + AtomicBoolean visibleFromCompletion = new AtomicBoolean(); + execution + .completion() + .whenComplete( + (ignored, failure) -> + visibleFromCompletion.set( + failure == null + && execution.currentResult().orElseThrow().getResults().size() == 1)); + + execution.onResponse(response("final")); + + assertTrue(execution.completion().toCompletableFuture().isDone()); + assertFalse(execution.completion().toCompletableFuture().isCompletedExceptionally()); + assertTrue(visibleFromCompletion.get()); + } + + @Test + public void failureCompletesExceptionallyWithoutPublishingRows() { + DefaultAsyncQueryExecution execution = new DefaultAsyncQueryExecution(); + + execution.onFailure(new IllegalStateException("boom")); + + CompletionException failure = + assertThrows( + CompletionException.class, () -> execution.completion().toCompletableFuture().join()); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertTrue(execution.currentResult().isEmpty()); + } + + private static QueryResponse response(String value) { + return new QueryResponse( + new Schema(List.of(new Column("state", null, ExprCoreType.STRING))), + List.of(ExprValueUtils.stringValue(value)), + null); + } +} diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/PPLServiceTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/PPLServiceTest.java index fea5e3d4030..8a83dc6923b 100644 --- a/ppl/src/test/java/org/opensearch/sql/ppl/PPLServiceTest.java +++ b/ppl/src/test/java/org/opensearch/sql/ppl/PPLServiceTest.java @@ -20,6 +20,7 @@ import org.mockito.junit.MockitoJUnitRunner; import org.opensearch.sql.common.response.ResponseListener; import org.opensearch.sql.common.setting.Settings; +import org.opensearch.sql.executor.AsyncQueryExecution; import org.opensearch.sql.executor.DefaultQueryManager; import org.opensearch.sql.executor.ExecutionEngine; import org.opensearch.sql.executor.ExecutionEngine.ExplainResponse; @@ -115,6 +116,27 @@ public void testExecuteShouldPass() { getExplainListener(false)); } + @Test + public void testExecuteAsyncReturnsCompletedFinalResult() { + QueryResponse response = new QueryResponse(schema, Collections.emptyList(), Cursor.None); + doAnswer( + invocation -> { + ResponseListener listener = invocation.getArgument(4); + listener.onResponse(response); + return null; + }) + .when(queryService) + .execute(any(), any(), any(), anyBoolean(), any()); + + AsyncQueryExecution execution = + pplService.executeAsync( + new PPLQueryRequest("search source=t a=1", null, QUERY), + PPLService.NO_ANONYMIZED_QUERY_SINK); + + execution.completion().toCompletableFuture().join(); + Assert.assertSame(response, execution.currentResult().orElseThrow()); + } + @Test public void testExecuteCsvFormatShouldPass() { doAnswer(