Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions sandbox/qa/dsl-query-types/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# DSL Query-Type REST Tests

Per-query-type integration tests for the parquet/composite data format, run against a **live, external**
OpenSearch server (your `./gradlew run` sandbox). One resource folder per DSL query type; each is
provisioned into a parquet-backed index over REST and its response validated against a committed
expected answer.

This module seeds the suite with a small set of query types. It is intended to grow one folder at a
time as more query types are covered.

## Why standalone (and why REST)

The DSL/analytics/parquet plugin stack targets **JDK 25**. An in-JVM `internalClusterTest` would load
those plugins into the test JVM (pinning it to JDK 25); these tests instead talk HTTP to an
already-running server via `OpenSearchRestTestCase`, so the test JVM loads no plugins.

The module applies **only** `opensearch.standalone-rest-test` and depends on nothing but
`:test:framework`. The dataset runner infrastructure (`Dataset` / `DatasetProvisioner` /
`DatasetQueryRunner`) is a self-contained local copy in this module.

## Layout — one resource folder per query type

Each query type is a folder under `src/test/resources/datasets/<type>/`:

```
datasets/
term/
mapping.json # index mapping (keeps the literal "number_of_shards" token)
bulk.json # sample docs (NDJSON)
dsl/q1.json # the DSL query body (auto-discovered as q<N>.json)
dsl/expected/q1.json # the expected answer (see below)
```

`DatasetProvisioner` splices the canonical parquet/composite settings into each `mapping.json` at
provision time — this is the **single place** those settings live:

```
"index.pluggable.dataformat.enabled": true,
"index.pluggable.dataformat": "composite",
"index.composite.primary_data_format": "parquet",
"index.composite.secondary_data_formats": "lucene"
```

## Java

```
Dataset.java ← dataset descriptor (folder name == index name == query type)
DatasetProvisioner.java ← reads mapping/bulk, injects parquet settings, creates + ingests
DatasetQueryRunner.java ← auto-discovers dsl/q*.json
DslQueryTypeCatalog.java ← one entry per type: type, family, Dataset
DslQueryTypesIT.java ← provisions each type + runs its query, validates against the expected answer
DslResponseValidator.java ← compares a response against the committed expected answer
DslTermQueryIT.java ← focused term-query correctness test (dataset: people)
```

## What is asserted

Each expected answer (`datasets/<type>/dsl/expected/q<N>.json`) is the **true answer** produced on a
vanilla OpenSearch index (default Lucene backend, no parquet settings) and committed. `DslQueryTypesIT`
provisions the dataset with parquet enabled, runs the query, and validates the parquet response against
that expected answer via `DslResponseValidator` (order-independent, numeric tolerance). A type is green
only when the parquet response matches the true answer, so any place parquet deviates surfaces as red.

### Single-valued-tags variants

`term` / `bool` store `tags` as a multi-valued array, which parquet rejects at ingest ("Cannot accept
multiple values for field [tags] of type [keyword]"). The `*_scalar` variants are identical except
`tags` is a single scalar value, which parquet accepts — isolating the multi-value array as the sole
cause and confirming the query type itself is supported once `tags` is single-valued.

## Running

Start the server with the plugin stack, then:

```bash
# Default: localhost:9200 (cluster runTask)
./gradlew :sandbox:qa:dsl-query-types:restTest -Dsandbox.enabled=true

# Custom cluster
./gradlew :sandbox:qa:dsl-query-types:restTest -Dsandbox.enabled=true -PrestCluster=host:port

# Just this test
./gradlew :sandbox:qa:dsl-query-types:restTest -Dsandbox.enabled=true \
--tests "org.opensearch.dsl.types.DslQueryTypesIT"
```

## Adding a query type

Create `datasets/<type>/` with `mapping.json` + `bulk.json` + `dsl/q1.json` + `dsl/expected/q1.json`
(the expected answer generated on a vanilla index), and add a matching `e("<type>", family)` entry to
`DslQueryTypeCatalog.all()`. Additional queries per type are auto-discovered as `dsl/q2.json`, ….
62 changes: 62 additions & 0 deletions sandbox/qa/dsl-query-types/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

/*
* Per-query-type DSL REST integration tests for the dsl-query-executor plugin.
*
* Runs against an EXTERNAL, already-running OpenSearch server (the one started with
* `./gradlew run -Dsandbox.enabled=true -PinstalledPlugins=[...]` with the analytics/DataFusion/DSL
* stack). Tests talk to it purely over HTTP via OpenSearchRestTestCase, so the test JVM never loads
* the plugins — the server runs on JDK 25 with the full stack, the tests just send _search requests.
*
* Deliberately STANDALONE: it applies only opensearch.standalone-rest-test and depends on nothing but
* :test:framework (added by that plugin). It does NOT depend on sandbox/qa/analytics-engine-rest or
* sandbox/plugins/test-ppl-frontend, so it is unaffected by their build-classpath state.
*
* Each query type is a folder under src/test/resources/datasets/<type>/ (mapping.json + bulk.json +
* dsl/q{N}.json + dsl/expected/q{N}.json golden). DslQueryTypeCatalog lists the types; DslQueryTypesIT
* turns every (type, queryNumber) pair into an independent parameterized test that provisions, queries,
* and validates against the golden.
*/

apply plugin: 'opensearch.standalone-rest-test'

// The DSL/analytics plugin stack the server runs targets JDK 25; compile the tests at the same level.
java {
sourceCompatibility = JavaVersion.toVersion(25)
targetCompatibility = JavaVersion.toVersion(25)
}

// No dependencies block: opensearch.standalone-rest-test already puts :test:framework on the test
// classpath, which provides OpenSearchRestTestCase, the REST client, entityAsMap, and assertions.
// Everything else goes to the server over HTTP.

// ── External-cluster runner ──────────────────────────────────────────────────
// Run against a cluster you started yourself (no testClusters lifecycle):
// ./gradlew :sandbox:qa:dsl-query-types:restTest -Dsandbox.enabled=true
// ./gradlew :sandbox:qa:dsl-query-types:restTest -Dsandbox.enabled=true -PrestCluster=host:port
// Defaults to localhost:9200 (the `./gradlew run` server, cluster name runTask).
//
// Goldens (datasets/<type>/dsl/expected/q<N>.json) are the TRUE answer — generated out-of-band by
// running each dataset on a VANILLA OpenSearch index (default Lucene backend, no parquet settings) and
// committed. This suite only validates the parquet response against them.
tasks.register('restTest', Test) {
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
include '**/*IT.class'
def cluster = findProperty('restCluster') ?: 'localhost:9200'
def clusterName = findProperty('restClusterName') ?: 'runTask'
systemProperty 'tests.rest.cluster', cluster
systemProperty 'tests.cluster', cluster
systemProperty 'tests.clustername', clusterName
systemProperty 'tests.security.manager', 'false'
systemProperty 'tests.rest.load_packaged', 'false'
testLogging {
exceptionFormat = 'full'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

package org.opensearch.dsl.types;

/**
* Descriptor for a per-query-type test dataset loaded from {@code resources/datasets/{name}/}.
* <p>
* A dataset consists of:
* <ul>
* <li>{@code mapping.json} — index mapping + settings (keeps the literal {@code "number_of_shards"}
* token so {@link DatasetProvisioner} can splice in the parquet/composite settings)</li>
* <li>{@code bulk.json} — bulk-indexable documents (NDJSON)</li>
* <li>{@code dsl/q{N}.json} — DSL query bodies (auto-discovered by {@link DatasetQueryRunner})</li>
* </ul>
* <p>
* A local copy tailored to this standalone module — it does not depend on the analytics-engine-rest
* test infrastructure (which drags in the test-ppl-frontend build).
*/
public final class Dataset {

/** The dataset name == directory under {@code resources/datasets/}. */
public final String name;

/** The index name to provision the dataset into. */
public final String indexName;

public Dataset(String name, String indexName) {
this.name = name;
this.indexName = indexName;
}

/** Path to the mapping resource. */
public String mappingResourcePath() {
return "datasets/" + name + "/mapping.json";
}

/** Path to the bulk data resource. */
public String bulkResourcePath() {
return "datasets/" + name + "/bulk.json";
}

/** Path to a query resource for the given language and query number. */
public String queryResourcePath(String language, String extension, int queryNumber) {
return "datasets/" + name + "/" + language + "/q" + queryNumber + "." + extension;
}

/** Path to the golden expected-response resource for the given language and query number. */
public String expectedResponseResourcePath(String language, int queryNumber) {
return "datasets/" + name + "/" + language + "/expected/q" + queryNumber + ".json";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

package org.opensearch.dsl.types;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.opensearch.client.Request;
import org.opensearch.client.Response;
import org.opensearch.client.ResponseException;
import org.opensearch.client.RestClient;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;

/**
* Provisions a {@link Dataset} into a parquet/composite index over REST: reads {@code mapping.json}
* and {@code bulk.json} from the dataset's resource dir, splices in the parquet data-format settings,
* creates the index, and bulk-ingests the docs.
*
* <p>The injected settings are the canonical form required by the DSL/analytics engine:
* <pre>
* "index.pluggable.dataformat.enabled": true,
* "index.pluggable.dataformat": "composite",
* "index.composite.primary_data_format": "parquet",
* "index.composite.secondary_data_formats": "lucene"
* </pre>
* so this is the single place index-creation settings live for the whole module.
*
* <p>Local copy tailored to this standalone module (no dependency on analytics-engine-rest).
*/
public final class DatasetProvisioner {

private static final Logger logger = LogManager.getLogger(DatasetProvisioner.class);

private DatasetProvisioner() {}

/** Provision the dataset into its parquet index. Throws (propagating the HTTP error) if the
* backend rejects the mapping — the expected path for geo/nested field shapes. */
public static void provision(RestClient client, Dataset dataset) throws IOException {
// Delete if it already exists — ignore "not found".
try {
client.performRequest(new Request("DELETE", "/" + dataset.indexName));
} catch (ResponseException e) {
// index may not exist — ignore
}

String mapping = loadResource(dataset.mappingResourcePath());
Request createIndex = new Request("PUT", "/" + dataset.indexName);
createIndex.setJsonEntity(injectParquetSettings(mapping)); // may throw ResponseException (e.g. geo/nested → HTTP 400)
client.performRequest(createIndex);

String bulk = loadResource(dataset.bulkResourcePath());
Request bulkRequest = new Request("POST", "/" + dataset.indexName + "/_bulk");
bulkRequest.addParameter("refresh", "true");
bulkRequest.setJsonEntity(bulk);
bulkRequest.setOptions(bulkRequest.getOptions().toBuilder().addHeader("Content-Type", "application/x-ndjson").build());
Response bulkResponse = client.performRequest(bulkRequest);
assertEquals("bulk ingest failed for " + dataset.indexName, 200, bulkResponse.getStatusLine().getStatusCode());
// The _bulk API returns HTTP 200 even when individual items fail (e.g. a parquet/composite
// index sets index.append_only.enabled, which rejects custom document ids). Fail loudly on
// per-item errors so a silent zero-ingest can't masquerade as a successful provision.
String bulkBody = new String(bulkResponse.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8);
if (bulkBody.contains("\"errors\":true")) {
throw new IOException("bulk ingest reported item errors for " + dataset.indexName + ": " + bulkBody);
}

Request flush = new Request("POST", "/" + dataset.indexName + "/_flush");
flush.addParameter("force", "true");
client.performRequest(flush);

// Wait for the primary to be active before the first query. YELLOW (not GREEN): on a single-node
// dev server any replica stays unassigned forever, which would time out a green wait.
Request health = new Request("GET", "/_cluster/health/" + dataset.indexName);
health.addParameter("wait_for_status", "yellow");
health.addParameter("wait_for_no_initializing_shards", "true");
health.addParameter("timeout", "60s");
client.performRequest(health);

logger.info("Dataset [{}] provisioned into parquet index [{}]", dataset.name, dataset.indexName);
}

/**
* Splice the parquet/composite data-format settings into the mapping body's settings block,
* anchored on the literal {@code "number_of_shards"} token. {@code secondary_data_formats} is the
* single string {@code "lucene"} so text-search predicates keep a Lucene backend.
*/
private static String injectParquetSettings(String mappingBody) {
return mappingBody.replace(
"\"number_of_shards\"",
"\"index.pluggable.dataformat.enabled\": true, "
+ "\"index.pluggable.dataformat\": \"composite\", "
+ "\"index.composite.primary_data_format\": \"parquet\", "
+ "\"index.composite.secondary_data_formats\": \"lucene\", "
+ "\"number_of_shards\""
);
}

/** Load a classpath resource as a UTF-8 string (trailing newline preserved for ndjson). */
public static String loadResource(String path) throws IOException {
try (InputStream is = DatasetProvisioner.class.getClassLoader().getResourceAsStream(path)) {
assertNotNull("Resource not found: " + path, is);
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String content = reader.lines().collect(Collectors.joining("\n"));
return content.isEmpty() ? content : content + "\n";
}
}
}
}
Loading